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> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" /> <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter> </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> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -73,6 +81,8 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver <receiver
android:name="IPNReceiver" android:name="IPNReceiver"
android:exported="true"> android:exported="true">

@ -36,7 +36,6 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting 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.localapi.Request
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import 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.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress import java.net.InetAddress
@ -61,6 +54,12 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
import java.util.Objects 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 { class App : Application(), libtailscale.AppContext {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -83,7 +82,7 @@ class App : Application(), libtailscale.AppContext {
@JvmStatic @JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) { 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) f.startActivityForResult(intent, request)
} }
@ -246,7 +245,7 @@ class App : Application(), libtailscale.AppContext {
val manu = Build.MANUFACTURER val manu = Build.MANUFACTURER
var model = Build.MODEL var model = Build.MODEL
// Strip manufacturer from 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) { if (idx != -1) {
model = model.substring(idx + manu.length).trim() model = model.substring(idx + manu.length).trim()
} }
@ -268,10 +267,10 @@ class App : Application(), libtailscale.AppContext {
fun attachPeer(act: Activity) { fun attachPeer(act: Activity) {
act.runOnUiThread( act.runOnUiThread(
Runnable { Runnable {
val ft: FragmentTransaction = act.getFragmentManager().beginTransaction() val ft: FragmentTransaction = act.fragmentManager.beginTransaction()
ft.add(Peer(), PEER_TAG) ft.add(Peer(), PEER_TAG)
ft.commit() 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. // getPackageSignatureFingerprint returns the first package signing certificate, if any.
get() { get() {
val info: PackageInfo val info: PackageInfo
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES) info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
for (signature in info.signatures) { for (signature in info.signatures) {
return signature.toByteArray() return signature.toByteArray()
} }
return null 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) @Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String { fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver: ContentResolver = getContentResolver() val resolver: ContentResolver = contentResolver
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name) contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
if ("" != mimeType) { if ("" != mimeType) {
@ -349,12 +334,12 @@ class App : Application(), libtailscale.AppContext {
@Throws(IOException::class) @Throws(IOException::class)
fun openUri(uri: String?, mode: String?): Int? { 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() } return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() }
} }
fun deleteUri(uri: String?) { fun deleteUri(uri: String?) {
val resolver: ContentResolver = getContentResolver() val resolver: ContentResolver = contentResolver
resolver.delete(Uri.parse(uri), null, null) 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 android.system.OsConstants
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
import libtailscale.Libtailscale
open class IPNService : VpnService(), libtailscale.IPNService { open class IPNService : VpnService(), libtailscale.IPNService {
private val randomID: String = UUID.randomUUID().toString() private val randomID: String = UUID.randomUUID().toString()
@ -63,7 +63,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
0, 0,
Intent(this, IPNActivity::class.java), Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
} }

@ -7,14 +7,18 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.RestrictionsManager import android.content.RestrictionsManager
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -48,6 +52,7 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notifierScope: CoroutineScope? = null private var notifierScope: CoroutineScope? = null
@ -59,6 +64,8 @@ class MainActivity : ComponentActivity() {
@JvmStatic val requestSignin: Int = 1000 @JvmStatic val requestSignin: Int = 1000
// requestPrepareVPN is for when Android's VpnService.prepare completes. // requestPrepareVPN is for when Android's VpnService.prepare completes.
@JvmStatic val requestPrepareVPN: Int = 1001 @JvmStatic val requestPrepareVPN: Int = 1001
const val WRITE_STORAGE_RESULT = 1000
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -110,9 +117,7 @@ class MainActivity : ComponentActivity() {
MullvadExitNodePicker( MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav) it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
} }
composable("runExitNode") { composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
RunExitNodeView(exitNodePickerNav)
}
} }
composable( composable(
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
@ -169,6 +174,10 @@ class MainActivity : ComponentActivity() {
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should // (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN // be done when the user initiall starts the VPN
requestVpnPermission() 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() { override fun onStop() {
@ -185,13 +194,38 @@ class MainActivity : ComponentActivity() {
val contract = VpnPermissionContract() val contract = VpnPermissionContract()
requestVpnPermission = requestVpnPermission =
registerForActivityResult(contract) { granted -> registerForActivityResult(contract) { granted ->
Notifier.vpnPermissionGranted.set(granted)
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}") Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
} }
requestVpnPermission.launch(Unit) 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 package com.tailscale.ipn.ui.localapi
import android.content.Context
import android.util.Log import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.FileTransfer
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.util.InputStreamAdapter 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -20,10 +27,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import libtailscale.FilePart import libtailscale.FilePart
import java.io.File
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
private object Endpoint { private object Endpoint {
const val DEBUG = "debug" const val DEBUG = "debug"
@ -61,6 +64,8 @@ typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
* corresponding method on this Client. * corresponding method on this Client.
*/ */
class Client(private val scope: CoroutineScope) { class Client(private val scope: CoroutineScope) {
private val TAG = Client::class.simpleName
fun status(responseHandler: StatusResponseHandler) { fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler) get(Endpoint.STATUS, responseHandler = responseHandler)
} }
@ -116,26 +121,45 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.TKA_STATUS, responseHandler = responseHandler) get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
} }
fun fileTargets(responseHandler: (Result<List<Ipn.FileTarget>>) -> Unit) {
get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
}
fun putTaildropFiles( fun putTaildropFiles(
context: Context,
peerId: StableNodeID, peerId: StableNodeID,
files: Collection<File>, files: Collection<FileTransfer>,
responseHandler: (Result<String>) -> Unit 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() val manifestPart = FilePart()
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset())) manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
manifestPart.filename = "manifest.json" manifestPart.filename = "manifest.json"
manifestPart.contentType = "application/json" manifestPart.contentType = "application/json"
manifestPart.contentLength = manifest.length.toLong()
val parts = mutableListOf(manifestPart) val parts = mutableListOf(manifestPart)
try {
parts.addAll( parts.addAll(
files.map { file -> files.map { file ->
val stream = context.contentResolver.openInputStream(file.uri)
if (stream == null) {
throw Exception("Error opening file stream")
}
val part = FilePart() val part = FilePart()
part.filename = file.name part.filename = URLEncoder.encode(file.filename, "utf-8")
part.contentLength = file.length() part.contentLength = file.size
part.body = InputStreamAdapter(file.inputStream()) part.body = InputStreamAdapter(stream)
part 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( return postMultipart(
"${Endpoint.FILE_PUT}/${peerId}", "${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 scope: CoroutineScope,
private val method: String, private val method: String,
path: 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 Finished: Boolean,
val Succeeded: Boolean, val Succeeded: Boolean,
) )
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
} }
class Persist { class Persist {

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

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.notifier package com.tailscale.ipn.ui.notifier
import android.util.Log 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
import com.tailscale.ipn.ui.model.Ipn.Notify import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -29,6 +30,11 @@ object Notifier {
private val TAG = Notifier::class.simpleName private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true } 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 state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null) val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
@ -37,10 +43,11 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null) val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null) val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null) val version: StateFlow<String?> = MutableStateFlow(null)
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
val tileReady: StateFlow<Boolean> = MutableStateFlow(false) // Taildrop-specific State
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null) 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 lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null private var manager: libtailscale.NotificationManager? = null
@ -70,6 +77,8 @@ object Notifier {
notify.LoginFinished?.let { loginFinished.set(it.property) } notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set) notify.Version?.let(version::set)
notify.OutgoingFiles?.let(outgoingFiles::set) notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
} }
state.collect { currstate -> state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped) 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.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth 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.Button
import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.ts_color_light_blue
@ -45,3 +52,20 @@ fun OpenURLButton(title: String, url: String) {
contentColor = MaterialTheme.colorScheme.secondary, contentColor = MaterialTheme.colorScheme.secondary,
containerColor = MaterialTheme.colorScheme.secondaryContainer)) 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 { enum class ErrorDialogType {
LOGOUT_FAILED, LOGOUT_FAILED,
SWITCH_USER_FAILED, SWITCH_USER_FAILED,
ADD_PROFILE_FAILED; ADD_PROFILE_FAILED,
SHARE_DEVICE_NOT_CONNECTED,
SHARE_FAILED;
val message: Int val message: Int
get() { get() {
@ -21,10 +23,21 @@ enum class ErrorDialogType {
LOGOUT_FAILED -> R.string.logout_failed LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed SWITCH_USER_FAILED -> R.string.switch_user_failed
ADD_PROFILE_FAILED -> R.string.add_profile_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 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown 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.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -42,7 +40,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeerList( 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -21,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue
data class BackNavigation( data class BackNavigation(
val onBack: () -> Unit, val onBack: () -> Unit,
@ -51,14 +53,24 @@ fun BackArrow(action: () -> Unit) {
@Composable @Composable
fun CheckedIndicator() { fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null) Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue)
} }
@Composable @Composable
fun SimpleActivityIndicator(size: Int = 32) { fun SimpleActivityIndicator(size: Int = 32) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.width(size.dp), 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, 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
@ -65,6 +68,7 @@ fun UserView(
UserActionState.NAV -> Unit UserActionState.NAV -> Unit
UserActionState.NONE -> Unit UserActionState.NONE -> Unit
} }
Spacer(modifier = Modifier.padding(end = 4.dp))
} }
} }
} }

@ -3,6 +3,9 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -35,7 +38,7 @@ open class IpnViewModel : ViewModel() {
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null) val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
// The userId associated with the current node. ie: The logged in user. // The userId associated with the current node. ie: The logged in user.
var selfNodeUserId: UserID? = null private var selfNodeUserId: UserID? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -65,6 +68,13 @@ open class IpnViewModel : ViewModel() {
Log.d(TAG, "Created") Log.d(TAG, "Created")
} }
protected fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
private fun loadUserProfiles() { private fun loadUserProfiles() {
Client(viewModelScope).profiles { result -> Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure { result.onSuccess(loginProfiles::set).onFailure {
@ -86,7 +96,7 @@ open class IpnViewModel : ViewModel() {
} }
} }
private fun startVPN() { fun startVPN() {
val context = App.getApplication().applicationContext val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java) val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN 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_days">in %d days</string>
<string name="in_x_months">in %d months</string> <string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</string> <string name="in_x_years">in %.1f years</string>
<!-- Strings for the user switcher -->
<string name="user_switcher">Accounts</string> <string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string> <string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</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="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="enabled">Enabled</string>
<string name="disabled">Disabled</string> <string name="disabled">Disabled</string>
<!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string> <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_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> <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="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="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> <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="bug_report_id">Bug Report ID</string>
<string name="learn_more">Learn more…</string> <string name="learn_more">Learn more…</string>
<!-- Strings for DNS settings -->
<string name="dns_settings">DNS Settings</string> <string name="dns_settings">DNS Settings</string>
<string name="using_tailscale_dns">Using Tailscale DNS</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> <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="not_using_tailscale_dns">Not Using Tailscale DNS</string>
<string name="allow_lan_access">Allow LAN Access</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> </resources>

Loading…
Cancel
Save