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>
ox/taildrop
Jonathan Nobels 2 years ago
parent bf74edd551
commit de67b7c5c1

@ -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">

@ -36,7 +36,6 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting
@ -48,12 +47,6 @@ import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
import java.net.InetAddress
@ -61,6 +54,12 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
import java.util.Objects
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
class App : Application(), libtailscale.AppContext {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -83,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)
}
@ -246,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()
}
@ -268,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()
})
}
@ -307,31 +306,17 @@ 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()
}
return null
}
fun requestWriteStoragePermission(act: Activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED) {
return
}
act.requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), IPNActivity.WRITE_STORAGE_RESULT)
}
@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) {
@ -349,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,124 +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.pm.PackageManager;
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;
import libtailscale.Libtailscale;
public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000;
@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 onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
if (reqCode == WRITE_STORAGE_RESULT) {
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
Libtailscale.onWriteStorageGranted();
}
}
}
@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)
}

@ -7,14 +7,18 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.content.pm.PackageManager
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -48,6 +52,7 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
class MainActivity : ComponentActivity() {
private var notifierScope: CoroutineScope? = null
@ -59,6 +64,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?) {
@ -110,9 +117,7 @@ class MainActivity : ComponentActivity() {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
composable("runExitNode") {
RunExitNodeView(exitNodePickerNav)
}
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
}
composable(
"peerDetails/{nodeId}",
@ -169,6 +174,10 @@ class MainActivity : ComponentActivity() {
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
// (jonathan) TODO: Requesting storage permissions up front (repeatedly) might also be user
// hostile, but we need these to write incoming taildrop files
requestStoragePermission()
}
override fun onStop() {
@ -185,13 +194,38 @@ 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")
}
}
private fun requestStoragePermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Libtailscale.onWriteStorageGranted()
return
}
if (ContextCompat.checkSelfPermission(
this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) !=
PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this, arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE), WRITE_STORAGE_RESULT)
}
}
@Deprecated("Deprecated in Android 11, but still needed for Android 10 and below.")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == WRITE_STORAGE_RESULT) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Libtailscale.onWriteStorageGranted()
}
}
}
}

@ -0,0 +1,102 @@
// 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.FileTransfer
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 transfers: StateFlow<List<FileTransfer>> = MutableStateFlow(emptyList())
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContent { AppTheme { TaildropView(transfers) } }
}
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<FileTransfer> =
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()
FileTransfer(name, size, it)
}
} ?: emptyList()
if (pendingFiles.isEmpty()) {
Log.e(TAG, "Share failure - no files extracted from intent")
}
transfers.set(pendingFiles)
}
}

@ -3,14 +3,21 @@
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
import com.tailscale.ipn.ui.model.FileTransfer
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.util.InputStreamAdapter
import java.net.URLEncoder
import java.nio.charset.Charset
import java.nio.file.Files.delete
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -20,10 +27,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
private object Endpoint {
const val DEBUG = "debug"
@ -61,6 +64,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 +121,45 @@ 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<FileTransfer>,
responseHandler: (Result<String>) -> Unit
) {
val manifest = Json.encodeToString(files.map { it.name to it.length() }.toMap())
val manifest =
Json.encodeToString(
files.map { URLEncoder.encode(it.filename, "utf-8") to it.size }.toMap())
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)
if (stream == null) {
throw Exception("Error opening file stream")
}
val part = FilePart()
part.filename = URLEncoder.encode(file.filename, "utf-8")
part.contentLength = file.size
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 +258,7 @@ class Client(private val scope: CoroutineScope) {
}
}
public class Request<T>(
class Request<T>(
private val scope: CoroutineScope,
private val method: String,
path: String,

@ -0,0 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import android.net.Uri
// Encapsulates a uri based file transfer for Taildrop.
data class FileTransfer(val filename: String, val size: Long, val uri: Uri)

@ -168,6 +168,8 @@ class Ipn {
val Finished: Boolean,
val Succeeded: Boolean,
)
@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.ExperimentalMaterial3Api
@ -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
@ -290,22 +287,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,194 @@
// 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.FileTransfer
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.flow.StateFlow
@Composable
fun TaildropView(
transfersIn: StateFlow<List<FileTransfer>>,
viewModel: TaildropViewModel = viewModel(factory = TaildropViewModelFactory(transfersIn))
) {
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)) {
val transfers = viewModel.transfers.collectAsState().value
val state = viewModel.state.collectAsState().value
FileShareHeader(fileTransfers = transfers, totalSize = viewModel.totalSize)
Spacer(modifier = Modifier.size(8.dp))
when (state) {
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<FileTransfer>, 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].filename, 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<FileTransfer>) {
// (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))
}
}

@ -8,12 +8,15 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.defaultPaddingModifier
@ -65,6 +68,7 @@ fun UserView(
UserActionState.NAV -> Unit
UserActionState.NONE -> Unit
}
Spacer(modifier = Modifier.padding(end = 4.dp))
}
}
}

@ -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,217 @@
// 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.FileTransfer
import com.tailscale.ipn.ui.model.Ipn
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 java.net.URLEncoder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class TaildropViewModelFactory(private val transfers: StateFlow<List<FileTransfer>>) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TaildropViewModel(transfers) as T
}
}
class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewModel() {
// Represents the state of a file transfer
enum class TransferState {
SENDING,
SENT,
FAILED
}
// The overall VPN state
val state = Notifier.state
// Map of outgoing files to peer ID. This is the full state of the outgoing files.
private val outgoing: StateFlow<Map<String, List<Ipn.OutgoingFile>>> =
MutableStateFlow(emptyMap())
// List of any nodes that have a file transfer pending FOR THE CURRENT SESSION
// This is used to filter outgoingFiles to ensure we only render the transfer state
// for things that are currently displayed.
private val pending: StateFlow<Set<String>> = MutableStateFlow(emptySet())
// The total size of all pending files.
var totalSize: Long = 0
get() = transfers.value.sumOf { it.size }
// 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.collect { outgoingFiles ->
val outgoingMap: MutableMap<String, List<Ipn.OutgoingFile>> = mutableMapOf()
val currentFiles = transfers.value.map { URLEncoder.encode(it.filename, "utf-8") }
outgoingFiles?.let { files ->
files
.filter { currentFiles.contains(it.Name) && pending.value.contains(it.PeerID) }
.forEach {
val list = outgoingMap.getOrDefault(it.PeerID, emptyList()).toMutableList()
list += it
outgoingMap[it.PeerID] = list
}
Log.d("TaildropViewModel", "Outgoing files: $outgoingMap")
outgoing.set(outgoingMap)
} ?: run { outgoing.set(emptyMap()) }
}
}
// Whenever our files list changes, we need to reset the outgoings files map and
// any pending requests. The user has changed the files they're attempting to share.
viewModelScope.launch {
transfers.collect {
pending.set(emptySet())
outgoing.set(emptyMap())
}
}
}
// Calculates the overall progress for a set of outoing files
private fun progress(transfers: List<Ipn.OutgoingFile>): Double {
val total = transfers.sumOf { it.DeclaredSize }.toDouble()
val sent = transfers.sumOf { it.Sent }.toDouble()
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(peerId: String, transfers: List<Ipn.OutgoingFile>): TransferState? {
// No transfers? Nothing state
if (transfers.isEmpty()) return null
// We may have transfers from a prior session for files the user selected and for peers
// in our list.. but we don't care about those. We only care if the peerId is in teh pending
// list.
if (!pending.value.contains(peerId)) 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) {
val outgoing = outgoing.collectAsState().value
val pending = pending.collectAsState().value
// Check our outgoing files for the peer and determine the state of the transfer.
val transfers = outgoing[peerId] ?: emptyList()
var status = transferState(peerId, transfers)
// Check if we have a pending transfer for this peer. We may not have an outgoing file
// yet, but we still want to show the sending state in the mean time.
if (status == null && pending.contains(peerId)) {
status = TransferState.SENDING
}
// Still no status? Nothing to render for this peer
if (status == null) return
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
}
// Ignore requests to resend a file (the backend will not overwrite anyway)
outgoing.value[node.StableID]?.let {
val status = transferState(node.StableID, it)
if (status == TransferState.SENDING || status == TransferState.SENT) {
return
}
}
pending.set(pending.value + node.StableID)
Client(viewModelScope).putTaildropFiles(context, node.StableID, transfers.value) {
// 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) {
pending.set(pending.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>

@ -76,6 +76,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>
@ -100,6 +102,8 @@
<string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</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>
@ -113,8 +117,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>
@ -126,4 +134,24 @@
<string name="not_using_tailscale_dns">Not Using Tailscale DNS</string>
<string name="allow_lan_access">Allow LAN Access</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>

Loading…
Cancel
Save