diff --git a/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt b/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt index 6de75e910..c45eb3e63 100644 --- a/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt +++ b/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt @@ -6,6 +6,7 @@ enum class Profile { CHANNEL_SOUNDING, CSC, DFS, + DFU, GLS, HRS, HTS, @@ -32,6 +33,7 @@ enum class Profile { BATTERY -> "Battery Service" THROUGHPUT -> "Throughput Service" UART -> "UART Service" + DFU -> "Device Firmware Update" } } \ No newline at end of file diff --git a/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/spec/Spec.kt b/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/spec/Spec.kt index 704b6de4f..4fea22ea1 100644 --- a/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/spec/Spec.kt +++ b/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/spec/Spec.kt @@ -16,3 +16,8 @@ val BATTERY_SERVICE_UUID: UUID = UUID.fromString("0000180F-0000-1000-8000-00805f val THROUGHPUT_SERVICE_UUID: UUID = UUID.fromString("0483DADD-6C9D-6CA9-5D41-03AD4FFF4ABB") val CHANNEL_SOUND_SERVICE_UUID: UUID = UUID.fromString("0000185B-0000-1000-8000-00805F9B34FB") val LBS_SERVICE_UUID: UUID = UUID.fromString("00001523-1212-EFDE-1523-785FEABCD123") +val DFU_SERVICE_UUID: UUID = UUID.fromString("0000FE59-0000-1000-8000-00805F9B34FB") +val LEGACY_DFU_SERVICE_UUID: UUID = UUID.fromString("00001530-1212-EFDE-1523-785FEABCD123") +val EXPERIMENTAL_BUTTONLESS_DFU_SERVICE_UUID: UUID = UUID.fromString("E2A00001-EC31-4EC3-A97A-1C34D87E9878") +val SMP_SERVICE_UUID: UUID = UUID.fromString("8D53DC1D-1DB7-4CD3-868B-8A527460AA84") +val MDS_SERVICE_UUID: UUID = UUID.fromString("54220000-F6A5-4007-A371-722F4EBD8436") diff --git a/lib_utils/src/main/res/drawable/ic_device_manager.xml b/lib_utils/src/main/res/drawable/ic_device_manager.xml new file mode 100644 index 000000000..854e204e0 --- /dev/null +++ b/lib_utils/src/main/res/drawable/ic_device_manager.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/lib_ui/src/main/res/drawable/ic_dfu.xml b/lib_utils/src/main/res/drawable/ic_dfu.xml similarity index 100% rename from lib_ui/src/main/res/drawable/ic_dfu.xml rename to lib_utils/src/main/res/drawable/ic_dfu.xml diff --git a/lib_utils/src/main/res/values/string.xml b/lib_utils/src/main/res/values/string.xml new file mode 100644 index 000000000..508ae8e69 --- /dev/null +++ b/lib_utils/src/main/res/values/string.xml @@ -0,0 +1,13 @@ + + + Device Firmware Update + DFU + nRF Connect Device Manager + SMP + nRF Connect Device Manager + MDS + Legacy DFU + Legacy DFU + Buttonless DFU + Buttonless DFU + \ No newline at end of file diff --git a/permissions-ranging/build.gradle.kts b/permissions-ranging/build.gradle.kts deleted file mode 100644 index 8d251185d..000000000 --- a/permissions-ranging/build.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -plugins { - alias(libs.plugins.nordic.feature) -} - -android { - namespace = "no.nordicsemi.android.permissions_ranging" -} - -dependencies { - implementation(libs.accompanist.permissions) -} \ No newline at end of file diff --git a/permissions-ranging/module-rules.pro b/permissions-ranging/module-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/permissions-ranging/module-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/permissions-ranging/src/main/AndroidManifest.xml b/permissions-ranging/src/main/AndroidManifest.xml deleted file mode 100644 index b406fd5be..000000000 --- a/permissions-ranging/src/main/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/RequestRangingPermission.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/RequestRangingPermission.kt deleted file mode 100644 index 3b0350dcf..000000000 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/RequestRangingPermission.kt +++ /dev/null @@ -1,42 +0,0 @@ -package no.nordicsemi.android.permissions_ranging - -import android.app.Activity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import no.nordicsemi.android.permissions_ranging.utils.RangingNotAvailableReason -import no.nordicsemi.android.permissions_ranging.utils.RangingPermissionState -import no.nordicsemi.android.permissions_ranging.view.RangingPermissionRequestView -import no.nordicsemi.android.permissions_ranging.viewmodel.RangingPermissionViewModel - -@Composable -fun RequestRangingPermission( - onChanged: (Boolean) -> Unit = {}, - content: @Composable (Boolean) -> Unit, -) { - val permissionViewModel = hiltViewModel() - val context = LocalContext.current - val activity = context as? Activity - - val state by activity?.let { permissionViewModel.requestRangingPermission(it) }!! - .collectAsStateWithLifecycle() - - - LaunchedEffect(state) { - onChanged(state is RangingPermissionState.Available) - } - - when (val s = state) { - is RangingPermissionState.Available -> content(true) - is RangingPermissionState.NotAvailable -> { - when (s.reason) { - RangingNotAvailableReason.NOT_AVAILABLE -> RangingPermissionRequestView(content) - RangingNotAvailableReason.PERMISSION_DENIED -> content(false) - } - } - } - -} \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/repository/RangingStateManager.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/repository/RangingStateManager.kt deleted file mode 100644 index 8380a2ee5..000000000 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/repository/RangingStateManager.kt +++ /dev/null @@ -1,89 +0,0 @@ -package no.nordicsemi.android.permissions_ranging.repository - -import android.app.Activity -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import no.nordicsemi.android.permissions_ranging.utils.LocalDataProvider -import no.nordicsemi.android.permissions_ranging.utils.RangingNotAvailableReason -import no.nordicsemi.android.permissions_ranging.utils.RangingPermissionState -import no.nordicsemi.android.permissions_ranging.utils.RangingPermissionUtils -import javax.inject.Inject -import javax.inject.Singleton - -private const val REFRESH_PERMISSIONS = - "no.nordicsemi.android.permissions_ranging.repository.REFRESH_RANGING_PERMISSIONS" -private const val RANGING_PERMISSION_REQUEST_CODE = 1001 - -@Singleton -internal class RangingStateManager @Inject constructor( - @param:ApplicationContext private val context: Context, -) { - private val dataProvider = LocalDataProvider(context) - private val utils = RangingPermissionUtils(context, dataProvider) - - fun rangingPermissionState(activity: Activity) = callbackFlow { - trySend(getRangingPermissionState()) - - val rangingStateChangeHandler = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - trySend(getRangingPermissionState()) - } - } - ContextCompat.registerReceiver( - context, - rangingStateChangeHandler, - IntentFilter(), - ContextCompat.RECEIVER_EXPORTED - ) - - ActivityCompat.requestPermissions( - activity, - arrayOf("android.permission.RANGING"), - RANGING_PERMISSION_REQUEST_CODE - ) - - awaitClose { - - context.unregisterReceiver(rangingStateChangeHandler) - } - - } - - fun refreshRangingPermissionState() { - val intent = Intent(REFRESH_PERMISSIONS) - context.sendBroadcast(intent) - } - - fun markRangingPermissionAsRequested() { - dataProvider.isRangingPermissionRequested = true - } - - fun isRangingPermissionDenied(): Boolean { - return try { - utils.isRangingPermissionDenied() - } catch (_: Exception) { - false - } - } - - private fun getRangingPermissionState(): RangingPermissionState { - return when { - !utils.isRangingPermissionAvailable -> RangingPermissionState.NotAvailable( - RangingNotAvailableReason.NOT_AVAILABLE - ) - - utils.isRangingPermissionAvailable && !utils.isRangingPermissionGranted -> RangingPermissionState.NotAvailable( - RangingNotAvailableReason.PERMISSION_DENIED - ) - - else -> RangingPermissionState.Available - } - } -} \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/LocalDataProvider.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/LocalDataProvider.kt deleted file mode 100644 index 2420d8253..000000000 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/LocalDataProvider.kt +++ /dev/null @@ -1,35 +0,0 @@ -package no.nordicsemi.android.permissions_ranging.utils - -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import androidx.annotation.ChecksSdkIntAtLeast -import androidx.core.app.ActivityCompat -import androidx.core.content.edit -import javax.inject.Inject -import javax.inject.Singleton - -private const val SHARED_PREFS_NAME = "SHARED_PREFS_RANGING" -private const val PREFS_PERMISSION_REQUESTED = "ranging_permission_requested" - -@Singleton -internal class LocalDataProvider @Inject constructor( - private val context: Context, -) { - private val sharedPrefs: SharedPreferences - get() = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE) - - val isBaklavaOrAbove: Boolean - @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA - - /** - * The first time an app requests a permission there is no 'Don't Allow' checkbox and - * [ActivityCompat.shouldShowRequestPermissionRationale] returns false. - */ - var isRangingPermissionRequested: Boolean - get() = sharedPrefs.getBoolean(PREFS_PERMISSION_REQUESTED, false) - set(value) { - sharedPrefs.edit { putBoolean(PREFS_PERMISSION_REQUESTED, value) } - } -} \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionState.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionState.kt deleted file mode 100644 index f6521b95b..000000000 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionState.kt +++ /dev/null @@ -1,23 +0,0 @@ -package no.nordicsemi.android.permissions_ranging.utils - -internal sealed class RangingPermissionState { - /** - * Ranging is available and the app has the required permissions. - */ - data object Available : RangingPermissionState() - - /** - * Ranging is not available. - */ - data class NotAvailable( - val reason: RangingNotAvailableReason, - ) : RangingPermissionState() -} - -internal enum class RangingNotAvailableReason { - /** Ranging is not available on this device. */ - NOT_AVAILABLE, - - /** The app does not have the required permissions. */ - PERMISSION_DENIED, -} \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionUtils.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionUtils.kt deleted file mode 100644 index 231e73513..000000000 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionUtils.kt +++ /dev/null @@ -1,57 +0,0 @@ -package no.nordicsemi.android.permissions_ranging.utils - -import android.Manifest -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.content.pm.PackageManager -import android.os.Build -import androidx.annotation.ChecksSdkIntAtLeast -import androidx.core.content.ContextCompat - -internal class RangingPermissionUtils( - private val context: Context, - private val dataProvider: LocalDataProvider, -) { - val isRangingPermissionAvailable: Boolean - @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) - get() = Build.VERSION.SDK_INT >= 36 - - - val isRangingPermissionGranted: Boolean - get() = isRangingPermissionAvailable && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.RANGING - ) == PackageManager.PERMISSION_GRANTED - - fun isRangingPermissionDenied(): Boolean { - return dataProvider.isBaklavaOrAbove && - dataProvider.isRangingPermissionRequested && // Ranging permission was requested. - !isRangingPermissionGranted // Ranging permission is not granted - && !context.findActivity() - ?.shouldShowRequestPermissionRationale(Manifest.permission.RANGING)!! - - } - - /** - * Finds the activity from the given context. - * - * https://github.com/google/accompanist/blob/6611ebda55eb2948eca9e1c89c2519e80300855a/permissions/src/main/java/com/google/accompanist/permissions/PermissionsUtil.kt#L99 - * - * @throws IllegalStateException if no activity was found. - * @return the activity. - */ - private fun Context.findActivity(): Activity? { - return try { - var context = this - while (context is ContextWrapper) { - if (context is Activity) return context - context = context.baseContext - } - null // no activity found - } catch (e: Exception) { - null - } - } -} \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/view/RangingPermissionRequestView.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/view/RangingPermissionRequestView.kt deleted file mode 100644 index 72a95df87..000000000 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/view/RangingPermissionRequestView.kt +++ /dev/null @@ -1,49 +0,0 @@ -package no.nordicsemi.android.permissions_ranging.view - -import android.Manifest -import android.os.Build -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.hilt.navigation.compose.hiltViewModel -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import no.nordicsemi.android.permissions_ranging.viewmodel.RangingPermissionViewModel - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -internal fun RangingPermissionRequestView( - content: @Composable (Boolean) -> Unit, -) { - val rangingPermissionViewModel = hiltViewModel() - - val permission = if (Build.VERSION.SDK_INT >= 36) - Manifest.permission.RANGING else null - - val rangingPermission = permission?.let { - rememberPermissionState(it) - } - - if (rangingPermission != null) { - when (rangingPermission.status) { - is PermissionStatus.Denied -> { - LaunchedEffect(!rangingPermission.status.isGranted) { - rangingPermissionViewModel.markRangingPermissionRequested() - rangingPermission.launchPermissionRequest() - if (!rangingPermission.status.isGranted) { - rangingPermissionViewModel.markRangingPermissionDenied() - } - rangingPermissionViewModel.refreshRangingPermissionState() - } - content(rangingPermission.status.isGranted) - } - - PermissionStatus.Granted -> content(true) - } - } else { - rangingPermissionViewModel.refreshRangingPermissionState() - content(true) - } - -} \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/viewmodel/RangingPermissionViewModel.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/viewmodel/RangingPermissionViewModel.kt deleted file mode 100644 index 87a3103dd..000000000 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/viewmodel/RangingPermissionViewModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package no.nordicsemi.android.permissions_ranging.viewmodel - -import android.app.Activity -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import no.nordicsemi.android.permissions_ranging.repository.RangingStateManager -import no.nordicsemi.android.permissions_ranging.utils.RangingNotAvailableReason -import no.nordicsemi.android.permissions_ranging.utils.RangingPermissionState -import javax.inject.Inject - -@HiltViewModel -internal class RangingPermissionViewModel @Inject constructor( - private val rangingStateManager: RangingStateManager, -) : ViewModel() { - fun requestRangingPermission(activity: Activity) = - rangingStateManager.rangingPermissionState(activity) - .stateIn( - viewModelScope, - SharingStarted.Lazily, - RangingPermissionState.NotAvailable(RangingNotAvailableReason.NOT_AVAILABLE), - ) - - fun refreshRangingPermissionState() { - rangingStateManager.refreshRangingPermissionState() - } - - fun markRangingPermissionRequested() { - rangingStateManager.markRangingPermissionAsRequested() - } - - fun markRangingPermissionDenied() { - rangingStateManager.isRangingPermissionDenied() - } -} \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt index b4b685688..315498a95 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt @@ -29,6 +29,7 @@ import no.nordicsemi.android.common.permissions.ble.RequireLocation import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission import no.nordicsemi.android.toolbox.lib.utils.Profile import no.nordicsemi.android.toolbox.profile.data.displayMessage +import no.nordicsemi.android.toolbox.profile.view.dfu.DFUScreen import no.nordicsemi.android.toolbox.profile.view.battery.BatteryScreen import no.nordicsemi.android.toolbox.profile.view.bps.BPSScreen import no.nordicsemi.android.toolbox.profile.view.cgms.CGMScreen @@ -192,7 +193,10 @@ internal fun DeviceConnectedView( // Display the appropriate screen for each profile. when (serviceManager.profile) { Profile.HTS -> HTSScreen() - Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen(isNotificationPermissionGranted) + Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen( + isNotificationPermissionGranted + ) + Profile.BPS -> BPSScreen() Profile.CSC -> CSCScreen() Profile.CGM -> CGMScreen() @@ -204,9 +208,10 @@ internal fun DeviceConnectedView( Profile.BATTERY -> BatteryScreen() Profile.THROUGHPUT -> ThroughputScreen(state.maxValueLength) Profile.UART -> UARTScreen(state.maxValueLength) + Profile.DFU -> DFUScreen() } } } } } -} \ No newline at end of file +} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/dfu/DFUScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/dfu/DFUScreen.kt new file mode 100644 index 000000000..c66c3a507 --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/dfu/DFUScreen.kt @@ -0,0 +1,113 @@ +package no.nordicsemi.android.toolbox.profile.view.dfu + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.viewmodel.DFUViewModel + +@Composable +internal fun DFUScreen() { + val dfuViewModel = hiltViewModel() + val dfuServiceState by dfuViewModel.dfuServiceState.collectAsStateWithLifecycle() + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + dfuServiceState.dfuAppName?.let { dfuApp -> + val intent = context.packageManager.getLaunchIntentForPackage(dfuApp.packageName) + val description = + intent?.let { + stringResource( + R.string.dfu_description_open, + stringResource(dfuApp.appName) + ) + } + ?: stringResource(R.string.dfu_description_download) + + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedCard { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(dfuApp.appIcon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp) + ) + + Text( + text = stringResource( + R.string.dfu_not_supported_title, + stringResource(dfuApp.appShortName) + ), + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = stringResource( + R.string.dfu_not_supported_text, + stringResource(dfuApp.appShortName), + stringResource(dfuApp.appName) + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Button( + onClick = { + intent?.let { context.startActivity(it) } + ?: uriHandler.openUri(dfuApp.appLink) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val icon = intent?.let { dfuApp.appIcon } ?: R.drawable.google_play_2022_icon + + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .padding(end = 8.dp), + tint = if (intent == null) Color.Unspecified else MaterialTheme.colorScheme.onPrimary + ) + + Text(text = description) + } + } + } + } +} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DFUViewModel.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DFUViewModel.kt new file mode 100644 index 000000000..dca5a35cd --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DFUViewModel.kt @@ -0,0 +1,66 @@ +package no.nordicsemi.android.toolbox.profile.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import no.nordicsemi.android.common.navigation.Navigator +import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel +import no.nordicsemi.android.toolbox.lib.utils.Profile +import no.nordicsemi.android.toolbox.profile.ProfileDestinationId +import no.nordicsemi.android.toolbox.profile.data.DFUServiceData +import no.nordicsemi.android.toolbox.profile.manager.repository.DFURepository +import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository +import javax.inject.Inject + +@HiltViewModel +internal class DFUViewModel @Inject constructor( + private val deviceRepository: DeviceRepository, + navigator: Navigator, + savedStateHandle: SavedStateHandle, +) : SimpleNavigationViewModel(navigator, savedStateHandle) { + val address = parameterOf(ProfileDestinationId) + + // StateFlow to hold the selected temperature unit + private val _dfuServiceState = MutableStateFlow(DFUServiceData()) + val dfuServiceState = _dfuServiceState.asStateFlow() + + init { + observeDFUProfile() + } + + /** + * Observes the [DeviceRepository.profileHandlerFlow] from the [deviceRepository] that contains [Profile.DFU]. + */ + private fun observeDFUProfile() = viewModelScope.launch { + deviceRepository.profileHandlerFlow + .onEach { mapOfPeripheralProfiles -> + mapOfPeripheralProfiles.forEach { (peripheral, profiles) -> + if (peripheral.address == address) { + profiles.filter { it.profile == Profile.DFU } + .forEach { _ -> + startDFUService(peripheral.address) + } + } + } + }.launchIn(this) + } + + /** + * Starts the DFU Service and observes data changes. + * + * @param address The address of the peripheral device. + */ + private fun startDFUService(address: String) = DFURepository.getData(address) + .onEach { dFUServiceData -> + _dfuServiceState.value = _dfuServiceState.value.copy( + profile = dFUServiceData.profile, + dfuAppName = dFUServiceData.dfuAppName + ) + }.launchIn(viewModelScope) + +} \ No newline at end of file diff --git a/profile/src/main/res/drawable/google_play_2022_icon.xml b/profile/src/main/res/drawable/google_play_2022_icon.xml new file mode 100644 index 000000000..75073ebb4 --- /dev/null +++ b/profile/src/main/res/drawable/google_play_2022_icon.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/profile/src/main/res/values/dfuStrings.xml b/profile/src/main/res/values/dfuStrings.xml new file mode 100644 index 000000000..ae269ba4a --- /dev/null +++ b/profile/src/main/res/values/dfuStrings.xml @@ -0,0 +1,8 @@ + + + Open %s + Download from Play Store + %s is not supported + %1s service is not available in the current version of the app. Please use the %2s app from Nordic Semiconductor to update your device’s firmware. + + \ No newline at end of file diff --git a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/DFUServiceData.kt b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/DFUServiceData.kt new file mode 100644 index 000000000..ade594efb --- /dev/null +++ b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/DFUServiceData.kt @@ -0,0 +1,63 @@ +package no.nordicsemi.android.toolbox.profile.data + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import no.nordicsemi.android.toolbox.lib.utils.Profile +import no.nordicsemi.android.toolbox.lib.utils.R + +internal const val DFU_PACKAGE_NAME = "no.nordicsemi.android.dfu" +internal const val DFU_APP_LINK = + "https://play.google.com/store/apps/details?id=no.nordicsemi.android.dfu" + +internal const val SMP_PACKAGE_NAME = "no.nordicsemi.android.nrfconnectdevicemanager" +internal const val SMP_APP_LINK = + "https://play.google.com/store/apps/details?id=no.nordicsemi.android.nrfconnectdevicemanager" + +data class DFUServiceData( + override val profile: Profile = Profile.DFU, + val dfuAppName: DFUsAvailable? = null, +) : ProfileServiceData() + +enum class DFUsAvailable( + val packageName: String, + val appLink: String, + @param:StringRes val appName: Int, + @param:DrawableRes val appIcon: Int, + @param:StringRes val appShortName: Int, +) { + DFU_SERVICE( + packageName = DFU_PACKAGE_NAME, + appLink = DFU_APP_LINK, + appName = R.string.dfu_app_name, + appIcon = R.drawable.ic_dfu, + appShortName = R.string.dfu_short_name, + ), + SMP_SERVICE( + packageName = SMP_PACKAGE_NAME, + appLink = SMP_APP_LINK, + appName = R.string.smp_app_name, + appIcon = R.drawable.ic_device_manager, + appShortName = R.string.smp_short_name, + ), + MDS_SERVICE( + packageName = SMP_PACKAGE_NAME, + appLink = SMP_APP_LINK, + appName = R.string.mds_app_name, + appIcon = R.drawable.ic_device_manager, + appShortName = R.string.mds_app_name, + ), + LEGACY_DFU_SERVICE( + packageName = DFU_PACKAGE_NAME, + appLink = DFU_APP_LINK, + appName = R.string.legacy_dfu_app_name, + appIcon = R.drawable.ic_dfu, + appShortName = R.string.legacy_dfu_short_name, + ), + EXPERIMENTAL_BUTTONLESS_DFU_SERVICE( + packageName = DFU_PACKAGE_NAME, + appLink = DFU_APP_LINK, + appName = R.string.buttonless_dfu_app_name, + appIcon = R.drawable.ic_dfu, + appShortName = R.string.buttonless_dfu_short_name, + ) +} \ No newline at end of file diff --git a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/DFUManager.kt b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/DFUManager.kt new file mode 100644 index 000000000..767d93593 --- /dev/null +++ b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/DFUManager.kt @@ -0,0 +1,60 @@ +package no.nordicsemi.android.toolbox.profile.manager + +import kotlinx.coroutines.CoroutineScope +import no.nordicsemi.android.toolbox.lib.utils.Profile +import no.nordicsemi.android.toolbox.lib.utils.spec.DFU_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.EXPERIMENTAL_BUTTONLESS_DFU_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.LEGACY_DFU_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.MDS_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.SMP_SERVICE_UUID +import no.nordicsemi.android.toolbox.profile.data.DFUsAvailable +import no.nordicsemi.android.toolbox.profile.manager.repository.DFURepository +import no.nordicsemi.kotlin.ble.client.RemoteService +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.toKotlinUuid + +internal class DFUManager : ServiceManager { + override val profile: Profile + get() = Profile.DFU + + @OptIn(ExperimentalUuidApi::class) + override suspend fun observeServiceInteractions( + deviceId: String, + remoteService: RemoteService, + scope: CoroutineScope + ) { + try { + when (remoteService.uuid) { + DFU_SERVICE_UUID.toKotlinUuid() -> DFURepository.updateAppName( + deviceId, + DFUsAvailable.DFU_SERVICE + ) + + SMP_SERVICE_UUID.toKotlinUuid() -> DFURepository.updateAppName( + deviceId, + DFUsAvailable.SMP_SERVICE + ) + + LEGACY_DFU_SERVICE_UUID.toKotlinUuid() -> DFURepository.updateAppName( + deviceId, + DFUsAvailable.LEGACY_DFU_SERVICE + ) + + EXPERIMENTAL_BUTTONLESS_DFU_SERVICE_UUID.toKotlinUuid() -> DFURepository.updateAppName( + deviceId, + DFUsAvailable.EXPERIMENTAL_BUTTONLESS_DFU_SERVICE + ) + + MDS_SERVICE_UUID.toKotlinUuid() -> DFURepository.updateAppName( + deviceId, + DFUsAvailable.MDS_SERVICE + ) + + else -> null + } + } catch (_: Exception) { + DFURepository.clear(deviceId) + } + } + +} \ No newline at end of file diff --git a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt index 77688cb0d..975b3a367 100644 --- a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt +++ b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt @@ -5,12 +5,17 @@ import no.nordicsemi.android.toolbox.lib.utils.spec.BPS_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.CGMS_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.CHANNEL_SOUND_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.CSC_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.DFU_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.DF_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.EXPERIMENTAL_BUTTONLESS_DFU_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.GLS_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.HRS_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.HTS_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.LBS_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.LEGACY_DFU_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.MDS_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.RSCS_SERVICE_UUID +import no.nordicsemi.android.toolbox.lib.utils.spec.SMP_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.THROUGHPUT_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.UART_SERVICE_UUID import kotlin.uuid.ExperimentalUuidApi @@ -34,6 +39,12 @@ object ServiceManagerFactory { UART_SERVICE_UUID to ::UARTManager, CHANNEL_SOUND_SERVICE_UUID to ::ChannelSoundingManager, LBS_SERVICE_UUID to ::LBSManager, + DFU_SERVICE_UUID to ::DFUManager, + SMP_SERVICE_UUID to ::DFUManager, + MDS_SERVICE_UUID to ::DFUManager, + LEGACY_DFU_SERVICE_UUID to ::DFUManager, + EXPERIMENTAL_BUTTONLESS_DFU_SERVICE_UUID to ::DFUManager, + // Add more service UUIDs to handler mappings as needed ).mapKeys { it.key.toKotlinUuid() } diff --git a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/repository/DFURepository.kt b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/repository/DFURepository.kt new file mode 100644 index 000000000..7fbd68640 --- /dev/null +++ b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/repository/DFURepository.kt @@ -0,0 +1,30 @@ +package no.nordicsemi.android.toolbox.profile.manager.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import no.nordicsemi.android.toolbox.profile.data.DFUServiceData +import no.nordicsemi.android.toolbox.profile.data.DFUsAvailable + +object DFURepository { + private val _dataMap = mutableMapOf>() + + fun getData(deviceId: String): Flow = + _dataMap.getOrPut(deviceId) { MutableStateFlow(DFUServiceData()) } + + + fun updateAppName(deviceId: String, appName: DFUsAvailable) { + _dataMap[deviceId]?.let { + it.update { dFUServiceData -> + dFUServiceData.copy(dfuAppName = appName) + } + } ?: run { + _dataMap[deviceId] = MutableStateFlow(DFUServiceData(dfuAppName = appName)) + } + } + + fun clear(deviceId: String) { + _dataMap.remove(deviceId) + } + +} \ No newline at end of file