Skip to content

Commit cd7b702

Browse files
committed
android: skip SAF directory picker if unsupported
updates tailscale/corp#30254 This adds an upfront isAndroidTV check before we set the SAF directory picker activity. We'll check both that we can actually launch the activity and that this isn't and AndroidTV device where the directory picker activity isn't supported or has a tendency to throw ActivityNotFound exceptions.
1 parent e5a704f commit cd7b702

File tree

2 files changed

+71
-47
lines changed

2 files changed

+71
-47
lines changed

android/src/main/java/com/tailscale/ipn/MainActivity.kt

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import com.tailscale.ipn.mdm.ShowHide
5353
import com.tailscale.ipn.ui.model.Ipn
5454
import com.tailscale.ipn.ui.notifier.Notifier
5555
import com.tailscale.ipn.ui.theme.AppTheme
56-
import com.tailscale.ipn.ui.util.AndroidTVUtil
56+
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
5757
import com.tailscale.ipn.ui.util.set
5858
import com.tailscale.ipn.ui.util.universalFit
5959
import com.tailscale.ipn.ui.view.AboutView
@@ -177,49 +177,53 @@ class MainActivity : ComponentActivity() {
177177
}
178178
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
179179

180-
val directoryPickerLauncher =
181-
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
182-
if (uri != null) {
183-
try {
184-
// Try to take persistable permissions for both read and write.
185-
contentResolver.takePersistableUriPermission(
186-
uri,
187-
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
188-
} catch (e: SecurityException) {
189-
TSLog.e("MainActivity", "Failed to persist permissions: $e")
190-
}
180+
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
181+
if (canOpenDocumentTree()) {
182+
directoryPickerLauncher =
183+
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
184+
if (uri != null) {
185+
try {
186+
// Try to take persistable permissions for both read and write.
187+
contentResolver.takePersistableUriPermission(
188+
uri,
189+
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
190+
} catch (e: SecurityException) {
191+
TSLog.e("MainActivity", "Failed to persist permissions: $e")
192+
}
191193

192-
// Check if write permission is actually granted.
193-
val writePermission =
194-
this.checkUriPermission(
195-
uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
196-
if (writePermission == PackageManager.PERMISSION_GRANTED) {
197-
TSLog.d("MainActivity", "Write permission granted for $uri")
198-
199-
lifecycleScope.launch(Dispatchers.IO) {
200-
try {
201-
Libtailscale.setDirectFileRoot(uri.toString())
202-
TaildropDirectoryStore.saveFileDirectory(uri)
203-
permissionsViewModel.refreshCurrentDir()
204-
} catch (e: Exception) {
205-
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
194+
// Check if write permission is actually granted.
195+
val writePermission =
196+
this.checkUriPermission(
197+
uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
198+
if (writePermission == PackageManager.PERMISSION_GRANTED) {
199+
TSLog.d("MainActivity", "Write permission granted for $uri")
200+
201+
lifecycleScope.launch(Dispatchers.IO) {
202+
try {
203+
Libtailscale.setDirectFileRoot(uri.toString())
204+
TaildropDirectoryStore.saveFileDirectory(uri)
205+
permissionsViewModel.refreshCurrentDir()
206+
} catch (e: Exception) {
207+
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
208+
}
206209
}
210+
} else {
211+
TSLog.d(
212+
"MainActivity",
213+
"Write access not granted for $uri. Falling back to internal storage.")
214+
// Don't save directory URI and fall back to internal storage.
207215
}
208216
} else {
209217
TSLog.d(
210218
"MainActivity",
211-
"Write access not granted for $uri. Falling back to internal storage.")
212-
// Don't save directory URI and fall back to internal storage.
213-
}
214-
} else {
215-
TSLog.d(
216-
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
219+
"Taildrop directory not saved. Will fall back to internal storage.")
217220

218-
// Fall back to internal storage.
221+
// Fall back to internal storage.
222+
}
219223
}
220-
}
221224

222-
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
225+
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
226+
}
223227

224228
setContent {
225229
navController = rememberNavController()
@@ -354,9 +358,11 @@ class MainActivity : ComponentActivity() {
354358
{ navController.navigate("taildropDir") },
355359
{ navController.navigate("notifications") })
356360
}
357-
composable("taildropDir") {
358-
TaildropDirView(
359-
backTo("permissions"), directoryPickerLauncher, permissionsViewModel)
361+
directoryPickerLauncher?.let {
362+
val launcher = it
363+
composable("taildropDir") {
364+
TaildropDirView(backTo("permissions"), launcher, permissionsViewModel)
365+
}
360366
}
361367
composable("notifications") {
362368
NotificationsView(backTo("permissions"), ::openApplicationSettings)
@@ -406,6 +412,16 @@ class MainActivity : ComponentActivity() {
406412
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
407413
}
408414

415+
// Most AndroidTV's don't support this and the UX is completely broken regardless. We have
416+
// reports of some old devices throwing ActivityNotFound exceptions on TV as well, so we
417+
// carefully guard against the attempt.
418+
private fun Context.canOpenDocumentTree(): Boolean {
419+
return !isAndroidTV() &&
420+
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
421+
.addCategory(Intent.CATEGORY_DEFAULT)
422+
.resolveActivity(packageManager) != null
423+
}
424+
409425
private fun showOtherVPNConflictDialog() {
410426
AlertDialog.Builder(this)
411427
.setTitle(R.string.vpn_permission_denied)
@@ -437,7 +453,7 @@ class MainActivity : ComponentActivity() {
437453
// Returns true if we should render a QR code instead of launching a browser
438454
// for login requests
439455
private fun useQRCodeLogin(): Boolean {
440-
return AndroidTVUtil.isAndroidTV()
456+
return isAndroidTV()
441457
}
442458

443459
override fun onNewIntent(intent: Intent) {

android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
221221
}
222222

223223
fun showDirectoryPickerLauncher() {
224-
_showDirectoryPickerInterstitial.set(false)
225-
directoryPickerLauncher?.launch(null)
224+
directoryPickerLauncher?.let {
225+
_showDirectoryPickerInterstitial.set(false)
226+
it.launch(null)
227+
}
226228
}
227229

228230
fun checkIfTaildropDirectorySelected() {
@@ -233,17 +235,23 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
233235
val app = App.get()
234236
val storedUri = app.getStoredDirectoryUri()
235237
if (storedUri == null) {
236-
// No stored URI, so launch the directory picker.
237-
_showDirectoryPickerInterstitial.set(true)
238+
if (directoryPickerLauncher != null) {
239+
// No stored URI, so launch the directory picker.
240+
_showDirectoryPickerInterstitial.set(true)
241+
}
238242
return
239243
}
240244

241245
val documentFile = DocumentFile.fromTreeUri(app, storedUri)
242246
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
243-
TSLog.d(
244-
"MainViewModel",
245-
"Stored directory URI is invalid or inaccessible; launching directory picker.")
246-
_showDirectoryPickerInterstitial.set(true)
247+
if (directoryPickerLauncher != null) {
248+
TSLog.d(
249+
"MainViewModel",
250+
"Stored directory URI is invalid or inaccessible; launching directory picker.")
251+
_showDirectoryPickerInterstitial.set(true)
252+
} else {
253+
TSLog.d("MainViewModel", "Directory picker activity not available")
254+
}
247255
} else {
248256
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
249257
}

0 commit comments

Comments
 (0)