android/ui: prompt for write external storage permission and show error if necessary

Updates #ENG-2948

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/251/head
Percy Wegmann 2 months ago committed by Percy Wegmann
parent e511430f73
commit 9f3e871637

@ -87,6 +87,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.activity:activity-compose:1.8.2'
implementation "com.google.accompanist:accompanist-permissions:0.34.0"
// Navigation dependencies. // Navigation dependencies.
def nav_version = "2.7.7" def nav_version = "2.7.7"

@ -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
@ -314,20 +313,6 @@ class App : Application(), libtailscale.AppContext {
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) {

@ -5,7 +5,6 @@ package com.tailscale.ipn;
import android.app.Activity; import android.app.Activity;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
@ -14,11 +13,7 @@ import android.provider.OpenableColumns;
import java.util.List; import java.util.List;
import libtailscale.Libtailscale;
public final class IPNActivity extends Activity { public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000;
@Override @Override
public void onCreate(Bundle state) { public void onCreate(Bundle state) {
super.onCreate(state); super.onCreate(state);
@ -88,15 +83,6 @@ public final class IPNActivity extends Activity {
// App.onShareIntent(nfiles, types, mimes, items, names, sizes); // 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 @Override
public void onDestroy() { public void onDestroy() {
super.onDestroy(); super.onDestroy();

@ -37,7 +37,7 @@ fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
@Composable @Composable
fun ErrorDialog( fun ErrorDialog(
@StringRes title: Int, @StringRes title: Int = R.string.error,
@StringRes message: Int, @StringRes message: Int,
@StringRes buttonText: Int = R.string.ok, @StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}

@ -3,6 +3,9 @@
package com.tailscale.ipn.ui.view 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -36,8 +39,10 @@ import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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
@ -49,6 +54,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.R
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
@ -60,6 +69,7 @@ import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView // Navigation actions for the MainView
@ -69,6 +79,7 @@ data class MainViewNavigation(
val onNavigateToExitNodes: () -> Unit val onNavigateToExitNodes: () -> Unit
) )
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
@ -108,6 +119,8 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
PromptWriteStoragePermissionsIfNecessary()
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
Row( Row(
modifier = 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
}
}
}

@ -160,4 +160,7 @@
<string name="shows_or_hides_the_ui_to_run_the_android_device_as_an_exit_node">Shows or hides the UI to run the Android device as an exit node.</string> <string name="shows_or_hides_the_ui_to_run_the_android_device_as_an_exit_node">Shows or hides the UI to run the Android device as an exit node.</string>
<string name="run_as_exit_node_visibility">Run As Exit Node visibility</string> <string name="run_as_exit_node_visibility">Run As Exit Node visibility</string>
<!-- Permissions Management -->
<string name="permission_required">Permission Required</string>
<string name="taildrop_requires_write">Please grant access to write to external storage to be able to receive files with Taildrop.</string>
</resources> </resources>

Loading…
Cancel
Save