android/ui: implement outgoing taildrop support (#242)

* android/ui: implement outgoing taildrop support

Updates tailscale/corp#18202

Adds share activity to handle outgoing taildrop requests.

This unbreaks the WaitingFiles notification for incoming files, but does not yet properly handle them.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android/ui: add transfer ID to outgoing file transfers (#245)

Helps track status of transfers.

Updates #ENG-2868

Signed-off-by: Percy Wegmann <percy@tailscale.com>

* android/ui: taildrop string change

Updates tailscale/corp#18202

Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
Signed-off-by: Jonathan Nobels <jnobels@gmail.com>

* android: bumping oss to pick up new taildrop support

Updates tailscale/corp#18202

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: remove write storage permission check

Updates tailscale/corp#18202

This is not required and the jni callback does't actually do what we need in the new client.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Signed-off-by: Percy Wegmann <percy@tailscale.com>
Signed-off-by: Jonathan Nobels <jnobels@gmail.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
pull/251/head
Jonathan Nobels 2 months ago committed by GitHub
parent db3ba696eb
commit e59112a8fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -48,6 +48,14 @@
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name="ShareActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@ -73,6 +81,8 @@
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<receiver
android:name="IPNReceiver"
android:exported="true">

@ -62,7 +62,7 @@ import java.util.Locale
import java.util.Objects
class App : Application(), libtailscale.AppContext {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
const val STATUS_CHANNEL_ID = "tailscale-status"
@ -82,7 +82,7 @@ class App : Application(), libtailscale.AppContext {
@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
val f: Fragment = act.getFragmentManager().findFragmentByTag(PEER_TAG)
val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}
@ -245,7 +245,7 @@ class App : Application(), libtailscale.AppContext {
val manu = Build.MANUFACTURER
var model = Build.MODEL
// Strip manufacturer from model.
val idx = model.toLowerCase(Locale.getDefault()).indexOf(manu.toLowerCase(Locale.getDefault()))
val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault()))
if (idx != -1) {
model = model.substring(idx + manu.length).trim()
}
@ -267,10 +267,10 @@ class App : Application(), libtailscale.AppContext {
fun attachPeer(act: Activity) {
act.runOnUiThread(
Runnable {
val ft: FragmentTransaction = act.getFragmentManager().beginTransaction()
val ft: FragmentTransaction = act.fragmentManager.beginTransaction()
ft.add(Peer(), PEER_TAG)
ft.commit()
act.getFragmentManager().executePendingTransactions()
act.fragmentManager.executePendingTransactions()
})
}
@ -306,7 +306,7 @@ class App : Application(), libtailscale.AppContext {
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
get() {
val info: PackageInfo
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES)
info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
for (signature in info.signatures) {
return signature.toByteArray()
}
@ -316,7 +316,7 @@ class App : Application(), libtailscale.AppContext {
@Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver: ContentResolver = getContentResolver()
val resolver: ContentResolver = contentResolver
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
if ("" != mimeType) {
@ -334,12 +334,12 @@ class App : Application(), libtailscale.AppContext {
@Throws(IOException::class)
fun openUri(uri: String?, mode: String?): Int? {
val resolver: ContentResolver = getContentResolver()
val resolver: ContentResolver = contentResolver
return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() }
}
fun deleteUri(uri: String?) {
val resolver: ContentResolver = getContentResolver()
val resolver: ContentResolver = contentResolver
resolver.delete(Uri.parse(uri), null, null)
}

@ -1,110 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import android.content.Intent;
import android.content.res.Configuration;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.OpenableColumns;
import java.util.List;
public final class IPNActivity extends Activity {
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
handleIntent();
}
@Override
public void onNewIntent(Intent i) {
setIntent(i);
handleIntent();
}
private void handleIntent() {
Intent it = getIntent();
String act = it.getAction();
String[] texts;
Uri[] uris;
if (Intent.ACTION_SEND.equals(act)) {
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
uris = extraUris.toArray(new Uri[0]);
texts = new String[uris.length];
} else {
return;
}
String mime = it.getType();
int nitems = uris.length;
String[] items = new String[nitems];
String[] mimes = new String[nitems];
int[] types = new int[nitems];
String[] names = new String[nitems];
long[] sizes = new long[nitems];
int nfiles = 0;
for (int i = 0; i < uris.length; i++) {
String text = texts[i];
Uri uri = uris[i];
if (text != null) {
types[nfiles] = 1; // FileTypeText
names[nfiles] = "file.txt";
mimes[nfiles] = mime;
items[nfiles] = text;
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
sizes[nfiles] = 0;
nfiles++;
} else if (uri != null) {
Cursor c = getContentResolver().query(uri, null, null, null, null);
if (c == null) {
// Ignore files we have no permission to access.
continue;
}
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
c.moveToFirst();
String name = c.getString(nameCol);
long size = c.getLong(sizeCol);
types[nfiles] = 2; // FileTypeURI
mimes[nfiles] = mime;
items[nfiles] = uri.toString();
names[nfiles] = name;
sizes[nfiles] = size;
nfiles++;
}
}
// TODO(oxtoacart): actually implement this
// App.onShareIntent(nfiles, types, mimes, items, names, sizes);
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public void onStart() {
super.onStart();
}
@Override
public void onStop() {
super.onStop();
}
@Override
public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c);
}
@Override
public void onLowMemory() {
super.onLowMemory();
}
}

@ -10,8 +10,8 @@ import android.os.Build
import android.system.OsConstants
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import libtailscale.Libtailscale
import java.util.UUID
import libtailscale.Libtailscale
open class IPNService : VpnService(), libtailscale.IPNService {
private val randomID: String = UUID.randomUUID().toString()
@ -63,7 +63,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return PendingIntent.getActivity(
this,
0,
Intent(this, IPNActivity::class.java),
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}

@ -62,6 +62,8 @@ class MainActivity : ComponentActivity() {
@JvmStatic val requestSignin: Int = 1000
// requestPrepareVPN is for when Android's VpnService.prepare completes.
@JvmStatic val requestPrepareVPN: Int = 1001
const val WRITE_STORAGE_RESULT = 1000
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -193,13 +195,9 @@ class MainActivity : ComponentActivity() {
val contract = VpnPermissionContract()
requestVpnPermission =
registerForActivityResult(contract) { granted ->
Notifier.vpnPermissionGranted.set(granted)
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
}
requestVpnPermission.launch(Unit)
} else {
Notifier.vpnPermissionGranted.set(true)
Log.i("VPN", "VPN permission granted")
}
}

@ -0,0 +1,106 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.TaildropView
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
// ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() {
private val TAG = ShareActivity::class.simpleName
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContent {
AppTheme { TaildropView(requestedTransfers, (application as App).applicationScope) }
}
}
override fun onStart() {
super.onStart()
Notifier.start(lifecycleScope)
loadFiles()
}
override fun onStop() {
super.onStop()
Notifier.stop()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent)
loadFiles()
}
// Loads the files from the intent.
fun loadFiles() {
if (intent == null) {
Log.e(TAG, "Share failure - No intent found")
return
}
val act = intent.action
val uris: List<Uri?>?
uris =
when (act) {
Intent.ACTION_SEND -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
} else {
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
}
}
else -> {
Log.e(TAG, "No extras found in intent - nothing to share")
null
}
}
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull {
contentResolver?.query(it, null, null, null, null)?.let { c ->
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
c.moveToFirst()
val name = c.getString(nameCol)
val size = c.getLong(sizeCol)
c.close()
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
file.uri = it
file
}
} ?: emptyList()
if (pendingFiles.isEmpty()) {
Log.e(TAG, "Share failure - no files extracted from intent")
}
requestedTransfers.set(pendingFiles)
}
}

@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.localapi
import android.content.Context
import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors
@ -20,7 +21,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.io.File
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@ -61,6 +61,8 @@ typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
* corresponding method on this Client.
*/
class Client(private val scope: CoroutineScope) {
private val TAG = Client::class.simpleName
fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}
@ -116,26 +118,42 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
}
fun fileTargets(responseHandler: (Result<List<Ipn.FileTarget>>) -> Unit) {
get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
}
fun putTaildropFiles(
context: Context,
peerId: StableNodeID,
files: Collection<File>,
files: Collection<Ipn.OutgoingFile>,
responseHandler: (Result<String>) -> Unit
) {
val manifest = Json.encodeToString(files.map { it.name to it.length() }.toMap())
val manifest = Json.encodeToString(files)
val manifestPart = FilePart()
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
manifestPart.filename = "manifest.json"
manifestPart.contentType = "application/json"
manifestPart.contentLength = manifest.length.toLong()
val parts = mutableListOf(manifestPart)
parts.addAll(
files.map { file ->
val part = FilePart()
part.filename = file.name
part.contentLength = file.length()
part.body = InputStreamAdapter(file.inputStream())
part
})
try {
parts.addAll(
files.map { file ->
val stream =
context.contentResolver.openInputStream(file.uri)
?: throw Exception("Error opening file stream")
val part = FilePart()
part.filename = file.Name
part.contentLength = file.DeclaredSize
part.body = InputStreamAdapter(stream)
part
})
} catch (e: Exception) {
parts.forEach { it.body.close() }
Log.e(TAG, "Error creating file upload body: $e")
responseHandler(Result.failure(e))
return
}
return postMultipart(
"${Endpoint.FILE_PUT}/${peerId}",
@ -234,7 +252,7 @@ class Client(private val scope: CoroutineScope) {
}
}
public class Request<T>(
class Request<T>(
private val scope: CoroutineScope,
private val method: String,
path: String,

@ -3,7 +3,10 @@
package com.tailscale.ipn.ui.model
import android.net.Uri
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
class Ipn {
@ -158,16 +161,27 @@ class Ipn {
@Serializable
data class OutgoingFile(
val ID: String = "",
val Name: String,
val PeerID: StableNodeID,
val Started: String,
val PeerID: StableNodeID = "",
val Started: String = "",
val DeclaredSize: Long,
val Sent: Long,
val Sent: Long = 0L,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Finished: Boolean,
val Succeeded: Boolean,
)
val Finished: Boolean = false,
val Succeeded: Boolean = false,
) {
@Transient lateinit var uri: Uri // only used on client
fun prepare(peerId: StableNodeID): OutgoingFile {
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
f.uri = uri
return f
}
}
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
}
class Persist {

@ -25,7 +25,7 @@ typealias BugReportID = String
// Represents and empty message with a single 'property' field.
class Empty {
@Serializable data class Message(val property: String)
@Serializable data class Message(val property: String = "")
}
// Parsable errors returned by localApiService

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.notifier
import android.util.Log
import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
@ -29,6 +30,11 @@ object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
// Global App State
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
// General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
@ -37,10 +43,11 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
// Taildrop-specific State
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null
@ -70,6 +77,8 @@ object Notifier {
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
}
state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped)

@ -6,14 +6,21 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue
@ -45,3 +52,20 @@ fun OpenURLButton(title: String, url: String) {
contentColor = MaterialTheme.colorScheme.secondary,
containerColor = MaterialTheme.colorScheme.secondaryContainer))
}
@Composable
fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null)
}
}
@Composable
fun CloseButton() {
val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null)
}
}

@ -13,7 +13,9 @@ import com.tailscale.ipn.R
enum class ErrorDialogType {
LOGOUT_FAILED,
SWITCH_USER_FAILED,
ADD_PROFILE_FAILED;
ADD_PROFILE_FAILED,
SHARE_DEVICE_NOT_CONNECTED,
SHARE_FAILED;
val message: Int
get() {
@ -21,10 +23,21 @@ enum class ErrorDialogType {
LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed
ADD_PROFILE_FAILED -> R.string.add_profile_failed
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected
SHARE_FAILED -> R.string.taildrop_share_failed
}
}
val title: Int = R.string.error
val title: Int
get() {
return when (this) {
LOGOUT_FAILED -> R.string.logout_failed_title
SWITCH_USER_FAILED -> R.string.switch_user_failed_title
ADD_PROFILE_FAILED -> R.string.add_profile_failed_title
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title
SHARE_FAILED -> R.string.taildrop_share_failed_title
}
}
val buttonText: Int = R.string.ok
}

@ -21,8 +21,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
@ -42,7 +40,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -307,22 +304,6 @@ fun ConnectView(
}
}
@Composable
fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null)
}
}
@Composable
fun CloseButton() {
val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerList(

@ -0,0 +1,65 @@
// 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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green
@Composable
fun PeerView(
peer: Tailcfg.Node,
selfPeer: String? = null,
stateVal: Ipn.State? = null,
disabled: Boolean = false,
subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" },
onClick: (Tailcfg.Node) -> Unit = {},
trailingContent: @Composable () -> Unit = {}
) {
val textColor = if (disabled) Color.Gray else MaterialTheme.colorScheme.primary
ListItem(
modifier = Modifier.clickable { onClick(peer) },
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list
// unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
val color: Color =
if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
}
Box(
modifier =
Modifier.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = peer.ComputedName,
style = MaterialTheme.typography.titleMedium,
color = textColor)
}
},
supportingContent = {
Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor)
},
trailingContent = trailingContent)
}

@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -21,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue
data class BackNavigation(
val onBack: () -> Unit,
@ -51,14 +53,24 @@ fun BackArrow(action: () -> Unit) {
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null)
Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue)
}
@Composable
fun SimpleActivityIndicator(size: Int = 32) {
CircularProgressIndicator(
modifier = Modifier.width(size.dp),
color = MaterialTheme.colorScheme.primary,
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
)
}
@Composable
fun ActivityIndicator(progress: Double, size: Int = 32) {
LinearProgressIndicator(
progress = { progress.toFloat() },
modifier = Modifier.width(size.dp),
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
)
}

@ -0,0 +1,195 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
@Composable
fun TaildropView(
requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
applicationScope: CoroutineScope,
viewModel: TaildropViewModel =
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
) {
Scaffold(topBar = { Header(R.string.share) }) { paddingInsets ->
val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Column(modifier = Modifier.padding(paddingInsets)) {
FileShareHeader(
fileTransfers = requestedTransfers.collectAsState().value,
totalSize = viewModel.totalSize)
Spacer(modifier = Modifier.size(8.dp))
when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> {
val peers = viewModel.myPeers.collectAsState().value
val context = LocalContext.current
FileSharePeerList(
peers = peers,
stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) },
onShare = { viewModel.share(context, it) })
}
else -> {
FileShareConnectView { viewModel.startVPN() }
}
}
}
}
}
@Composable
fun FileSharePeerList(
peers: List<Tailcfg.Node>,
stateViewGenerator: @Composable (String) -> Unit,
onShare: (Tailcfg.Node) -> Unit
) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Text(stringResource(R.string.my_devices), style = MaterialTheme.typography.titleMedium)
}
when (peers.isEmpty()) {
true -> {
Column(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.no_devices_to_share_with),
style = MaterialTheme.typography.titleMedium)
}
}
false -> {
LazyColumn {
peers.forEach { peer ->
val disabled = !(peer.Online ?: false)
item {
PeerView(
peer = peer,
onClick = { onShare(peer) },
disabled = disabled,
subtitle = { peer.Hostinfo.OS ?: "" },
trailingContent = { stateViewGenerator(peer.StableID) })
}
}
}
}
}
}
@Composable
fun FileShareConnectView(onToggle: () -> Unit) {
Column(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.connect_to_your_tailnet_to_share_files),
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = onToggle) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
}
@Composable
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconForTransfer(fileTransfers)
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
when (fileTransfers.isEmpty()) {
true ->
Text(
stringResource(R.string.no_files_to_share),
style = MaterialTheme.typography.titleMedium)
false -> {
when (fileTransfers.size) {
1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
else ->
Text(
stringResource(R.string.file_count, fileTransfers.size),
style = MaterialTheme.typography.titleMedium)
}
}
}
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
Text(size, style = MaterialTheme.typography.titleMedium)
}
}
HorizontalDivider()
}
}
@Composable
fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
// (jonathan) TODO: Thumbnails?
when (transfers.size) {
0 ->
Icon(
painter = painterResource(R.drawable.warning),
contentDescription = "no files",
modifier = Modifier.size(32.dp))
1 -> {
// Show a thumbnail for single image shares.
val context = LocalContext.current
context.contentResolver.getType(transfers[0].uri)?.let {
if (it.startsWith("image/")) {
AsyncImage(
model = transfers[0].uri,
contentDescription = "one file",
modifier = Modifier.size(40.dp))
return
}
Icon(
painter = painterResource(R.drawable.single_file),
contentDescription = "files",
modifier = Modifier.size(40.dp))
}
}
else ->
Icon(
painter = painterResource(R.drawable.single_file),
contentDescription = "files",
modifier = Modifier.size(40.dp))
}
}

@ -6,11 +6,16 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ListItem
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal

@ -3,6 +3,9 @@
package com.tailscale.ipn.ui.viewModel
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel
@ -35,7 +38,7 @@ open class IpnViewModel : ViewModel() {
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
// The userId associated with the current node. ie: The logged in user.
var selfNodeUserId: UserID? = null
private var selfNodeUserId: UserID? = null
init {
viewModelScope.launch {
@ -65,6 +68,13 @@ open class IpnViewModel : ViewModel() {
Log.d(TAG, "Created")
}
protected fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
@ -86,7 +96,7 @@ open class IpnViewModel : ViewModel() {
}
}
private fun startVPN() {
fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN

@ -0,0 +1,200 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ActivityIndicator
import com.tailscale.ipn.ui.view.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class TaildropViewModelFactory(
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TaildropViewModel(requestedTransfers, applicationScope) as T
}
}
class TaildropViewModel(
val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope
) : IpnViewModel() {
// Represents the state of a file transfer
enum class TransferState {
SENDING,
SENT,
FAILED
}
// The overall VPN state
val state = Notifier.state
// Set of all nodes for which we've requested a file transfer. This is used to prevent us from
// request a transfer to the same peer twice.
private val selectedPeers: StateFlow<Set<StableNodeID>> = MutableStateFlow(emptySet())
// Set of OutgoingFile.IDs that we're currently transferring.
private val currentTransferIDs: StateFlow<Set<String>> = MutableStateFlow(emptySet())
// Flow of Ipn.OutgoingFiles with updated statuses for every entry in transferWithStatuses.
private val transfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
// The total size of all pending files.
val totalSize: Long
get() = requestedTransfers.value.sumOf { it.DeclaredSize }
// The list of peers that we can share with. This includes only the nodes belonging to the user
// and excludes the current node. Sorted by online devices first, and offline second,
// alphabetically.
val myPeers: StateFlow<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
// Non null if there's an error to be rendered.
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
init {
viewModelScope.launch {
Notifier.state.collect {
if (it == Ipn.State.Running) {
loadTargets()
}
}
}
viewModelScope.launch {
// Map the outgoing files by their PeerId since we need to display them for each peer
// We only need to track files which are pending send, everything else is irrelevant.
Notifier.outgoingFiles
.combine(currentTransferIDs) { outgoingFiles, ongoingIDs ->
Pair(outgoingFiles, ongoingIDs)
}
.collect { (outgoingFiles, ongoingIDs) ->
outgoingFiles?.let {
transfers.set(outgoingFiles.filter { ongoingIDs.contains(it.ID) })
} ?: run { transfers.set(emptyList()) }
}
}
viewModelScope.launch {
requestedTransfers.collect {
// This means that we're processing a new share intent, clear current state
selectedPeers.set(emptySet())
currentTransferIDs.set(emptySet())
}
}
}
// Calculates the overall progress for a set of outgoing files
private fun progress(transfers: List<Ipn.OutgoingFile>): Double {
val total = transfers.sumOf { it.DeclaredSize }.toDouble()
val sent = transfers.sumOf { it.Sent }.toDouble()
if (total < 0.1) return 0.0
return (sent / total)
}
// Calculates the overall state of a set of file transfers.
// peerId: The peer ID to check for transfers.
// transfers: The list of outgoing file transfers for the peer.
private fun transferState(transfers: List<Ipn.OutgoingFile>): TransferState? {
// No transfers? Nothing state
if (transfers.isEmpty()) return null
return if (transfers.all { it.Finished }) {
// Everything done? SENT if all succeeded, FAILED if any failed.
if (transfers.any { !it.Succeeded }) TransferState.FAILED else TransferState.SENT
} else {
// Not complete, we're still sending
TransferState.SENDING
}
}
// Loads all of the valid fileTargets from localAPI
private fun loadTargets() {
Client(viewModelScope).fileTargets { result ->
result
.onSuccess { it ->
val allSharablePeers = it.map { it.Node }
val onlinePeers = allSharablePeers.filter { it.Online ?: false }.sortedBy { it.Name }
val offlinePeers =
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
myPeers.set(onlinePeers + offlinePeers)
}
.onFailure { Log.e(TAG, "Error loading targets: ${it.message}") }
}
}
// Creates the trailing status view for the peer list item depending on the state of
// any requested transfers.
@Composable
fun TrailingContentForPeer(peerId: String) {
// Check our outgoing files for the peer and determine the state of the transfer.
val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId }
var status: TransferState = transferState(transfers) ?: return
// Still no status? Nothing to render for this peer
Column(modifier = Modifier.fillMaxHeight()) {
when (status) {
TransferState.SENDING -> {
val progress = progress(transfers)
Text(
stringResource(id = R.string.taildrop_sending),
style = MaterialTheme.typography.bodyMedium)
ActivityIndicator(progress, 60)
}
TransferState.SENT -> CheckedIndicator()
TransferState.FAILED -> Text(stringResource(id = R.string.taildrop_share_failed_short))
}
}
}
// Commences the file transfer to the specified node iff
fun share(context: Context, node: Tailcfg.Node) {
if (node.Online != true) {
showDialog.set(ErrorDialogType.SHARE_DEVICE_NOT_CONNECTED)
return
}
if (selectedPeers.value.contains(node.StableID)) {
// We've already selected this peer, ignore
return
}
selectedPeers.set(selectedPeers.value + node.StableID)
val preparedTransfers = requestedTransfers.value.map { it.prepare(node.StableID) }
currentTransferIDs.set(currentTransferIDs.value + preparedTransfers.map { it.ID })
Client(applicationScope).putTaildropFiles(context, node.StableID, preparedTransfers) {
// This is an early API failure and will not get communicated back up to us via
// outgoing files - things never made it that far.
if (it.isFailure) {
selectedPeers.set(selectedPeers.value - node.StableID)
showDialog.set(ErrorDialogType.SHARE_FAILED)
}
}
}
}

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/>
</vector>

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

@ -78,6 +78,8 @@
<string name="in_x_days">in %d days</string>
<string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</string>
<!-- Strings for the user switcher -->
<string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string>
@ -104,6 +106,8 @@
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="disable">Disable</string>
<!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string>
<string name="tailnet_lock_explainer">Tailnet lock lets devices in your network verify public keys distributed by the coordination server before trusting them for connectivity. </string>
<string name="tailnet_lock_enabled">Tailnet lock is currently enabled.</string>
@ -117,8 +121,12 @@
<string name="tailnet_lock_key">Tailnet Lock Key</string>
<string name="node_key_explainer">Used to sign this node from another signing device in your tailnet.</string>
<string name="tailnet_lock_key_explainer">Used to authorize changes to the Tailnet lock configuration.</string>
<!-- Strings for the bug report screen -->
<string name="bug_report_id">Bug Report ID</string>
<string name="learn_more">Learn more…</string>
<!-- Strings for DNS settings -->
<string name="dns_settings">DNS Settings</string>
<string name="using_tailscale_dns">Using Tailscale DNS</string>
<string name="this_device_is_using_tailscale_to_resolve_dns_names">This device is using Tailscale to resolve DNS names.</string>
@ -172,4 +180,24 @@
<string name="permission_post_notifications_granted">Thank you for granting Tailscale the Notifications permission.</string>
<!-- Strings for the share activity -->
<string name="share">Send via Tailscale</string>
<string name="share_device_not_connected">Unable to share files with this device. This device is not currently connected to the tailnet.</string>
<string name="no_files_to_share">No files to share</string>
<string name="file_count">%1$s Files</string>
<string name="connect_to_your_tailnet_to_share_files">Connect to your tailnet to share files</string>
<string name="my_devices">My Devices</string>
<string name="no_devices_to_share_with">There are no devices on your tailnet to share to</string>
<string name="taildrop_share_failed">Taildrop failed. Some files were not shared. Please try again.</string>
<string name="taildrop_sending">Sending</string>
<string name="taildrop_share_failed_short">Failed</string>
<!-- Error Dialog Titles -->
<string name="logout_failed_title">Logout Failed</string>
<string name="switch_user_failed_title">Cannot Switch To User</string>
<string name="add_profile_failed_title">Unable to Add Profile</string>
<string name="share_device_not_connected_title">Not Connected</string>
<string name="taildrop_share_failed_title">Taildrop Failed</string>
</resources>

@ -13,7 +13,7 @@ require (
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.18.0
inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.63.0-pre.0.20240324181545-f78928191539
tailscale.com v1.63.0-pre.0.20240327135352-66e4d843c138
)
require (
@ -38,11 +38,13 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/benoitkugler/textlayout v0.3.0 // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/coreos/go-iptables v0.7.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
github.com/gaissmai/bart v0.4.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-text/typesetting v0.0.0-20221214153724-0399769901d5 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect

@ -81,6 +81,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@ -134,6 +136,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/gaissmai/bart v0.4.1 h1:G1t58voWkNmT47lBDawH5QhtTDsdqRIO+ftq5x4P9Ls=
github.com/gaissmai/bart v0.4.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
@ -666,5 +670,5 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
tailscale.com v1.63.0-pre.0.20240324181545-f78928191539 h1:DBpvudmMiWINWePS9RdY+dST7xOideFf26TSgqsu2rQ=
tailscale.com v1.63.0-pre.0.20240324181545-f78928191539/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY=
tailscale.com v1.63.0-pre.0.20240327135352-66e4d843c138 h1:CUxCS0sUOwasW/zpNGt63oLCV7QZL7dnrwYMik4OGwQ=
tailscale.com v1.63.0-pre.0.20240327135352-66e4d843c138/go.mod h1:6kGByHNxnFfK1i4gVpdtvpdS1HicHohWXnsfwmXy64I=

Loading…
Cancel
Save