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-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"

@ -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) {

@ -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();

@ -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 = {}

@ -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
}
}
}

@ -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="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>

Loading…
Cancel
Save