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
+10 -5
View File
@@ -1,12 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Android 6.011 -->
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" tools:targetApi="31"/>
<!-- Allow access http: android:usesCleartextTraffic="true" --> <!-- Allow access http: android:usesCleartextTraffic="true" -->
<application <application
android:name=".MyApplication" android:name=".MyApplication"
@@ -7,12 +7,15 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidBluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidBluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModule = module { actual val platformModule = module {
single<AppStorage> { AppStoragePlatform(androidContext()) } single<AppStorage> { AppStoragePlatform(androidContext()) }
single<BluetoothManager> { AndroidBluetoothManager(androidContext()) } single<BluetoothManager> { AndroidBluetoothManager(androidContext()) }
single<PermissionHandler> { PermissionHandlerPlatform(androidContext()) }
single<SqlDriver> { single<SqlDriver> {
AndroidSqliteDriver( AndroidSqliteDriver(
schema = AppDatabase.Schema, schema = AppDatabase.Schema,
@@ -1,36 +1,114 @@
package com.digitoolsolutions.app.torquevaultkmp.utils package com.digitoolsolutions.app.torquevaultkmp.utils
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.os.Build import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class PermissionHandlerPlatform(private val context: Context): PermissionHandler {
@SuppressLint("ContextCastToActivity")
@Composable @Composable
actual fun HandlePermissionRequest( override fun HandlePermissionRequest (
onPermissionResult: (Boolean) -> Unit onPermissionResult: (Boolean, Boolean) -> Unit,
content: @Composable (requestPermissions: () -> Unit) -> Unit
) { ) {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val permissions = if (isPreAndroid12()) {
/** For android 11- */
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
/** For android 12+ */
arrayOf( arrayOf(
Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT Manifest.permission.BLUETOOTH_CONNECT
) )
} else {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.ACCESS_FINE_LOCATION
)
} }
val activity: Activity? = LocalContext.current as? Activity
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { result -> ) { result ->
val allGranted = result.values.all { it } val allGranted = result.values.all { it }
onPermissionResult(allGranted) val permanentlyDenied = if (isPreAndroid12()) {
} /** For android 6.0 - 11 */
val deniedLocation = result[Manifest.permission.ACCESS_FINE_LOCATION] == false
deniedLocation && activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(it,
Manifest.permission.ACCESS_FINE_LOCATION)
} ?: false
} else {
/** For android 12+ */
val deniedScan = result[Manifest.permission.BLUETOOTH_SCAN] == false
val deniedConnect = result[Manifest.permission.BLUETOOTH_CONNECT] == false
LaunchedEffect(Unit) { val permDeniedScan = deniedScan && activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(it,
Manifest.permission.BLUETOOTH_SCAN)
} ?: false
val permDeniedConnect = deniedConnect && activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(it,
Manifest.permission.BLUETOOTH_CONNECT)
} ?: false
permDeniedScan || permDeniedConnect
}
onPermissionResult(allGranted, permanentlyDenied)
}
content {
launcher.launch(permissions) launcher.launch(permissions)
} }
} }
private fun isPreAndroid12(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S
}
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val _isLocationEnabled = MutableStateFlow(true)
override val isLocationEnabled: StateFlow<Boolean> = _isLocationEnabled.asStateFlow()
init {
if(isPreAndroid12()) {
val providerChangedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
checkLocationState()
}
}
}
checkLocationState()
context.registerReceiver(providerChangedReceiver,
IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)
)
}
}
override fun checkLocationState() {
if(isPreAndroid12()) {
try {
_isLocationEnabled.value =
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (e: SecurityException) {
_isLocationEnabled.value = false
}
}
}
override fun enableLocation() {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
@@ -44,4 +44,32 @@
<string name="action_retry">Retry</string> <string name="action_retry">Retry</string>
<string name="device_connecting">Connecting…</string> <string name="device_connecting">Connecting…</string>
<string name="device_explanation">It should just take a moment…</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> </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.MainScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.BluetoothRequirementWrapper import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.BluetoothRequirementWrapper
import com.digitoolsolutions.app.torquevaultkmp.theme.TorqueVaultTheme import com.digitoolsolutions.app.torquevaultkmp.theme.TorqueVaultTheme
import io.github.aakira.napier.Napier
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
@@ -1,5 +1,6 @@
package com.digitoolsolutions.app.torquevaultkmp.components package com.digitoolsolutions.app.torquevaultkmp.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@@ -101,8 +102,8 @@ fun WarningView(
) { ) {
WarningView( WarningView(
painterResource = painterResource, painterResource = painterResource,
title = { Text(text = title) }, title = { Text(text = title, color = MaterialTheme.colorScheme.onBackground) },
hint = { Hint(text = hint, textAlign = hintTextAlign) }, hint = { Hint(text = hint, textAlign = hintTextAlign, color = MaterialTheme.colorScheme.onBackground) },
modifier = modifier, modifier = modifier,
content = content, content = content,
) )
@@ -128,8 +129,8 @@ fun WarningView(
) { ) {
WarningView( WarningView(
painterResource = painterResource, painterResource = painterResource,
title = { Text(text = title) }, title = { Text(text = title, color = MaterialTheme.colorScheme.onBackground) },
hint = { Hint(text = hint, textAlign = hintTextAlign) }, hint = { Hint(text = hint, textAlign = hintTextAlign, color = MaterialTheme.colorScheme.onBackground) },
modifier = modifier, modifier = modifier,
content = content, content = content,
) )
@@ -179,7 +180,7 @@ fun WarningView(
content: @Composable ColumnScope.() -> Unit = {} content: @Composable ColumnScope.() -> Unit = {}
) { ) {
Column( Column(
modifier = modifier, modifier = modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, 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.scanner.ScannerViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen 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.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -1,20 +1,10 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth 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.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -22,55 +12,62 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect 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.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.HandlePermissionRequest import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import org.koin.compose.koinInject import org.koin.compose.koinInject
@Composable @Composable
fun BluetoothRequirementWrapper( fun BluetoothRequirementWrapper(
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val bluetoothManager: BluetoothManager = koinInject() val bluetoothManager: BluetoothManager = koinInject()
val permissionHandler: PermissionHandler = koinInject()
val isEnabled by bluetoothManager.isBluetoothEnabled.collectAsState() val isEnabled by bluetoothManager.isBluetoothEnabled.collectAsState()
val hasPermission by bluetoothManager.hasBluetoothPermission.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) } var showPermissionRequest by remember { mutableStateOf(false) }
if (showPermissionRequest) { if (showPermissionRequest) {
HandlePermissionRequest { _ -> permissionHandler.HandlePermissionRequest(
{ allGranted, denied ->
showPermissionRequest = false showPermissionRequest = false
bluetoothManager.checkBluetoothPermission() bluetoothManager.checkBluetoothPermission()
hasPermanentlyDenied = denied
}
) {
requestPermissions ->
LaunchedEffect(Unit) {
requestPermissions()
}
} }
} }
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
bluetoothManager.checkBluetoothState() bluetoothManager.checkBluetoothState()
bluetoothManager.checkBluetoothPermission() bluetoothManager.checkBluetoothPermission()
permissionHandler.checkLocationState()
onPauseOrDispose { } onPauseOrDispose { }
} }
LaunchedEffect(isEnabled, hasPermission) { /* MainScreen */
if (!isEnabled && hasPermission) {
bluetoothManager.enableBluetooth()
}
}
if (isEnabled && hasPermission) {
content() content()
} else { /* Overlay content */
if (!isEnabled || !hasPermission || !isLocationEnabled) {
BluetoothRequestScreen( BluetoothRequestScreen(
isEnabled = isEnabled, isEnabled = isEnabled,
hasPermission = hasPermission, hasPermission = hasPermission,
hasPermanentlyDenied = hasPermanentlyDenied,
isLocationEnabled = isLocationEnabled,
onEnableBluetooth = { bluetoothManager.enableBluetooth() }, onEnableBluetooth = { bluetoothManager.enableBluetooth() },
onRequestPermission = { onEnableLocation = { permissionHandler.enableLocation() },
bluetoothManager.enableBluetooth() onRequestPermission = { showPermissionRequest = true },
showPermissionRequest = true
},
onOpenSettings = { bluetoothManager.openSettings() } onOpenSettings = { bluetoothManager.openSettings() }
) )
} }
@@ -80,46 +77,28 @@ fun BluetoothRequirementWrapper(
fun BluetoothRequestScreen( fun BluetoothRequestScreen(
isEnabled: Boolean, isEnabled: Boolean,
hasPermission: Boolean, hasPermission: Boolean,
hasPermanentlyDenied: Boolean,
isLocationEnabled: Boolean,
onEnableBluetooth: () -> Unit, onEnableBluetooth: () -> Unit,
onEnableLocation: () -> Unit,
onRequestPermission: () -> Unit, onRequestPermission: () -> Unit,
onOpenSettings: () -> Unit onOpenSettings: () -> Unit
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier
verticalArrangement = Arrangement.Center, .fillMaxSize()
horizontalAlignment = Alignment.CenterHorizontally .background(MaterialTheme.colorScheme.background)
.padding(12.dp)
){ ){
Icon( when {
imageVector = if (!hasPermission) Icons.Default.Security else Icons.Default.BluetoothDisabled, !isEnabled -> BluetoothDisabledView(onEnable = onEnableBluetooth)
contentDescription = null, !hasPermission -> LocationPermissionRequiredView(
modifier = Modifier.size(80.dp), hasPermission = hasPermission,
tint = MaterialTheme.colorScheme.primary hasPermanentlyDenied = hasPermanentlyDenied,
onRequestPermission = onRequestPermission,
onOpenSetting = onOpenSettings
) )
Spacer(modifier = Modifier.height(24.dp)) !isLocationEnabled -> LocationDisabledView(onEnable = onEnableLocation)
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")
}
} }
} }
} }
@@ -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 package com.digitoolsolutions.app.torquevaultkmp.utils
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.StateFlow
interface PermissionHandler {
@Composable @Composable
expect fun HandlePermissionRequest( fun HandlePermissionRequest(
onPermissionResult: (Boolean) -> Unit onPermissionResult: (Boolean, Boolean) -> Unit,
content: @Composable (requestPermissions: () -> Unit) -> Unit
) )
val isLocationEnabled: StateFlow<Boolean>
fun enableLocation()
fun checkLocationState()
}
@@ -7,11 +7,14 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.IosBluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.IosBluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModule = module { actual val platformModule = module {
single<AppStorage> { AppStoragePlatform() } single<AppStorage> { AppStoragePlatform() }
single<BluetoothManager> { IosBluetoothManager() } single<BluetoothManager> { IosBluetoothManager() }
single<PermissionHandler> { PermissionHandlerPlatform() }
single<SqlDriver> { single<SqlDriver> {
NativeSqliteDriver( NativeSqliteDriver(
schema = AppDatabase.Schema, schema = AppDatabase.Schema,
@@ -65,19 +65,8 @@ class IosBluetoothManager : BluetoothManager {
val state = centralManager.state val state = centralManager.state
if (state == CBCentralManagerStatePoweredOff) { if (state == CBCentralManagerStatePoweredOff) {
// Attempt to open Bluetooth settings directly via App-Prefs (best effort) // Bluetooth is off
val bluetoothUrl = NSURL.URLWithString("App-Prefs:root=Bluetooth") showBluetoothOffAlert()
dispatch_async(dispatch_get_main_queue()) {
val app = UIApplication.sharedApplication
if (bluetoothUrl != null && app.canOpenURL(bluetoothUrl)) {
app.openURL(bluetoothUrl, mapOf<Any?, Any?>(), null)
} else {
// Fallback: Trigger the system's "Bluetooth is Off" power alert
// This alert has a "Settings" button that goes to the right place.
centralManager.scanForPeripheralsWithServices(null, null)
centralManager.stopScan()
}
}
} else if (auth == CBManagerAuthorizationNotDetermined) { } else if (auth == CBManagerAuthorizationNotDetermined) {
// Accessing the state or scanning will trigger the system prompt // Accessing the state or scanning will trigger the system prompt
centralManager.scanForPeripheralsWithServices(null, null) centralManager.scanForPeripheralsWithServices(null, null)
@@ -85,6 +74,17 @@ class IosBluetoothManager : BluetoothManager {
checkBluetoothState() checkBluetoothState()
} }
} }
fun showBluetoothOffAlert() {
val alert = UIAlertController.alertControllerWithTitle(
"Bluetooth is Off",
"Please enable Bluetooth in Settings > Bluetooth to use this feature.",
UIAlertControllerStyleAlert
)
alert.addAction(UIAlertAction.actionWithTitle("Close", UIAlertActionStyleDefault, null))
UIApplication.sharedApplication.keyWindow?.rootViewController?.presentViewController(alert, true, null)
}
} }
actual fun createBluetoothManager(): BluetoothManager = IosBluetoothManager() actual fun createBluetoothManager(): BluetoothManager = IosBluetoothManager()
@@ -2,11 +2,16 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.compose.koinInject import org.koin.compose.koinInject
class PermissionHandlerPlatform: PermissionHandler {
@Composable @Composable
actual fun HandlePermissionRequest( override fun HandlePermissionRequest(
onPermissionResult: (Boolean) -> Unit onPermissionResult: (Boolean, Boolean) -> Unit,
content: @Composable (requestPermissions: () -> Unit) -> Unit
) { ) {
val bluetoothManager: BluetoothManager = koinInject() val bluetoothManager: BluetoothManager = koinInject()
@@ -17,6 +22,14 @@ actual fun HandlePermissionRequest(
// We call the result immediately, but the StateFlows in BluetoothManager // We call the result immediately, but the StateFlows in BluetoothManager
// will update asynchronously via the CBCentralManagerDelegate // will update asynchronously via the CBCentralManagerDelegate
onPermissionResult(bluetoothManager.hasBluetoothPermission.value)
val granted = bluetoothManager.hasBluetoothPermission.value
val denied = !granted
onPermissionResult(granted, denied)
} }
} }
private val _isLocationEnabled = MutableStateFlow(true)
override val isLocationEnabled: StateFlow<Boolean> = _isLocationEnabled.asStateFlow()
override fun enableLocation() { /* no-op */ }
override fun checkLocationState() { /* no-op */ }
}