From 31ea6be8cd49618e1f285f3dc98c3f609ade7e7a Mon Sep 17 00:00:00 2001 From: lamtruong28 Date: Mon, 25 May 2026 10:18:56 +0700 Subject: [PATCH] Handle request bluetooth permission --- .../src/androidMain/AndroidManifest.xml | 6 + .../app/torquevaultkmp/MainActivity.kt | 2 - .../app/torquevaultkmp/di/PlatformModules.kt | 3 + .../utils/BluetoothManager.android.kt | 93 +++++++++++++ .../utils/PermissionHandler.android.kt | 36 +++++ .../app/torquevaultkmp/App.kt | 13 +- .../app/torquevaultkmp/screens/MainScreen.kt | 13 +- .../bluetooth/BluetoothRequirementWrapper.kt | 125 ++++++++++++++++++ .../torquevaultkmp/utils/BluetoothManager.kt | 15 +++ .../torquevaultkmp/utils/PermissionHandler.kt | 8 ++ .../app/torquevaultkmp/di/PlatformModules.kt | 3 + .../utils/BluetoothManager.ios.kt | 90 +++++++++++++ .../utils/PermissionHandler.ios.kt | 22 +++ iosApp/iosApp/Info.plist | 6 + 14 files changed, 426 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt create mode 100644 composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index cccba71..3eeee81 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,12 @@ + + + + + + { AppStoragePlatform(androidContext()) } + single { AndroidBluetoothManager(androidContext()) } single { AndroidSqliteDriver( schema = AppDatabase.Schema, diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.android.kt new file mode 100644 index 0000000..ed80090 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.android.kt @@ -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 = _isBluetoothEnabled.asStateFlow() + + private val _hasBluetoothPermission = MutableStateFlow(false) + override val hasBluetoothPermission: StateFlow = _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") +} diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt new file mode 100644 index 0000000..9f1fd7b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt @@ -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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt index f951ca6..bed8cc2 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable 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 org.koin.compose.viewmodel.koinViewModel @@ -16,10 +17,12 @@ fun App() { val isLoggedIn = viewModel.isLoggedIn() TorqueVaultTheme(darkTheme = isDarkTheme) { - MainScreen( - isDarkTheme = isDarkTheme, - onThemeToggle = { viewModel.toggleTheme(it) }, - isLoggedIn = isLoggedIn - ) + BluetoothRequirementWrapper { + MainScreen( + isDarkTheme = isDarkTheme, + onThemeToggle = { viewModel.toggleTheme(it) }, + isLoggedIn = isLoggedIn + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt index f523bcb..2349af7 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt @@ -19,6 +19,7 @@ import androidx.navigation.compose.rememberNavController import com.digitoolsolutions.app.torquevaultkmp.AppViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination 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.BondedScreen import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination @@ -109,8 +110,16 @@ fun MainScreen( modifier = Modifier.padding(innerPadding).fillMaxSize() ) { composable(AuthDestination.route) { LoginScreen(navController) } - composable(HomeDestination.route) { HomeScreen(navController) } - composable(BondedDestination.route) { BondedScreen(navController) } + composable(HomeDestination.route) { + BluetoothRequirementWrapper { + HomeScreen(navController) + } + } + composable(BondedDestination.route) { + BluetoothRequirementWrapper { + BondedScreen(navController) + } + } composable(LogDestination.route) { LogScreen(navController) } composable(SettingDestination.route) { SettingScreen(navController, isDarkTheme, onThemeToggle) diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt new file mode 100644 index 0000000..654edea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt @@ -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") + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.kt new file mode 100644 index 0000000..d177489 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.kt @@ -0,0 +1,15 @@ +package com.digitoolsolutions.app.torquevaultkmp.utils + +import kotlinx.coroutines.flow.StateFlow + +interface BluetoothManager { + val isBluetoothEnabled: StateFlow + val hasBluetoothPermission: StateFlow + + fun checkBluetoothState() + fun checkBluetoothPermission() + fun openSettings() + fun enableBluetooth() +} + +expect fun createBluetoothManager(): BluetoothManager diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt new file mode 100644 index 0000000..bd90c03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt @@ -0,0 +1,8 @@ +package com.digitoolsolutions.app.torquevaultkmp.utils + +import androidx.compose.runtime.Composable + +@Composable +expect fun HandlePermissionRequest( + onPermissionResult: (Boolean) -> Unit +) diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt index 3bd600c..aa7ab7f 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -5,10 +5,13 @@ import app.cash.sqldelight.driver.native.NativeSqliteDriver import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage 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 actual val platformModule = module { single { AppStoragePlatform() } + single { IosBluetoothManager() } single { NativeSqliteDriver( schema = AppDatabase.Schema, diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt new file mode 100644 index 0000000..ff54a1b --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt @@ -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 = _isBluetoothEnabled.asStateFlow() + + private val _hasBluetoothPermission = MutableStateFlow(false) + override val hasBluetoothPermission: StateFlow = _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(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(), 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(), 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() diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt new file mode 100644 index 0000000..f29c971 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt @@ -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) + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 95a2efb..7016972 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -18,5 +18,11 @@ + + NSBluetoothAlwaysUsageDescription + The app requires Bluetooth to connect and manage Torque devices. + NSBluetoothPeripheralUsageDescription + The app requires Bluetooth to connect and manage Torque devices. +