Merge branch 'main' into task/improve-app-split-tunneling-feature
commit
9583c7af0a
@ -0,0 +1,26 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import java.io.OutputStream
|
||||
|
||||
// This class adapts a Java OutputStream to the libtailscale.OutputStream interface.
|
||||
class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream {
|
||||
// writes data to the outputStream in its entirety. Returns -1 on error.
|
||||
override fun write(data: ByteArray): Long {
|
||||
return try {
|
||||
outputStream.write(data)
|
||||
outputStream.flush()
|
||||
data.size.toLong()
|
||||
} catch (e: Exception) {
|
||||
TSLog.d("OutputStreamAdapter", "write exception: $e")
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.Permissions
|
||||
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
|
||||
|
||||
@Composable
|
||||
fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) {
|
||||
val permissions = Permissions.withGrantedStatus
|
||||
|
||||
// Find the notification permission
|
||||
val notificationPermission =
|
||||
permissions.find { (permission, _) ->
|
||||
permission.title == R.string.permission_post_notifications
|
||||
}
|
||||
val granted = notificationPermission?.second ?: false
|
||||
val permission = notificationPermission?.first
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView)
|
||||
}) { innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
if (permission != null) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(permission.title),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(permission.description),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.notification_settings_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
item("spacer") {
|
||||
Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider
|
||||
}
|
||||
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_post_notifications),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
if (granted) stringResource(R.string.on)
|
||||
else stringResource(R.string.off),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
Button(
|
||||
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
|
||||
onClick = openApplicationSettings,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Text(stringResource(R.string.open_notification_settings))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ListItem
|
||||
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.runtime.getValue
|
||||
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.theme.exitNodeToggleButton
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.friendlyDirName
|
||||
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
|
||||
@Composable
|
||||
fun TaildropDirView(
|
||||
backToPermissionsView: BackNavigation,
|
||||
openDirectoryLauncher: ActivityResultLauncher<Uri?>,
|
||||
permissionsViewModel: PermissionsViewModel
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(titleRes = R.string.taildrop_dir_access, onBack = backToPermissionsView)
|
||||
}) { innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.taildrop_dir_access),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_taildrop_dir),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
})
|
||||
}
|
||||
|
||||
item("divider0") { Lists.SectionDivider() }
|
||||
|
||||
item {
|
||||
val currentDir by permissionsViewModel.currentDir.collectAsState()
|
||||
TSLog.d("TaildropDirView", "currentDir in UI: $currentDir")
|
||||
val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access"
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.dir_access),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = displayPath, style = MaterialTheme.typography.bodyMedium)
|
||||
Button(
|
||||
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
|
||||
onClick = { openDirectoryLauncher.launch(null) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Text(stringResource(R.string.pick_dir))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.util.ShareFileHelper
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow<Unit>) :
|
||||
ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
|
||||
return AppViewModel(application, taildropPrompt) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
|
||||
// Application context-aware ViewModel used to track app-wide VPN and Taildrop state.
|
||||
// This must be application-scoped because Tailscale may be enabled, disabled, or used for
|
||||
// file transfers (Taildrop) outside the activity lifecycle.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Track VPN preparation state (e.g., whether permission has been granted) and activity state
|
||||
// - Monitor incoming Taildrop file transfers
|
||||
// - Coordinate prompts for Taildrop directory selection if not yet configured
|
||||
class AppViewModel(application: Application, private val taildropPrompt: Flow<Unit>) :
|
||||
AndroidViewModel(application) {
|
||||
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
|
||||
// if the user has previously consented to the VPN application. This is used to determine whether
|
||||
// a VPN permission launcher needs to be shown.
|
||||
val _vpnPrepared = MutableStateFlow(false)
|
||||
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
|
||||
// Whether a VPN interface has been established. This is set by net.updateTUN upon
|
||||
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
|
||||
val _vpnActive = MutableStateFlow(false)
|
||||
val vpnActive: StateFlow<Boolean> = _vpnActive
|
||||
// Select Taildrop directory
|
||||
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
|
||||
private val _triggerDirectoryPicker = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val triggerDirectoryPicker: SharedFlow<Unit> = _triggerDirectoryPicker
|
||||
val TAG = "AppViewModel"
|
||||
|
||||
init {
|
||||
observeIncomingTaildrop()
|
||||
prepareVpn()
|
||||
}
|
||||
|
||||
private fun observeIncomingTaildrop() {
|
||||
viewModelScope.launch {
|
||||
taildropPrompt.collect {
|
||||
TSLog.d(TAG, "Taildrop event received, checking directory")
|
||||
checkIfTaildropDirectorySelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestDirectoryPicker() {
|
||||
_triggerDirectoryPicker.tryEmit(Unit)
|
||||
}
|
||||
|
||||
private fun prepareVpn() {
|
||||
// Check if the user has granted permission yet.
|
||||
if (!vpnPrepared.value) {
|
||||
val vpnIntent = VpnService.prepare(getApplication())
|
||||
if (vpnIntent != null) {
|
||||
setVpnPrepared(false)
|
||||
Log.d(TAG, "VpnService.prepare returned non-null intent")
|
||||
} else {
|
||||
setVpnPrepared(true)
|
||||
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfTaildropDirectorySelected() {
|
||||
val app = App.get()
|
||||
val storedUri = app.getStoredDirectoryUri()
|
||||
if (ShareFileHelper.hasValidTaildropDir()) {
|
||||
return
|
||||
}
|
||||
|
||||
val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) }
|
||||
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
|
||||
TSLog.d(
|
||||
"MainViewModel",
|
||||
"Stored directory URI is invalid or inaccessible; launching directory picker.")
|
||||
viewModelScope.launch { requestDirectoryPicker() }
|
||||
} else {
|
||||
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
|
||||
}
|
||||
}
|
||||
|
||||
fun setVpnActive(isActive: Boolean) {
|
||||
_vpnActive.value = isActive
|
||||
}
|
||||
|
||||
fun setVpnPrepared(isPrepared: Boolean) {
|
||||
_vpnPrepared.value = isPrepared
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.tailscale.ipn.TaildropDirectoryStore
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class PermissionsViewModel : ViewModel() {
|
||||
private val _currentDir =
|
||||
MutableStateFlow<String?>(TaildropDirectoryStore.loadSavedDir()?.toString())
|
||||
val currentDir: StateFlow<String?> = _currentDir
|
||||
|
||||
fun refreshCurrentDir() {
|
||||
val newUri = TaildropDirectoryStore.loadSavedDir()?.toString()
|
||||
TSLog.d("PermissionsViewModel", "refreshCurrentDir: $newUri")
|
||||
_currentDir.value = newUri
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn.util
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.Signature
|
||||
import kotlin.random.Random
|
||||
|
||||
class NoSuchKeyException : Exception("no key found matching the provided ID")
|
||||
class HardwareKeysNotSupported : Exception("hardware-backed keys are not supported on this device")
|
||||
|
||||
// HardwareKeyStore implements the callbacks necessary to implement key.HardwareAttestationKey on
|
||||
// the Go side. It uses KeyStore with a StrongBox processor.
|
||||
class HardwareKeyStore() {
|
||||
var keyStoreKeys = HashMap<String, KeyPair>();
|
||||
val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun newID(): String {
|
||||
var id: String
|
||||
do {
|
||||
id = Random.nextBytes(4).toHexString()
|
||||
} while (keyStoreKeys.containsKey(id))
|
||||
return id
|
||||
}
|
||||
|
||||
fun createKey(): String {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
throw HardwareKeysNotSupported()
|
||||
}
|
||||
val id = newID()
|
||||
val kpg: KeyPairGenerator = KeyPairGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore"
|
||||
)
|
||||
val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder(
|
||||
id, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||
).run {
|
||||
// Use DIGEST_NONE because hashing is done on the Go side.
|
||||
setDigests(KeyProperties.DIGEST_NONE)
|
||||
setIsStrongBoxBacked(true)
|
||||
build()
|
||||
}
|
||||
|
||||
kpg.initialize(parameterSpec)
|
||||
|
||||
val kp = kpg.generateKeyPair()
|
||||
keyStoreKeys[id] = kp
|
||||
return id
|
||||
}
|
||||
|
||||
fun releaseKey(id: String) {
|
||||
keyStoreKeys.remove(id)
|
||||
}
|
||||
|
||||
fun sign(id: String, data: ByteArray): ByteArray {
|
||||
val key = keyStoreKeys[id]
|
||||
if (key == null) {
|
||||
throw NoSuchKeyException()
|
||||
}
|
||||
// Use NONEwithECDSA because hashing is done on the Go side.
|
||||
return Signature.getInstance("NONEwithECDSA").run {
|
||||
initSign(key.private)
|
||||
update(data)
|
||||
sign()
|
||||
}
|
||||
}
|
||||
|
||||
fun public(id: String): ByteArray {
|
||||
val key = keyStoreKeys[id]
|
||||
if (key == null) {
|
||||
throw NoSuchKeyException()
|
||||
}
|
||||
return key.public.encoded
|
||||
}
|
||||
|
||||
fun load(id: String) {
|
||||
if (keyStoreKeys[id] != null) {
|
||||
// Already loaded.
|
||||
return
|
||||
}
|
||||
val entry: KeyStore.Entry = keyStore.getEntry(id, null)
|
||||
if (entry !is KeyStore.PrivateKeyEntry) {
|
||||
throw NoSuchKeyException()
|
||||
}
|
||||
keyStoreKeys[id] = KeyPair(entry.certificate.publicKey, entry.privateKey)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,322 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.tailscale.ipn.TaildropDirectoryStore
|
||||
import com.tailscale.ipn.ui.util.InputStreamAdapter
|
||||
import com.tailscale.ipn.ui.util.OutputStreamAdapter
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import libtailscale.Libtailscale
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SafFile(val fd: Int, val uri: String)
|
||||
|
||||
object ShareFileHelper : libtailscale.ShareFileHelper {
|
||||
private var appContext: Context? = null
|
||||
private var app: libtailscale.Application? = null
|
||||
private var savedUri: String? = null
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
@JvmStatic
|
||||
fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) {
|
||||
appContext = context.applicationContext
|
||||
this.app = app
|
||||
savedUri = uri
|
||||
scope = appScope
|
||||
Libtailscale.setShareFileHelper(this)
|
||||
TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri")
|
||||
}
|
||||
|
||||
// A simple data class that holds a SAF OutputStream along with its URI.
|
||||
data class SafStream(val uri: String, val stream: OutputStream)
|
||||
|
||||
val taildropPrompt = MutableSharedFlow<Unit>(replay = 1)
|
||||
|
||||
fun observeTaildropPrompt(): Flow<Unit> = taildropPrompt
|
||||
|
||||
@Volatile private var directoryReady: CompletableDeferred<Unit>? = null
|
||||
|
||||
fun hasValidTaildropDir(): Boolean {
|
||||
val uri = TaildropDirectoryStore.loadSavedDir()
|
||||
if (uri == null) return false
|
||||
|
||||
// Only SAF tree URIs are supported
|
||||
if (uri.scheme != "content") {
|
||||
TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}")
|
||||
return false
|
||||
}
|
||||
|
||||
val context = appContext ?: return false
|
||||
val docFile = DocumentFile.fromTreeUri(context, uri)
|
||||
|
||||
if (docFile == null || !docFile.exists() || !docFile.canWrite()) {
|
||||
TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun waitUntilTaildropDirReady() {
|
||||
if (!hasValidTaildropDir()) {
|
||||
if (directoryReady?.isActive != true) {
|
||||
directoryReady = CompletableDeferred()
|
||||
scope?.launch { taildropPrompt.emit(Unit) }
|
||||
}
|
||||
directoryReady?.await()
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyDirectoryReady() {
|
||||
directoryReady?.takeIf { !it.isCompleted }?.complete(Unit)
|
||||
}
|
||||
|
||||
// A helper function that opens or creates a SafStream for a given file.
|
||||
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
|
||||
val context = appContext ?: return "" to null
|
||||
val dirUri = savedUri ?: return "" to null
|
||||
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null
|
||||
val file =
|
||||
dir.findFile(fileName)
|
||||
?: dir.createFile("application/octet-stream", fileName)
|
||||
?: return "" to null
|
||||
val os = context.contentResolver.openOutputStream(file.uri, "rw")
|
||||
return file.uri.toString() to os
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
|
||||
val ctx = appContext ?: throw IOException("App context not initialized")
|
||||
val dirUri = savedUri ?: throw IOException("No directory URI")
|
||||
val dir =
|
||||
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
|
||||
?: throw IOException("Invalid tree URI: $dirUri")
|
||||
val file =
|
||||
dir.findFile(fileName)
|
||||
?: dir.createFile("application/octet-stream", fileName)
|
||||
?: throw IOException("Failed to create file: $fileName")
|
||||
val pfd =
|
||||
ctx.contentResolver.openFileDescriptor(file.uri, "rw")
|
||||
?: throw IOException("Failed to open file descriptor for ${file.uri}")
|
||||
val fos = FileOutputStream(pfd.fileDescriptor)
|
||||
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
|
||||
return file.uri.toString() to SeekableOutputStream(fos, pfd)
|
||||
}
|
||||
|
||||
private val currentUri = ConcurrentHashMap<String, String>()
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
|
||||
runBlocking { waitUntilTaildropDirReady() }
|
||||
val (uri, stream) = openWriterFD(fileName, offset)
|
||||
if (stream == null) {
|
||||
throw IOException("Failed to open file writer for $fileName")
|
||||
}
|
||||
currentUri[fileName] = uri
|
||||
return OutputStreamAdapter(stream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun getFileURI(fileName: String): String {
|
||||
runBlocking { waitUntilTaildropDirReady() }
|
||||
currentUri[fileName]?.let {
|
||||
return it
|
||||
}
|
||||
val ctx = appContext ?: throw IOException("App context not initialized")
|
||||
val dirStr = savedUri ?: throw IOException("No saved directory URI")
|
||||
val dir =
|
||||
DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr))
|
||||
?: throw IOException("Invalid tree URI: $dirStr")
|
||||
val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName")
|
||||
val uri = file.uri.toString()
|
||||
currentUri[fileName] = uri
|
||||
return uri
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun renameFile(oldPath: String, targetName: String): String {
|
||||
val ctx = appContext ?: throw IOException("not initialized")
|
||||
val dirUri = savedUri ?: throw IOException("directory not set")
|
||||
val srcUri = Uri.parse(oldPath)
|
||||
val dir =
|
||||
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
|
||||
?: throw IOException("cannot open dir $dirUri")
|
||||
|
||||
var finalName = targetName
|
||||
dir.findFile(finalName)?.let { existing ->
|
||||
if (lengthOfUri(ctx, existing.uri) == 0L) {
|
||||
existing.delete()
|
||||
} else {
|
||||
finalName = generateNewFilename(finalName)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri ->
|
||||
runCatching { ctx.contentResolver.delete(srcUri, null, null) }
|
||||
cleanupPartials(dir, targetName)
|
||||
return newUri.toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TSLog.w(
|
||||
"renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}")
|
||||
}
|
||||
|
||||
val dest =
|
||||
dir.createFile("application/octet-stream", finalName)
|
||||
?: throw IOException("createFile failed for $finalName")
|
||||
|
||||
ctx.contentResolver.openInputStream(srcUri).use { inp ->
|
||||
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
|
||||
if (inp == null || out == null) {
|
||||
dest.delete()
|
||||
throw IOException("Unable to open output stream for URI: ${dest.uri}")
|
||||
}
|
||||
inp.copyTo(out)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.contentResolver.delete(srcUri, null, null)
|
||||
cleanupPartials(dir, targetName)
|
||||
return dest.uri.toString()
|
||||
}
|
||||
|
||||
private fun lengthOfUri(ctx: Context, uri: Uri): Long =
|
||||
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
|
||||
// delete any stray “.partial” files for this base name
|
||||
private fun cleanupPartials(dir: DocumentFile, base: String) {
|
||||
for (child in dir.listFiles()) {
|
||||
val n = child.name ?: continue
|
||||
if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) {
|
||||
child.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun deleteFile(uri: String) {
|
||||
runBlocking { waitUntilTaildropDirReady() }
|
||||
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
|
||||
val uri = Uri.parse(uri)
|
||||
val doc =
|
||||
DocumentFile.fromSingleUri(ctx, uri)
|
||||
?: throw IOException("DeleteFile: cannot resolve URI $uri")
|
||||
if (!doc.delete()) {
|
||||
throw IOException("DeleteFile: delete() returned false for $uri")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun getFileInfo(fileName: String): String {
|
||||
val context = appContext ?: throw IOException("app context not initialized")
|
||||
val dirUri = savedUri ?: throw IOException("SAF URI not initialized")
|
||||
val dir =
|
||||
DocumentFile.fromTreeUri(context, Uri.parse(dirUri))
|
||||
?: throw IOException("could not resolve SAF root")
|
||||
val file =
|
||||
dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory")
|
||||
val name = file.name ?: throw IOException("file name missing for $fileName")
|
||||
val size = file.length()
|
||||
val modTime = file.lastModified()
|
||||
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
|
||||
}
|
||||
|
||||
private fun jsonEscape(s: String): String {
|
||||
return JSONObject.quote(s)
|
||||
}
|
||||
|
||||
fun generateNewFilename(filename: String): String {
|
||||
val dotIndex = filename.lastIndexOf('.')
|
||||
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
|
||||
val extension = if (dotIndex != -1) filename.substring(dotIndex) else ""
|
||||
val uuid = UUID.randomUUID()
|
||||
return "$baseName-$uuid$extension"
|
||||
}
|
||||
|
||||
fun listPartialFiles(suffix: String): Array<String> {
|
||||
val context = appContext ?: return emptyArray()
|
||||
val rootUri = savedUri ?: return emptyArray()
|
||||
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
|
||||
return dir.listFiles()
|
||||
.filter { it.name?.endsWith(suffix) == true }
|
||||
.mapNotNull { it.name }
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun listFilesJSON(suffix: String): String {
|
||||
val list = listPartialFiles(suffix)
|
||||
if (list.isEmpty()) {
|
||||
throw IOException("no files found matching suffix \"$suffix\"")
|
||||
}
|
||||
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun openFileReader(name: String): libtailscale.InputStream {
|
||||
val context = appContext ?: throw IOException("app context not initialized")
|
||||
val rootUri = savedUri ?: throw IOException("SAF URI not initialized")
|
||||
val dir =
|
||||
DocumentFile.fromTreeUri(context, Uri.parse(rootUri))
|
||||
?: throw IOException("could not open SAF root")
|
||||
val suffix = name.substringAfterLast('.', ".$name")
|
||||
val file =
|
||||
dir.listFiles().firstOrNull {
|
||||
val fname = it.name ?: return@firstOrNull false
|
||||
fname.endsWith(suffix, ignoreCase = false)
|
||||
} ?: throw IOException("no file ending with \"$suffix\" in SAF directory")
|
||||
val inStream =
|
||||
context.contentResolver.openInputStream(file.uri)
|
||||
?: throw IOException("openInputStream returned null for ${file.uri}")
|
||||
return InputStreamAdapter(inStream)
|
||||
}
|
||||
|
||||
fun setUri(uri: String) {
|
||||
savedUri = uri
|
||||
}
|
||||
|
||||
private class SeekableOutputStream(
|
||||
private val fos: FileOutputStream,
|
||||
private val pfd: ParcelFileDescriptor
|
||||
) : OutputStream() {
|
||||
private var closed = false
|
||||
|
||||
override fun write(b: Int) = fos.write(b)
|
||||
|
||||
override fun write(b: ByteArray) = fos.write(b)
|
||||
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
fos.write(b, off, len)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closed) {
|
||||
closed = true
|
||||
try {
|
||||
fos.flush()
|
||||
fos.fd.sync() // blocks until data + metadata are durable
|
||||
} finally {
|
||||
fos.close()
|
||||
pfd.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() = fos.flush()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:tint="#000000">
|
||||
|
||||
<!-- Folder outline -->
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z" />
|
||||
|
||||
<!-- Flipped arrow, shifted downward by 1dp (in viewport units) -->
|
||||
<group
|
||||
android:translateY="2">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M16,10.99l-1.41,-1.41L13,11.16V7h-2v4.16L9.41,9.58 8,10.99 12.01,15 16,10.99z" />
|
||||
</group>
|
||||
</vector>
|
||||
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z"/>
|
||||
|
||||
</vector>
|
||||
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2zM16,17L8,17v-6c0,-2.48 1.51,-4.5 4,-4.5s4,2.02 4,4.5v6z"/>
|
||||
|
||||
</vector>
|
||||
@ -1 +1 @@
|
||||
982da8f24fa0504f2214f24b0d68b2febd5983f8
|
||||
aa85d1541af0921f830f053f29d91971fa5838f6
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package libtailscale
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"tailscale.com/feature/taildrop"
|
||||
)
|
||||
|
||||
// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper.
|
||||
type androidFileOps struct {
|
||||
helper ShareFileHelper
|
||||
}
|
||||
|
||||
var _ taildrop.FileOps = (*androidFileOps)(nil)
|
||||
|
||||
func newAndroidFileOps(helper ShareFileHelper) *androidFileOps {
|
||||
return &androidFileOps{helper: helper}
|
||||
}
|
||||
|
||||
func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
|
||||
wc, err := ops.helper.OpenFileWriter(name, offset)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
uri, err := ops.helper.GetFileURI(name)
|
||||
if err != nil {
|
||||
wc.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
return wc, uri, nil
|
||||
}
|
||||
|
||||
func (ops *androidFileOps) Remove(baseName string) error {
|
||||
uri, err := ops.helper.GetFileURI(baseName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ops.helper.DeleteFile(uri)
|
||||
}
|
||||
|
||||
func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) {
|
||||
return ops.helper.RenameFile(oldPath, newName)
|
||||
}
|
||||
|
||||
func (ops *androidFileOps) ListFiles() ([]string, error) {
|
||||
namesJSON, err := ops.helper.ListFilesJSON("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
if err := json.Unmarshal([]byte(namesJSON), &names); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) {
|
||||
in, err := ops.helper.OpenFileReader(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adaptInputStream(in), nil
|
||||
}
|
||||
|
||||
func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) {
|
||||
infoJSON, err := ops.helper.GetFileInfo(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fi androidFileInfo
|
||||
if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fi, nil
|
||||
}
|
||||
|
||||
type androidFileInfoJSON struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"modTime"`
|
||||
}
|
||||
|
||||
type androidFileInfo struct {
|
||||
data androidFileInfoJSON
|
||||
}
|
||||
|
||||
// compile-time check
|
||||
var _ os.FileInfo = (*androidFileInfo)(nil)
|
||||
|
||||
func (fi *androidFileInfo) Name() string { return fi.data.Name }
|
||||
func (fi *androidFileInfo) Size() int64 { return fi.data.Size }
|
||||
func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 }
|
||||
func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) }
|
||||
func (fi *androidFileInfo) IsDir() bool { return false }
|
||||
func (fi *androidFileInfo) Sys() any { return nil }
|
||||
@ -0,0 +1,91 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package libtailscale
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func emptyHardwareAttestationKey(appCtx AppContext) key.HardwareAttestationKey {
|
||||
return &hardwareAttestationKey{appCtx: appCtx}
|
||||
}
|
||||
|
||||
func createHardwareAttestationKey(appCtx AppContext) (key.HardwareAttestationKey, error) {
|
||||
id, err := appCtx.HardwareAttestationKeyCreate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := &hardwareAttestationKey{appCtx: appCtx, id: id}
|
||||
if err := k.fetchPublic(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
var hardwareAttestationKeyNotInitialized = errors.New("HardwareAttestationKey has not been initialized")
|
||||
|
||||
type hardwareAttestationKey struct {
|
||||
appCtx AppContext
|
||||
id string
|
||||
// public key is always initialized in createHardwareAttestationKey and
|
||||
// UnmarshalJSON. It's only nil in emptyHardwareAttestationKey.
|
||||
public *ecdsa.PublicKey
|
||||
}
|
||||
|
||||
func (k *hardwareAttestationKey) fetchPublic() error {
|
||||
if k.id == "" || k.appCtx == nil {
|
||||
return hardwareAttestationKeyNotInitialized
|
||||
}
|
||||
|
||||
pubRaw, err := k.appCtx.HardwareAttestationKeyPublic(k.id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading public key from KeyStore: %w", err)
|
||||
}
|
||||
pubAny, err := x509.ParsePKIXPublicKey(pubRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing public key: %w", err)
|
||||
}
|
||||
pub, ok := pubAny.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("parsed key is %T, expected *ecdsa.PublicKey", pubAny)
|
||||
}
|
||||
k.public = pub
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *hardwareAttestationKey) Public() crypto.PublicKey { return k.public }
|
||||
|
||||
func (k *hardwareAttestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
|
||||
if k.id == "" || k.appCtx == nil {
|
||||
return nil, hardwareAttestationKeyNotInitialized
|
||||
}
|
||||
return k.appCtx.HardwareAttestationKeySign(k.id, digest)
|
||||
}
|
||||
|
||||
func (k *hardwareAttestationKey) MarshalJSON() ([]byte, error) { return json.Marshal(k.id) }
|
||||
|
||||
func (k *hardwareAttestationKey) UnmarshalJSON(data []byte) error {
|
||||
if err := json.Unmarshal(data, &k.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.appCtx.HardwareAttestationKeyLoad(k.id); err != nil {
|
||||
return fmt.Errorf("loading key with ID %q from KeyStore: %w", k.id, err)
|
||||
}
|
||||
return k.fetchPublic()
|
||||
}
|
||||
|
||||
func (k *hardwareAttestationKey) Close() error {
|
||||
if k.id == "" || k.appCtx == nil {
|
||||
return hardwareAttestationKeyNotInitialized
|
||||
}
|
||||
return k.appCtx.HardwareAttestationKeyRelease(k.id)
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package libtailscale
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
)
|
||||
|
||||
// adaptInputStream wraps an [InputStream] into an [io.ReadCloser].
|
||||
// It launches a goroutine to stream reads into a pipe.
|
||||
func adaptInputStream(in InputStream) io.ReadCloser {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
r, w := io.Pipe()
|
||||
go func() {
|
||||
defer w.Close()
|
||||
for {
|
||||
b, err := in.Read()
|
||||
if err != nil {
|
||||
log.Printf("error reading from inputstream: %v", err)
|
||||
return
|
||||
}
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(b); err != nil {
|
||||
log.Printf("error writing to pipe: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return r
|
||||
}
|
||||
Loading…
Reference in New Issue