Handle request bluetooth permission

This commit is contained in:
2026-05-25 10:18:56 +07:00
parent 66fe3be32f
commit 31ea6be8cd
14 changed files with 426 additions and 9 deletions
@@ -1,6 +1,12 @@
<?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:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<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.BLUETOOTH_CONNECT" />
<!-- Allow access http: android:usesCleartextTraffic="true" --> <!-- Allow access http: android:usesCleartextTraffic="true" -->
<application <application
android:name=".MyApplication" android:name=".MyApplication"
@@ -6,8 +6,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.digitoolsolutions.app.torquevaultkmp.di.initKoin
import org.koin.android.ext.koin.androidContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -5,11 +5,14 @@ import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage 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.BluetoothManager
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<SqlDriver> { single<SqlDriver> {
AndroidSqliteDriver( AndroidSqliteDriver(
schema = AppDatabase.Schema, schema = AppDatabase.Schema,
@@ -0,0 +1,93 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager as AndroidBluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AndroidBluetoothManager(private val context: Context) : BluetoothManager {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as AndroidBluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val _isBluetoothEnabled = MutableStateFlow(false)
override val isBluetoothEnabled: StateFlow<Boolean> = _isBluetoothEnabled.asStateFlow()
private val _hasBluetoothPermission = MutableStateFlow(false)
override val hasBluetoothPermission: StateFlow<Boolean> = _hasBluetoothPermission.asStateFlow()
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
checkBluetoothState()
}
}
}
init {
checkBluetoothState()
checkBluetoothPermission()
context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
}
override fun checkBluetoothState() {
try {
_isBluetoothEnabled.value = bluetoothAdapter?.isEnabled == true
} catch (e: SecurityException) {
_isBluetoothEnabled.value = false
}
}
override fun checkBluetoothPermission() {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.ACCESS_FINE_LOCATION
)
}
_hasBluetoothPermission.value = permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
override fun openSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
override fun enableBluetooth() {
if (bluetoothAdapter?.isEnabled == false) {
val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}
actual fun createBluetoothManager(): BluetoothManager {
// This will be handled by Koin in a real app,
// but for the sake of expect/actual fulfilling the requirement:
// We'll need to get the context from somewhere if we don't use Koin.
// However, the user has Koin, so we should use it.
throw UnsupportedOperationException("Use Koin to inject BluetoothManager")
}
@@ -0,0 +1,36 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@Composable
actual fun HandlePermissionRequest(
onPermissionResult: (Boolean) -> Unit
) {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.ACCESS_FINE_LOCATION
)
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val allGranted = result.values.all { it }
onPermissionResult(allGranted)
}
LaunchedEffect(Unit) {
launcher.launch(permissions)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview 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.theme.TorqueVaultTheme import com.digitoolsolutions.app.torquevaultkmp.theme.TorqueVaultTheme
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -16,10 +17,12 @@ fun App() {
val isLoggedIn = viewModel.isLoggedIn() val isLoggedIn = viewModel.isLoggedIn()
TorqueVaultTheme(darkTheme = isDarkTheme) { TorqueVaultTheme(darkTheme = isDarkTheme) {
BluetoothRequirementWrapper {
MainScreen( MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onThemeToggle = { viewModel.toggleTheme(it) }, onThemeToggle = { viewModel.toggleTheme(it) },
isLoggedIn = isLoggedIn isLoggedIn = isLoggedIn
) )
} }
}
} }
@@ -19,6 +19,7 @@ import androidx.navigation.compose.rememberNavController
import com.digitoolsolutions.app.torquevaultkmp.AppViewModel import com.digitoolsolutions.app.torquevaultkmp.AppViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.LoginScreen import com.digitoolsolutions.app.torquevaultkmp.screens.auth.LoginScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.BluetoothRequirementWrapper
import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedDestination import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedDestination
import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedScreen import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination
@@ -109,8 +110,16 @@ fun MainScreen(
modifier = Modifier.padding(innerPadding).fillMaxSize() modifier = Modifier.padding(innerPadding).fillMaxSize()
) { ) {
composable(AuthDestination.route) { LoginScreen(navController) } composable(AuthDestination.route) { LoginScreen(navController) }
composable(HomeDestination.route) { HomeScreen(navController) } composable(HomeDestination.route) {
composable(BondedDestination.route) { BondedScreen(navController) } BluetoothRequirementWrapper {
HomeScreen(navController)
}
}
composable(BondedDestination.route) {
BluetoothRequirementWrapper {
BondedScreen(navController)
}
}
composable(LogDestination.route) { LogScreen(navController) } composable(LogDestination.route) { LogScreen(navController) }
composable(SettingDestination.route) { composable(SettingDestination.route) {
SettingScreen(navController, isDarkTheme, onThemeToggle) SettingScreen(navController, isDarkTheme, onThemeToggle)
@@ -0,0 +1,125 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth
import androidx.compose.foundation.layout.Arrangement
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
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.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.HandlePermissionRequest
import org.koin.compose.koinInject
@Composable
fun BluetoothRequirementWrapper(
content: @Composable () -> Unit
) {
val bluetoothManager: BluetoothManager = koinInject()
val isEnabled by bluetoothManager.isBluetoothEnabled.collectAsState()
val hasPermission by bluetoothManager.hasBluetoothPermission.collectAsState()
var showPermissionRequest by remember { mutableStateOf(false) }
if (showPermissionRequest) {
HandlePermissionRequest { _ ->
showPermissionRequest = false
bluetoothManager.checkBluetoothPermission()
}
}
LifecycleResumeEffect(Unit) {
bluetoothManager.checkBluetoothState()
bluetoothManager.checkBluetoothPermission()
onPauseOrDispose { }
}
LaunchedEffect(isEnabled, hasPermission) {
if (!isEnabled && hasPermission) {
bluetoothManager.enableBluetooth()
}
}
if (isEnabled && hasPermission) {
content()
} else {
BluetoothRequestScreen(
isEnabled = isEnabled,
hasPermission = hasPermission,
onEnableBluetooth = { bluetoothManager.enableBluetooth() },
onRequestPermission = {
bluetoothManager.enableBluetooth()
showPermissionRequest = true
},
onOpenSettings = { bluetoothManager.openSettings() }
)
}
}
@Composable
fun BluetoothRequestScreen(
isEnabled: Boolean,
hasPermission: Boolean,
onEnableBluetooth: () -> 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")
}
}
}
}
@@ -0,0 +1,15 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import kotlinx.coroutines.flow.StateFlow
interface BluetoothManager {
val isBluetoothEnabled: StateFlow<Boolean>
val hasBluetoothPermission: StateFlow<Boolean>
fun checkBluetoothState()
fun checkBluetoothPermission()
fun openSettings()
fun enableBluetooth()
}
expect fun createBluetoothManager(): BluetoothManager
@@ -0,0 +1,8 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import androidx.compose.runtime.Composable
@Composable
expect fun HandlePermissionRequest(
onPermissionResult: (Boolean) -> Unit
)
@@ -5,10 +5,13 @@ import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage 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.IosBluetoothManager
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<SqlDriver> { single<SqlDriver> {
NativeSqliteDriver( NativeSqliteDriver(
schema = AppDatabase.Schema, schema = AppDatabase.Schema,
@@ -0,0 +1,90 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import platform.CoreBluetooth.*
import platform.Foundation.NSURL
import platform.UIKit.*
import platform.darwin.NSObject
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
class IosBluetoothManager : BluetoothManager {
private val _isBluetoothEnabled = MutableStateFlow(false)
override val isBluetoothEnabled: StateFlow<Boolean> = _isBluetoothEnabled.asStateFlow()
private val _hasBluetoothPermission = MutableStateFlow(false)
override val hasBluetoothPermission: StateFlow<Boolean> = _hasBluetoothPermission.asStateFlow()
// Strong reference to delegate to prevent garbage collection
private val centralManagerDelegate = object : NSObject(), CBCentralManagerDelegateProtocol {
override fun centralManagerDidUpdateState(central: CBCentralManager) {
_isBluetoothEnabled.value = central.state == CBCentralManagerStatePoweredOn
checkBluetoothPermission()
}
}
private val centralManager: CBCentralManager by lazy {
val options = mapOf<Any?, Any?>(CBCentralManagerOptionShowPowerAlertKey to true)
CBCentralManager(delegate = centralManagerDelegate, queue = null, options = options)
}
init {
// Trigger lazy initialization
checkBluetoothState()
checkBluetoothPermission()
}
override fun checkBluetoothState() {
_isBluetoothEnabled.value = centralManager.state == CBCentralManagerStatePoweredOn
}
override fun checkBluetoothPermission() {
val auth = CBManager.authorization
_hasBluetoothPermission.value = auth == CBManagerAuthorizationAllowedAlways
}
override fun openSettings() {
val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString)
if (url != null) {
dispatch_async(dispatch_get_main_queue()) {
if (UIApplication.sharedApplication.canOpenURL(url)) {
UIApplication.sharedApplication.openURL(url, mapOf<Any?, Any?>(), null)
}
}
}
}
override fun enableBluetooth() {
val auth = CBManager.authorization
if (auth == CBManagerAuthorizationDenied || auth == CBManagerAuthorizationRestricted) {
openSettings()
return
}
val state = centralManager.state
if (state == CBCentralManagerStatePoweredOff) {
// Attempt to open Bluetooth settings directly via App-Prefs (best effort)
val bluetoothUrl = NSURL.URLWithString("App-Prefs:root=Bluetooth")
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) {
// Accessing the state or scanning will trigger the system prompt
centralManager.scanForPeripheralsWithServices(null, null)
centralManager.stopScan()
checkBluetoothState()
}
}
}
actual fun createBluetoothManager(): BluetoothManager = IosBluetoothManager()
@@ -0,0 +1,22 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import org.koin.compose.koinInject
@Composable
actual fun HandlePermissionRequest(
onPermissionResult: (Boolean) -> Unit
) {
val bluetoothManager: BluetoothManager = koinInject()
LaunchedEffect(Unit) {
// Trigger enableBluetooth which on iOS will touch the CBCentralManager
// and show the permission prompt if it's currently "Not Determined"
bluetoothManager.enableBluetooth()
// We call the result immediately, but the StateFlows in BluetoothManager
// will update asynchronously via the CBCentralManagerDelegate
onPermissionResult(bluetoothManager.hasBluetoothPermission.value)
}
}
+6
View File
@@ -18,5 +18,11 @@
</dict> </dict>
</dict> </dict>
</dict> </dict>
<!-- Bluetooth permission-->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>The app requires Bluetooth to connect and manage Torque devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>The app requires Bluetooth to connect and manage Torque devices.</string>
</dict> </dict>
</plist> </plist>