Handle request runtime permission

This commit is contained in:
2026-06-22 21:41:58 +07:00
parent e0f152e6d7
commit b092987709
17 changed files with 467 additions and 134 deletions
@@ -44,4 +44,32 @@
<string name="action_retry">Retry</string>
<string name="device_connecting">Connecting…</string>
<string name="device_explanation">It should just take a moment…</string>
<string name="action_grant_permission">Grant permission</string>
<string name="action_settings">Settings</string>
<string name="action_enable">Enable</string>
<string name="action_try_again">Try again</string>
<string name="action_location_settings">Location settings</string>
<string name="action_go_up">Back</string>
<string name="bluetooth_disabled_title">BLUETOOTH DISABLED</string>
<string name="bluetooth_disabled_info">The Bluetooth adapter is turned off.\nClick the button below to enable it.</string>
<string name="location_permission_title">LOCATION PERMISSION REQUIRED</string>
<string name="location_permission_info">From Android 6.0 Marshmallow until Android 11 the application requires Location permission in order to scan for Bluetooth Low Energy devices. This is because Bluetooth LE beacons, for example iBeacons or Eddystone, may be used to
determine the phone's and user's location.\n
The app will not use this information in any way.\n
On Android 12 or newer a new Nearby permission can be requested instead, without the need for location.</string>
<string name="bluetooth_permission_title">BLUETOOTH PERMISSIONS REQUIRED</string>
<string name="bluetooth_permission_info">The app needs to scan and connect to Bluetooth LE devices.</string>
<string name="bluetooth_not_available_title">BLUETOOTH NOT AVAILABLE</string>
<string name="bluetooth_not_available_info">Bluetooth is not available on your phone.\nWe cannot provide you the feature.</string>
<string name="location_required_title">Location required</string>
<string name="bluetooth_required_title">Bluetooth required</string>
<string name="location_disabled_title">LOCATION DISABLED</string>
<string name="location_disabled_info">Location is turned off. Most Android phones require it in order to scan for Bluetooth LE devices.\n
Click the button below to enable Location.</string>
</resources>
@@ -6,6 +6,7 @@ import androidx.compose.ui.tooling.preview.Preview
import com.digitoolsolutions.app.torquevaultkmp.screens.MainScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.BluetoothRequirementWrapper
import com.digitoolsolutions.app.torquevaultkmp.theme.TorqueVaultTheme
import io.github.aakira.napier.Napier
import org.koin.compose.viewmodel.koinViewModel
@Composable
@@ -1,5 +1,6 @@
package com.digitoolsolutions.app.torquevaultkmp.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -101,8 +102,8 @@ fun WarningView(
) {
WarningView(
painterResource = painterResource,
title = { Text(text = title) },
hint = { Hint(text = hint, textAlign = hintTextAlign) },
title = { Text(text = title, color = MaterialTheme.colorScheme.onBackground) },
hint = { Hint(text = hint, textAlign = hintTextAlign, color = MaterialTheme.colorScheme.onBackground) },
modifier = modifier,
content = content,
)
@@ -128,8 +129,8 @@ fun WarningView(
) {
WarningView(
painterResource = painterResource,
title = { Text(text = title) },
hint = { Hint(text = hint, textAlign = hintTextAlign) },
title = { Text(text = title, color = MaterialTheme.colorScheme.onBackground) },
hint = { Hint(text = hint, textAlign = hintTextAlign, color = MaterialTheme.colorScheme.onBackground) },
modifier = modifier,
content = content,
)
@@ -179,7 +180,7 @@ fun WarningView(
content: @Composable ColumnScope.() -> Unit = {}
) {
Column(
modifier = modifier,
modifier = modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -36,6 +36,7 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen
import io.github.aakira.napier.Napier
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@@ -1,20 +1,10 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BluetoothDisabled
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -22,55 +12,62 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views.BluetoothDisabledView
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views.LocationDisabledView
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views.LocationPermissionRequiredView
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.HandlePermissionRequest
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import org.koin.compose.koinInject
@Composable
fun BluetoothRequirementWrapper(
content: @Composable () -> Unit
content: @Composable () -> Unit,
) {
val bluetoothManager: BluetoothManager = koinInject()
val permissionHandler: PermissionHandler = koinInject()
val isEnabled by bluetoothManager.isBluetoothEnabled.collectAsState()
val hasPermission by bluetoothManager.hasBluetoothPermission.collectAsState()
var hasPermanentlyDenied by remember { mutableStateOf(false) }
val isLocationEnabled by permissionHandler.isLocationEnabled.collectAsState()
var showPermissionRequest by remember { mutableStateOf(false) }
if (showPermissionRequest) {
HandlePermissionRequest { _ ->
showPermissionRequest = false
bluetoothManager.checkBluetoothPermission()
permissionHandler.HandlePermissionRequest(
{ allGranted, denied ->
showPermissionRequest = false
bluetoothManager.checkBluetoothPermission()
hasPermanentlyDenied = denied
}
) {
requestPermissions ->
LaunchedEffect(Unit) {
requestPermissions()
}
}
}
LifecycleResumeEffect(Unit) {
bluetoothManager.checkBluetoothState()
bluetoothManager.checkBluetoothPermission()
permissionHandler.checkLocationState()
onPauseOrDispose { }
}
LaunchedEffect(isEnabled, hasPermission) {
if (!isEnabled && hasPermission) {
bluetoothManager.enableBluetooth()
}
}
if (isEnabled && hasPermission) {
content()
} else {
/* MainScreen */
content()
/* Overlay content */
if (!isEnabled || !hasPermission || !isLocationEnabled) {
BluetoothRequestScreen(
isEnabled = isEnabled,
hasPermission = hasPermission,
hasPermanentlyDenied = hasPermanentlyDenied,
isLocationEnabled = isLocationEnabled,
onEnableBluetooth = { bluetoothManager.enableBluetooth() },
onRequestPermission = {
bluetoothManager.enableBluetooth()
showPermissionRequest = true
},
onEnableLocation = { permissionHandler.enableLocation() },
onRequestPermission = { showPermissionRequest = true },
onOpenSettings = { bluetoothManager.openSettings() }
)
}
@@ -80,46 +77,28 @@ fun BluetoothRequirementWrapper(
fun BluetoothRequestScreen(
isEnabled: Boolean,
hasPermission: Boolean,
hasPermanentlyDenied: Boolean,
isLocationEnabled: Boolean,
onEnableBluetooth: () -> Unit,
onEnableLocation: () -> Unit,
onRequestPermission: () -> Unit,
onOpenSettings: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = if (!hasPermission) Icons.Default.Security else Icons.Default.BluetoothDisabled,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = if (!hasPermission) "Bluetooth Permission Required" else "Bluetooth is Disabled",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (!hasPermission)
"This app needs Bluetooth permissions to connect to your devices. Please grant permission."
else "Please enable Bluetooth to use this app's features.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
if (!hasPermission) {
Button(onClick = onRequestPermission) {
Text("Request Permission")
}
} else if (!isEnabled) {
Button(onClick = onEnableBluetooth) {
Text("Enable Bluetooth")
}
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(12.dp)
){
when {
!isEnabled -> BluetoothDisabledView(onEnable = onEnableBluetooth)
!hasPermission -> LocationPermissionRequiredView(
hasPermission = hasPermission,
hasPermanentlyDenied = hasPermanentlyDenied,
onRequestPermission = onRequestPermission,
onOpenSetting = onOpenSettings
)
!isLocationEnabled -> LocationDisabledView(onEnable = onEnableLocation)
}
}
}
@@ -0,0 +1,37 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.action_enable
import torquevaultkmp.composeapp.generated.resources.bluetooth_disabled_info
import torquevaultkmp.composeapp.generated.resources.bluetooth_disabled_title
@Composable
internal fun BluetoothDisabledView(
onEnable: () -> Unit,
) {
WarningView(
imageVector = Icons.Rounded.BluetoothDisabled,
title = stringResource(Res.string.bluetooth_disabled_title),
hint = stringResource(Res.string.bluetooth_disabled_info),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Button(onClick = onEnable) {
Text(text = stringResource(Res.string.action_enable))
}
}
}
@@ -0,0 +1,26 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.baseline_bluetooth_disabled_24
import torquevaultkmp.composeapp.generated.resources.bluetooth_not_available_info
import torquevaultkmp.composeapp.generated.resources.bluetooth_not_available_title
@Composable
internal fun BluetoothNotAvailableView() {
WarningView(
painterResource = painterResource(Res.drawable.baseline_bluetooth_disabled_24),
title = stringResource(Res.string.bluetooth_not_available_title),
hint = stringResource(Res.string.bluetooth_not_available_info),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
)
}
@@ -0,0 +1,64 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
//import android.Manifest
//import android.content.Context
//import android.content.Intent
//import android.net.Uri
//import android.os.Build
//import android.provider.Settings
//import androidx.activity.compose.rememberLauncherForActivityResult
//import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material3.Button
import androidx.compose.material3.SegmentedButtonDefaults.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
//import androidx.compose.ui.platform.LocalContext
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.action_grant_permission
import torquevaultkmp.composeapp.generated.resources.action_settings
import torquevaultkmp.composeapp.generated.resources.baseline_bluetooth_disabled_24
import torquevaultkmp.composeapp.generated.resources.bluetooth_permission_info
import torquevaultkmp.composeapp.generated.resources.bluetooth_permission_title
@Composable
internal fun BluetoothPermissionRequiredView(
hasPermission: Boolean,
hasPermanentlyDenied: Boolean,
onRequestPermission: () -> Unit,
onOpenSetting: () -> Unit
) {
WarningView(
imageVector = Icons.Rounded.BluetoothDisabled,
title = stringResource(Res.string.bluetooth_permission_title),
hint = stringResource(Res.string.bluetooth_permission_info),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
when {
!hasPermission -> Button(
onClick = { onRequestPermission() }
) {
Text(text = stringResource(Res.string.action_grant_permission))
}
!hasPermanentlyDenied -> Button(
onClick = { onOpenSetting() }
) {
Text(text = stringResource(Res.string.action_settings))
}
}
}
}
@@ -0,0 +1,35 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.action_enable
import torquevaultkmp.composeapp.generated.resources.location_disabled_info
import torquevaultkmp.composeapp.generated.resources.location_disabled_title
@Composable
internal fun LocationDisabledView(
onEnable: () -> Unit,
) {
WarningView(
imageVector = Icons.Rounded.LocationOff,
title = stringResource(Res.string.location_disabled_title),
hint = stringResource(Res.string.location_disabled_info),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Button(onClick = onEnable) {
Text(text = stringResource(Res.string.action_enable))
}
}
}
@@ -0,0 +1,52 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.action_grant_permission
import torquevaultkmp.composeapp.generated.resources.action_settings
import torquevaultkmp.composeapp.generated.resources.location_permission_info
import torquevaultkmp.composeapp.generated.resources.location_permission_title
@Composable
internal fun LocationPermissionRequiredView(
hasPermission: Boolean,
hasPermanentlyDenied: Boolean,
onRequestPermission: () -> Unit,
onOpenSetting: () -> Unit
) {
WarningView(
imageVector = Icons.Rounded.LocationOff,
title = stringResource(Res.string.location_permission_title),
hint = stringResource(Res.string.location_permission_info),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
when {
!hasPermission && !hasPermanentlyDenied -> {
Button(onClick = onRequestPermission) {
Text(stringResource(Res.string.action_grant_permission))
}
}
!hasPermission && hasPermanentlyDenied -> {
Button(onClick = onOpenSetting) {
Text(stringResource(Res.string.action_settings))
}
}
}
}
}
@@ -1,8 +1,15 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.StateFlow
@Composable
expect fun HandlePermissionRequest(
onPermissionResult: (Boolean) -> Unit
)
interface PermissionHandler {
@Composable
fun HandlePermissionRequest(
onPermissionResult: (Boolean, Boolean) -> Unit,
content: @Composable (requestPermissions: () -> Unit) -> Unit
)
val isLocationEnabled: StateFlow<Boolean>
fun enableLocation()
fun checkLocationState()
}