diff --git a/android/build.gradle b/android/build.gradle
index 0240870..155b72c 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -87,6 +87,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2'
+ implementation "com.google.accompanist:accompanist-permissions:0.34.0"
// Navigation dependencies.
def nav_version = "2.7.7"
diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt
index d8f0924..1536d92 100644
--- a/android/src/main/java/com/tailscale/ipn/App.kt
+++ b/android/src/main/java/com/tailscale/ipn/App.kt
@@ -36,7 +36,6 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
-import androidx.core.content.ContextCompat
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting
@@ -314,20 +313,6 @@ class App : Application(), libtailscale.AppContext {
return null
}
- fun requestWriteStoragePermission(act: Activity) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
- Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- // We can write files without permission.
- return
- }
- if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
- PackageManager.PERMISSION_GRANTED) {
- return
- }
- act.requestPermissions(
- arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), IPNActivity.WRITE_STORAGE_RESULT)
- }
-
@Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
diff --git a/android/src/main/java/com/tailscale/ipn/IPNActivity.java b/android/src/main/java/com/tailscale/ipn/IPNActivity.java
index c5eb741..48792eb 100644
--- a/android/src/main/java/com/tailscale/ipn/IPNActivity.java
+++ b/android/src/main/java/com/tailscale/ipn/IPNActivity.java
@@ -5,7 +5,6 @@ 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;
@@ -14,11 +13,7 @@ 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);
@@ -88,15 +83,6 @@ public final class IPNActivity extends Activity {
// 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();
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
index 4065dbd..7e5771b 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
@@ -37,7 +37,7 @@ fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
@Composable
fun ErrorDialog(
- @StringRes title: Int,
+ @StringRes title: Int = R.string.error,
@StringRes message: Int,
@StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
index e536e73..9180a08 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
@@ -3,6 +3,9 @@
package com.tailscale.ipn.ui.view
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -36,8 +39,10 @@ import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -49,6 +54,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.shouldShowRationale
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
@@ -60,6 +69,7 @@ import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView
@@ -69,6 +79,7 @@ data class MainViewNavigation(
val onNavigateToExitNodes: () -> Unit
)
+@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
LoadingIndicator.Wrap {
@@ -108,6 +119,8 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
when (state.value) {
Ipn.State.Running -> {
+ PromptWriteStoragePermissionsIfNecessary()
+
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
Row(
modifier =
@@ -395,3 +408,34 @@ fun PeerList(
}
}
}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun PromptWriteStoragePermissionsIfNecessary() {
+ val writeStoragePermissionState =
+ rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+
+ val showDialog = remember { MutableStateFlow(false) }
+
+ val requestPermissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (!granted) {
+ showDialog.value = true
+ }
+ }
+
+ LaunchedEffect(writeStoragePermissionState) {
+ if (!writeStoragePermissionState.status.isGranted &&
+ writeStoragePermissionState.status.shouldShowRationale) {
+ showDialog.value = true
+ } else {
+ requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ }
+ }
+
+ if (showDialog.collectAsState().value) {
+ ErrorDialog(title = R.string.permission_required, message = R.string.taildrop_requires_write) {
+ showDialog.value = false
+ }
+ }
+}
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index 01f4794..f8c8173 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -160,4 +160,7 @@
Shows or hides the UI to run the Android device as an exit node.
Run As Exit Node visibility
+
+ Permission Required
+ Please grant access to write to external storage to be able to receive files with Taildrop.