-
Notifications
You must be signed in to change notification settings - Fork 511
android: use SAF for storing Taildropped files #632
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,18 +10,21 @@ import android.content.Context | |
import android.content.Intent | ||
import android.content.RestrictionsManager | ||
import android.content.pm.ActivityInfo | ||
import android.content.pm.PackageManager | ||
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE | ||
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK | ||
import android.net.ConnectivityManager | ||
import android.net.NetworkCapabilities | ||
import android.net.Uri | ||
import android.os.Build | ||
import android.os.Bundle | ||
import android.os.Process | ||
import android.provider.Settings | ||
import android.util.Log | ||
import androidx.activity.ComponentActivity | ||
import androidx.activity.compose.setContent | ||
import androidx.activity.result.ActivityResultLauncher | ||
import androidx.activity.result.contract.ActivityResultContract | ||
import androidx.activity.result.contract.ActivityResultContracts | ||
import androidx.annotation.RequiresApi | ||
import androidx.browser.customtabs.CustomTabsIntent | ||
import androidx.compose.animation.core.LinearOutSlowInEasing | ||
|
@@ -89,8 +92,13 @@ import kotlinx.coroutines.cancel | |
import kotlinx.coroutines.flow.MutableStateFlow | ||
import kotlinx.coroutines.flow.StateFlow | ||
import kotlinx.coroutines.launch | ||
import libtailscale.Libtailscale | ||
import java.io.IOException | ||
import java.security.GeneralSecurityException | ||
|
||
class MainActivity : ComponentActivity() { | ||
// Key to store the SAF URI in EncryptedSharedPreferences. | ||
val PREF_KEY_SAF_URI = "saf_directory_uri" | ||
private lateinit var navController: NavHostController | ||
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent> | ||
private val viewModel: MainViewModel by lazy { | ||
|
@@ -150,6 +158,41 @@ class MainActivity : ComponentActivity() { | |
} | ||
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) | ||
|
||
val directoryPickerLauncher = | ||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> | ||
if (uri != null) { | ||
try { | ||
// Try to take persistable permissions for both read and write. | ||
contentResolver.takePersistableUriPermission( | ||
uri, | ||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) | ||
} catch (e: SecurityException) { | ||
TSLog.e("MainActivity", "Failed to persist permissions: $e") | ||
} | ||
|
||
// Check if write permission is actually granted. | ||
val writePermission = | ||
this.checkUriPermission( | ||
uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION) | ||
if (writePermission == PackageManager.PERMISSION_GRANTED) { | ||
TSLog.d("MainActivity", "Write permission granted for $uri") | ||
Libtailscale.setDirectFileRoot(uri.toString()) | ||
saveFileDirectory(uri) | ||
} else { | ||
TSLog.d( | ||
"MainActivity", | ||
"Write access not granted for $uri. Falling back to internal storage.") | ||
// Don't save directory URI and fall back to internal storage. | ||
} | ||
} else { | ||
TSLog.d( | ||
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") | ||
// Fall back to internal storage. | ||
} | ||
} | ||
Comment on lines
+161
to
+192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the MainActivity.kt directoryPickerLauncher, there's a missing call to |
||
|
||
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) | ||
|
||
setContent { | ||
navController = rememberNavController() | ||
|
||
|
@@ -198,7 +241,7 @@ class MainActivity : ComponentActivity() { | |
onNavigateToSearch = { | ||
viewModel.enableSearchAutoFocus() | ||
navController.navigate("search") | ||
}) | ||
}) | ||
|
||
val settingsNav = | ||
SettingsNav( | ||
|
@@ -245,9 +288,8 @@ class MainActivity : ComponentActivity() { | |
viewModel = viewModel, | ||
navController = navController, | ||
onNavigateBack = { navController.popBackStack() }, | ||
autoFocus = autoFocus | ||
) | ||
} | ||
autoFocus = autoFocus) | ||
} | ||
composable("settings") { SettingsView(settingsNav) } | ||
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } | ||
composable("health") { HealthView(backTo("main")) } | ||
|
@@ -365,23 +407,35 @@ class MainActivity : ComponentActivity() { | |
override fun onNewIntent(intent: Intent) { | ||
super.onNewIntent(intent) | ||
if (intent.getBooleanExtra(START_AT_ROOT, false)) { | ||
if (this::navController.isInitialized) { | ||
val previousEntry = navController.previousBackStackEntry | ||
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") | ||
|
||
if (previousEntry != null) { | ||
navController.popBackStack(route = "main", inclusive = false) | ||
} else { | ||
TSLog.e("MainActivity", "onNewIntent: No previous back stack entry, navigating directly to 'main'") | ||
navController.navigate("main") { | ||
popUpTo("main") { inclusive = true } | ||
} | ||
} | ||
if (this::navController.isInitialized) { | ||
val previousEntry = navController.previousBackStackEntry | ||
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") | ||
|
||
if (previousEntry != null) { | ||
navController.popBackStack(route = "main", inclusive = false) | ||
} else { | ||
TSLog.e( | ||
"MainActivity", | ||
"onNewIntent: No previous back stack entry, navigating directly to 'main'") | ||
navController.navigate("main") { popUpTo("main") { inclusive = true } } | ||
} | ||
} | ||
} | ||
} | ||
|
||
} | ||
|
||
@Throws(IOException::class, GeneralSecurityException::class) | ||
fun saveFileDirectory(directoryUri: Uri) { | ||
val prefs = App.get().getEncryptedPrefs() | ||
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply() | ||
try { | ||
// Must restart Tailscale because a new LocalBackend with the new directory must be created. | ||
App.get().startLibtailscale(directoryUri.toString()) | ||
} catch (e: Exception) { | ||
TSLog.d( | ||
"MainActivity", | ||
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e") | ||
} | ||
} | ||
|
||
private fun login(urlString: String) { | ||
// Launch coroutine to listen for state changes. When the user completes login, relaunch | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 { | ||
kari-ts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return try { | ||
outputStream.write(data) | ||
outputStream.flush() | ||
data.size.toLong() | ||
} catch (e: Exception) { | ||
Comment on lines
+12
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the OutputStreamAdapter.write method, the return value is always data.size.toLong() regardless of how many bytes were actually written. Java's OutputStream.write might write fewer bytes than requested. Consider capturing the actual bytes written or ensuring the entire buffer is written (possibly with multiple calls). |
||
TSLog.d("OutputStreamAdapter", "write exception: $e") | ||
-1L | ||
} | ||
barnstar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
override fun close() { | ||
outputStream.close() | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are incoming Taildrop files relevant on Android TV? They are not on AppleTV. If the user can actually pick a directory and use the files - all good - otherwise we should skip the dialog.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good call. gating it with an AndroidTV check