feature: Add new CSC Screen

This commit is contained in:
Sylwester Zieliński
2021-09-14 11:37:40 +02:00
parent 419aaf7e5b
commit c944a446ef
72 changed files with 2949 additions and 227 deletions

123
app/.gitignore vendored
View File

@@ -1,123 +0,0 @@
# Created by https://www.toptal.com/developers/gitignore/api/androidstudio
# Edit at https://www.toptal.com/developers/gitignore?templates=androidstudio
### AndroidStudio ###
# Covers files to be ignored for android development using Android Studio.
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle
.gradle/
build/
# Signing files
.signing/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
captures/
.navigation/
*.ipr
*~
*.swp
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Android Patch
gen-external-apklibs
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# NDK
obj/
# IntelliJ IDEA
*.iml
*.iws
/out/
# User-specific configurations
.idea/
# OS-specific files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Legacy Eclipse project files
.classpath
.project
.cproject
.settings/
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.war
*.ear
# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
hs_err_pid*
## Plugin-specific files:
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Mongo Explorer plugin
.idea/mongoSettings.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### AndroidStudio Patch ###
!/gradle/wrapper/gradle-wrapper.jar
# End of https://www.toptal.com/developers/gitignore/api/androidstudio

View File

@@ -1,15 +1,17 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 31
compileSdk android_api_version
defaultConfig {
applicationId "no.nordicsemi.android.nrftoolbox"
minSdk 21
targetSdk 31
minSdk android_min_api_version
targetSdk android_api_version
versionCode 1
versionName "1.0"
@@ -38,21 +40,32 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion '1.5.21'
kotlinCompilerVersion kotlin_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
hilt {
enableExperimentalClasspathAggregation = true
}
}
dependencies {
//Hilt requires to implement every module in the main app module
//https://github.com/google/dagger/issues/2123
implementation project(":feature_csc")
implementation project(":lib_broadcast")
implementation project(":lib_events")
implementation project(":lib_theme")
implementation project(":lib_scanner")
implementation libs.nordic.ble.common
implementation libs.bundles.hilt
kapt libs.bundles.hiltkapt
implementation libs.bundles.compose
implementation libs.androidx.core
implementation libs.material
implementation libs.lifecycle
implementation libs.lifecycle.activity
implementation libs.compose.lifecycle
implementation libs.compose.activity
testImplementation libs.test.junit
@@ -60,4 +73,4 @@ dependencies {
androidTestImplementation libs.android.test.espresso
androidTestImplementation libs.android.test.compose.ui
debugImplementation libs.android.test.compose.tooling
}
}

View File

@@ -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

View File

@@ -3,6 +3,7 @@
package="no.nordicsemi.android.nrftoolbox">
<application
android:name=".NrfToolboxApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View File

@@ -0,0 +1,86 @@
package no.nordicsemi.android.nrftoolbox
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import no.nordicsemi.android.csc.CSCRoute
@Composable
fun HomeScreen() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeView(navController) }
composable("csc-route") { CSCRoute() }
}
}
@Composable
fun HomeView(navHostController: NavController) {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
FeatureButton(R.drawable.ic_csc, R.string.csc_module) { navHostController.navigate("csc-route") }
}
}
@Composable
fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onClick() },
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)
) {
Image(
painter = painterResource(iconId),
contentDescription = stringResource(id = nameId),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.White)
)
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(id = nameId),
modifier = Modifier.padding(16.dp),
)
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
HomeView(rememberNavController())
}

View File

@@ -5,34 +5,21 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.nrftoolbox.ui.theme.TestTheme
import dagger.hilt.android.AndroidEntryPoint
import no.nordicsemi.android.theme.TestTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
Greeting("Android")
HomeScreen()
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
TestTheme {
Greeting("Android")
}
}

View File

@@ -0,0 +1,8 @@
package no.nordicsemi.android.nrftoolbox
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class NrfToolboxApplication : Application() {
}

View File

@@ -1,8 +0,0 @@
package no.nordicsemi.android.nrftoolbox.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#00B3DC"
android:pathData="M757,436l-65,-207.4c-3.3,-10.4 -12.9,-17.5 -23.8,-17.5H564.8c-13.8,0 -24.9,11.2 -24.9,24.9c0,13.8 11.2,24.9 24.9,24.9h85l28.4,90.6l-242.5,55.2L414.3,310h16.4c13.8,0 24.9,-11.2 24.9,-24.9s-11.2,-24.9 -24.9,-24.9H295.8c-13.8,0 -24.9,11.2 -24.9,24.9s11.2,24.9 24.9,24.9h67.4l24,107.9l-78.3,17.8c-2.4,0.6 -4.7,1.5 -6.8,2.6c-11.2,-2.1 -22.7,-3.3 -34.6,-3.3c-101.7,0 -184.4,82.7 -184.4,184.4s82.7,184.4 184.4,184.4s184.4,-82.7 184.4,-184.4c0,-60.6 -29.4,-114.5 -74.8,-148.2l316.1,-72l12.2,38.8c-86,15.6 -151.4,91 -151.4,181.4c0,101.7 82.7,184.4 184.4,184.4s184.4,-82.7 184.4,-184.4C922.8,524 850,445.4 757,436zM402.1,619.4c0,74.2 -60.3,134.5 -134.5,134.5s-134.5,-60.3 -134.5,-134.5s60.3,-134.5 134.5,-134.5S402.1,545.2 402.1,619.4zM738.4,753.9c-74.2,0 -134.5,-60.3 -134.5,-134.5c0,-74.1 60.3,-134.4 134.4,-134.5c0,0 0.1,0 0.1,0c0,0 0.1,0 0.1,0c74.1,0.1 134.4,60.4 134.4,134.5C872.9,693.6 812.6,753.9 738.4,753.9z" />
</vector>

View File

@@ -2,13 +2,13 @@
<!-- Base application theme. -->
<style name="Theme.Test" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryVariant">@color/colorPrimaryDark</item>
<item name="colorOnPrimary">@color/colorOnPrimary</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<item name="colorSecondary">@color/colorSecondary</item>
<item name="colorSecondaryVariant">@color/colorSecondaryDark</item>
<item name="colorOnSecondary">@color/colorOnSecondary</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -1,3 +1,5 @@
<resources>
<string name="app_name">Test</string>
<string name="app_name">nRF Toolbox</string>
<string name="csc_module">CSC</string>
</resources>

View File

@@ -1,7 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
compose_version = '1.0.2'
compose_version = '1.1.0-alpha03'
kotlin_version = '1.5.30'
android_api_version = 31
android_min_api_version = 21
}
repositories {
google()
@@ -9,8 +12,9 @@ buildscript {
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.2"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "io.spring.gradle:dependency-management-plugin:1.0.11.RELEASE"
classpath "com.google.dagger:hilt-android-gradle-plugin:2.38.1"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

27
feature_csc/build.gradle Normal file
View File

@@ -0,0 +1,27 @@
apply from: rootProject.file("library.gradle")
apply plugin: 'kotlin-parcelize'
dependencies {
implementation project(":lib_broadcast")
implementation project(":lib_events")
implementation project(":lib_theme")
implementation project(":lib_scanner")
implementation libs.nordic.ble.common
implementation libs.nordic.log
implementation libs.bundles.compose
implementation libs.androidx.core
implementation libs.material
implementation libs.lifecycle.activity
implementation libs.lifecycle.service
implementation libs.compose.lifecycle
implementation libs.compose.activity
testImplementation libs.test.junit
androidTestImplementation libs.android.test.junit
androidTestImplementation libs.android.test.espresso
androidTestImplementation libs.android.test.compose.ui
debugImplementation libs.android.test.compose.tooling
}

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.csc
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("no.nordicsemi.android.csc.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="no.nordicsemi.android.csc">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
tools:ignore="CoarseFineLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application>
<service android:name=".service.CSCService" />
</application>
</manifest>

View File

@@ -0,0 +1,19 @@
package no.nordicsemi.android.csc
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import no.nordicsemi.android.csc.view.CscScreen
import no.nordicsemi.android.scanner.ScannerRoute
@Composable
fun CSCRoute() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "csc_screen") {
composable("csc_screen") { CscScreen(navController) }
composable("scanner-destination") { ScannerRoute(navController) }
}
}

View File

@@ -0,0 +1,131 @@
package no.nordicsemi.android.csc.batery
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import androidx.annotation.IntRange
import no.nordicsemi.android.ble.callback.DataReceivedCallback
import no.nordicsemi.android.ble.common.callback.battery.BatteryLevelDataCallback
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.csc.service.BatteryManagerCallbacks
import no.nordicsemi.android.log.LogContract
import java.util.*
/**
* The Ble Manager with Battery Service support.
*
* @param <T> The profile callbacks type.
* @see BleManager
</T> */
abstract class BatteryManager<T : BatteryManagerCallbacks?>(context: Context) : LoggableBleManager<T>(context) {
private var batteryLevelCharacteristic: BluetoothGattCharacteristic? = null
/**
* Returns the last received Battery Level value.
* The value is set to null when the device disconnects.
* @return Battery Level value, in percent.
*/
/** Last received Battery Level value. */
var batteryLevel: Int? = null
private set
private val batteryLevelDataCallback: DataReceivedCallback =
object : BatteryLevelDataCallback() {
override fun onBatteryLevelChanged(
device: BluetoothDevice,
@IntRange(from = 0, to = 100) batteryLevel: Int
) {
log(LogContract.Log.Level.APPLICATION, "Battery Level received: $batteryLevel%")
this@BatteryManager.batteryLevel = batteryLevel
mCallbacks!!.onBatteryLevelChanged(device, batteryLevel)
}
override fun onInvalidDataReceived(device: BluetoothDevice, data: Data) {
log(Log.WARN, "Invalid Battery Level data received: $data")
}
}
fun readBatteryLevelCharacteristic() {
if (isConnected) {
readCharacteristic(batteryLevelCharacteristic)
.with(batteryLevelDataCallback)
.fail { device: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Battery Level characteristic not found"
)
}
.enqueue()
}
}
fun enableBatteryLevelCharacteristicNotifications() {
if (isConnected) {
// If the Battery Level characteristic is null, the request will be ignored
setNotificationCallback(batteryLevelCharacteristic)
.with(batteryLevelDataCallback)
enableNotifications(batteryLevelCharacteristic)
.done { device: BluetoothDevice? ->
log(
Log.INFO,
"Battery Level notifications enabled"
)
}
.fail { device: BluetoothDevice?, status: Int ->
log(
Log.WARN,
"Battery Level characteristic not found"
)
}
.enqueue()
}
}
/**
* Disables Battery Level notifications on the Server.
*/
fun disableBatteryLevelCharacteristicNotifications() {
if (isConnected) {
disableNotifications(batteryLevelCharacteristic)
.done { device: BluetoothDevice? ->
log(
Log.INFO,
"Battery Level notifications disabled"
)
}
.enqueue()
}
}
protected abstract inner class BatteryManagerGattCallback : BleManagerGattCallback() {
override fun initialize() {
readBatteryLevelCharacteristic()
enableBatteryLevelCharacteristicNotifications()
}
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(BATTERY_SERVICE_UUID)
if (service != null) {
batteryLevelCharacteristic = service.getCharacteristic(
BATTERY_LEVEL_CHARACTERISTIC_UUID
)
}
return batteryLevelCharacteristic != null
}
override fun onDeviceDisconnected() {
batteryLevelCharacteristic = null
batteryLevel = null
}
}
companion object {
/** Battery Service UUID. */
private val BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
/** Battery Level characteristic UUID. */
private val BATTERY_LEVEL_CHARACTERISTIC_UUID =
UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb")
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.csc.batery
import no.nordicsemi.android.ble.data.Data
object CSCMeasurementParser {
private const val WHEEL_REV_DATA_PRESENT: Byte = 0x01 // 1 bit
private const val CRANK_REV_DATA_PRESENT: Byte = 0x02 // 1 bit
@JvmStatic
fun parse(data: Data): String {
var offset = 0
val flags = data.getByte(offset)!!.toInt() // 1 byte
offset += 1
val wheelRevPresent = flags and WHEEL_REV_DATA_PRESENT.toInt() > 0
val crankRevPreset = flags and CRANK_REV_DATA_PRESENT.toInt() > 0
var wheelRevolutions = 0
var lastWheelEventTime = 0
if (wheelRevPresent) {
wheelRevolutions = data.getIntValue(Data.FORMAT_UINT32, offset)!!
offset += 4
lastWheelEventTime = data.getIntValue(Data.FORMAT_UINT16, offset)!! // 1/1024 s
offset += 2
}
var crankRevolutions = 0
var lastCrankEventTime = 0
if (crankRevPreset) {
crankRevolutions = data.getIntValue(Data.FORMAT_UINT16, offset)!!
offset += 2
lastCrankEventTime = data.getIntValue(Data.FORMAT_UINT16, offset)!!
//offset += 2;
}
val builder = StringBuilder()
if (wheelRevPresent) {
builder.append("Wheel rev: ").append(wheelRevolutions).append(",\n")
builder.append("Last wheel event time: ").append(lastWheelEventTime).append(",\n")
}
if (crankRevPreset) {
builder.append("Crank rev: ").append(crankRevolutions).append(",\n")
builder.append("Last crank event time: ").append(lastCrankEventTime).append(",\n")
}
if (!wheelRevPresent && !crankRevPreset) {
builder.append("No wheel or crank data")
}
builder.setLength(builder.length - 2)
return builder.toString()
}
}

View File

@@ -0,0 +1,42 @@
package no.nordicsemi.android.csc.batery
import android.content.Context
import android.util.Log
import no.nordicsemi.android.ble.BleManagerCallbacks
import no.nordicsemi.android.ble.LegacyBleManager
import no.nordicsemi.android.log.ILogSession
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.log.Logger
/**
* The manager that logs to nRF Logger. If nRF Logger is not installed, logs are ignored.
*
* @param <T> the callbacks class.
</T> */
abstract class LoggableBleManager<T : BleManagerCallbacks?>
/**
* The manager constructor.
*
*
* After constructing the manager, the callbacks object must be set with
* [.setGattCallbacks].
*
* @param context the context.
*/
(context: Context) : LegacyBleManager<T>(context) {
private var logSession: ILogSession? = null
/**
* Sets the log session to log into.
*
* @param session nRF Logger log session to log inti, or null, if nRF Logger is not installed.
*/
fun setLogger(session: ILogSession?) {
logSession = session
}
override fun log(priority: Int, message: String) {
Logger.log(logSession, LogContract.Log.Level.fromPriority(priority), message)
Log.println(priority, "BleManager", message)
}
}

View File

@@ -0,0 +1,6 @@
package no.nordicsemi.android.csc.service
import no.nordicsemi.android.ble.BleManagerCallbacks
import no.nordicsemi.android.ble.common.profile.battery.BatteryLevelCallback
interface BatteryManagerCallbacks : BleManagerCallbacks, BatteryLevelCallback

View File

@@ -0,0 +1,613 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.csc.service;
import android.app.Service;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.lifecycle.LifecycleService;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import no.nordicsemi.android.ble.BleManager;
import no.nordicsemi.android.ble.BleManagerCallbacks;
import no.nordicsemi.android.ble.utils.ILogger;
import no.nordicsemi.android.csc.R;
import no.nordicsemi.android.csc.batery.LoggableBleManager;
import no.nordicsemi.android.log.ILogSession;
import no.nordicsemi.android.log.Logger;
@SuppressWarnings("unused")
public abstract class BleProfileService extends LifecycleService implements BleManagerCallbacks {
@SuppressWarnings("unused")
private static final String TAG = "BleProfileService";
public static final String BROADCAST_CONNECTION_STATE = "no.nordicsemi.android.nrftoolbox.BROADCAST_CONNECTION_STATE";
public static final String BROADCAST_SERVICES_DISCOVERED = "no.nordicsemi.android.nrftoolbox.BROADCAST_SERVICES_DISCOVERED";
public static final String BROADCAST_DEVICE_READY = "no.nordicsemi.android.nrftoolbox.DEVICE_READY";
public static final String BROADCAST_BOND_STATE = "no.nordicsemi.android.nrftoolbox.BROADCAST_BOND_STATE";
@Deprecated
public static final String BROADCAST_BATTERY_LEVEL = "no.nordicsemi.android.nrftoolbox.BROADCAST_BATTERY_LEVEL";
public static final String BROADCAST_ERROR = "no.nordicsemi.android.nrftoolbox.BROADCAST_ERROR";
/**
* The parameter passed when creating the service. Must contain the address of the sensor that we want to connect to
*/
public static final String EXTRA_DEVICE_ADDRESS = "no.nordicsemi.android.nrftoolbox.EXTRA_DEVICE_ADDRESS";
/**
* The key for the device name that is returned in {@link #BROADCAST_CONNECTION_STATE} with state {@link #STATE_CONNECTED}.
*/
public static final String EXTRA_DEVICE_NAME = "no.nordicsemi.android.nrftoolbox.EXTRA_DEVICE_NAME";
public static final String EXTRA_DEVICE = "no.nordicsemi.android.nrftoolbox.EXTRA_DEVICE";
public static final String EXTRA_LOG_URI = "no.nordicsemi.android.nrftoolbox.EXTRA_LOG_URI";
public static final String EXTRA_CONNECTION_STATE = "no.nordicsemi.android.nrftoolbox.EXTRA_CONNECTION_STATE";
public static final String EXTRA_BOND_STATE = "no.nordicsemi.android.nrftoolbox.EXTRA_BOND_STATE";
public static final String EXTRA_SERVICE_PRIMARY = "no.nordicsemi.android.nrftoolbox.EXTRA_SERVICE_PRIMARY";
public static final String EXTRA_SERVICE_SECONDARY = "no.nordicsemi.android.nrftoolbox.EXTRA_SERVICE_SECONDARY";
@Deprecated
public static final String EXTRA_BATTERY_LEVEL = "no.nordicsemi.android.nrftoolbox.EXTRA_BATTERY_LEVEL";
public static final String EXTRA_ERROR_MESSAGE = "no.nordicsemi.android.nrftoolbox.EXTRA_ERROR_MESSAGE";
public static final String EXTRA_ERROR_CODE = "no.nordicsemi.android.nrftoolbox.EXTRA_ERROR_CODE";
public static final int STATE_LINK_LOSS = -1;
public static final int STATE_DISCONNECTED = 0;
public static final int STATE_CONNECTED = 1;
public static final int STATE_CONNECTING = 2;
public static final int STATE_DISCONNECTING = 3;
private LoggableBleManager<BleManagerCallbacks> bleManager;
private Handler handler;
protected boolean bound;
private boolean activityIsChangingConfiguration;
private BluetoothDevice bluetoothDevice;
private String deviceName;
private ILogSession logSession;
private final BroadcastReceiver bluetoothStateBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
final int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF);
final ILogger logger = getBinder();
final String stateString = "[Broadcast] Action received: " + BluetoothAdapter.ACTION_STATE_CHANGED + ", state changed to " + state2String(state);
logger.log(Log.DEBUG, stateString);
switch (state) {
case BluetoothAdapter.STATE_ON:
onBluetoothEnabled();
break;
case BluetoothAdapter.STATE_TURNING_OFF:
case BluetoothAdapter.STATE_OFF:
onBluetoothDisabled();
break;
}
}
private String state2String(final int state) {
switch (state) {
case BluetoothAdapter.STATE_TURNING_ON:
return "TURNING ON";
case BluetoothAdapter.STATE_ON:
return "ON";
case BluetoothAdapter.STATE_TURNING_OFF:
return "TURNING OFF";
case BluetoothAdapter.STATE_OFF:
return "OFF";
default:
return "UNKNOWN (" + state + ")";
}
}
};
public class LocalBinder extends Binder implements ILogger {
/**
* Disconnects from the sensor.
*/
public final void disconnect() {
final int state = bleManager.getConnectionState();
if (state == BluetoothGatt.STATE_DISCONNECTED || state == BluetoothGatt.STATE_DISCONNECTING) {
bleManager.close();
onDeviceDisconnected(bluetoothDevice);
return;
}
bleManager.disconnect().enqueue();
}
/**
* Sets whether the bound activity if changing configuration or not.
* If <code>false</code>, we will turn off battery level notifications in onUnbind(..) method below.
*
* @param changing true if the bound activity is finishing
*/
public void setActivityIsChangingConfiguration(final boolean changing) {
activityIsChangingConfiguration = changing;
}
/**
* Returns the device address
*
* @return device address
*/
public String getDeviceAddress() {
return bluetoothDevice.getAddress();
}
/**
* Returns the device name
*
* @return the device name
*/
public String getDeviceName() {
return deviceName;
}
/**
* Returns the Bluetooth device
*
* @return the Bluetooth device
*/
public BluetoothDevice getBluetoothDevice() {
return bluetoothDevice;
}
/**
* Returns <code>true</code> if the device is connected to the sensor.
*
* @return <code>true</code> if device is connected to the sensor, <code>false</code> otherwise
*/
public boolean isConnected() {
return bleManager.isConnected();
}
/**
* Returns the connection state of given device.
*
* @return the connection state, as in {@link BleManager#getConnectionState()}.
*/
public int getConnectionState() {
return bleManager.getConnectionState();
}
/**
* Returns the log session that can be used to append log entries.
* The log session is created when the service is being created.
* The method returns <code>null</code> if the nRF Logger app was not installed.
*
* @return the log session
*/
public ILogSession getLogSession() {
return logSession;
}
@Override
public void log(final int level, @NonNull final String message) {
Logger.log(logSession, level, message);
}
@Override
public void log(final int level, final @StringRes int messageRes, final Object... params) {
Logger.log(logSession, level, messageRes, params);
}
}
/**
* Returns a handler that is created in onCreate().
* The handler may be used to postpone execution of some operations or to run them in UI thread.
*/
protected Handler getHandler() {
return handler;
}
/**
* Returns the binder implementation. This must return class implementing the additional manager interface that may be used in the bound activity.
*
* @return the service binder
*/
protected LocalBinder getBinder() {
// default implementation returns the basic binder. You can overwrite the LocalBinder with your own, wider implementation
return new LocalBinder();
}
@Override
public IBinder onBind(final Intent intent) {
bound = true;
return getBinder();
}
@Override
public final void onRebind(final Intent intent) {
bound = true;
if (!activityIsChangingConfiguration)
onRebind();
}
/**
* Called when the activity has rebound to the service after being recreated.
* This method is not called when the activity was killed to be recreated when the phone orientation changed
* if prior to being killed called {@link LocalBinder#setActivityIsChangingConfiguration(boolean)} with parameter true.
*/
protected void onRebind() {
// empty default implementation
}
@Override
public final boolean onUnbind(final Intent intent) {
bound = false;
if (!activityIsChangingConfiguration)
onUnbind();
// We want the onRebind method be called if anything else binds to it again
return true;
}
/**
* Called when the activity has unbound from the service before being finished.
* This method is not called when the activity is killed to be recreated when the phone orientation changed.
*/
protected void onUnbind() {
// empty default implementation
}
@SuppressWarnings("unchecked")
@Override
public void onCreate() {
super.onCreate();
handler = new Handler();
// Initialize the manager
bleManager = initializeManager();
bleManager.setGattCallbacks(this);
// Register broadcast receivers
registerReceiver(bluetoothStateBroadcastReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
// Service has now been created
onServiceCreated();
// Call onBluetoothEnabled if Bluetooth enabled
final BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
if (bluetoothAdapter.isEnabled()) {
onBluetoothEnabled();
}
}
/**
* Called when the service has been created, before the {@link #onBluetoothEnabled()} is called.
*/
protected void onServiceCreated() {
// empty default implementation
}
/**
* Initializes the Ble Manager responsible for connecting to a single device.
*
* @return a new BleManager object
*/
@SuppressWarnings("rawtypes")
protected abstract LoggableBleManager initializeManager();
/**
* This method returns whether autoConnect option should be used.
*
* @return true to use autoConnect feature, false (default) otherwise.
*/
protected boolean shouldAutoConnect() {
return false;
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (intent == null || !intent.hasExtra(EXTRA_DEVICE_ADDRESS))
throw new UnsupportedOperationException("No device address at EXTRA_DEVICE_ADDRESS key");
final Uri logUri = intent.getParcelableExtra(EXTRA_LOG_URI);
logSession = Logger.openSession(getApplicationContext(), logUri);
deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
Logger.i(logSession, "Service started");
final BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
final String deviceAddress = intent.getStringExtra(EXTRA_DEVICE_ADDRESS);
bluetoothDevice = adapter.getRemoteDevice(deviceAddress);
bleManager.setLogger(logSession);
onServiceStarted();
bleManager.connect(bluetoothDevice)
.useAutoConnect(shouldAutoConnect())
.retry(3, 100)
.enqueue();
return START_REDELIVER_INTENT;
}
/**
* Called when the service has been started. The device name and address are set.
* The BLE Manager will try to connect to the device after this method finishes.
*/
protected void onServiceStarted() {
// empty default implementation
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
// This method is called when user removed the app from Recents.
// By default, the service will be killed and recreated immediately after that.
// However, all managed devices will be lost and devices will be disconnected.
stopSelf();
}
@Override
public void onDestroy() {
super.onDestroy();
// Unregister broadcast receivers
unregisterReceiver(bluetoothStateBroadcastReceiver);
// shutdown the manager
bleManager.close();
Logger.i(logSession, "Service destroyed");
bleManager = null;
bluetoothDevice = null;
deviceName = null;
logSession = null;
handler = null;
}
/**
* Method called when Bluetooth Adapter has been disabled.
*/
protected void onBluetoothDisabled() {
// empty default implementation
}
/**
* This method is called when Bluetooth Adapter has been enabled and
* after the service was created if Bluetooth Adapter was enabled at that moment.
* This method could initialize all Bluetooth related features, for example open the GATT server.
*/
protected void onBluetoothEnabled() {
// empty default implementation
}
@Override
public void onDeviceConnecting(@NonNull final BluetoothDevice device) {
final Intent broadcast = new Intent(BROADCAST_CONNECTION_STATE);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_CONNECTION_STATE, STATE_CONNECTING);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onDeviceConnected(@NonNull final BluetoothDevice device) {
final Intent broadcast = new Intent(BROADCAST_CONNECTION_STATE);
broadcast.putExtra(EXTRA_CONNECTION_STATE, STATE_CONNECTED);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_DEVICE_NAME, deviceName);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onDeviceDisconnecting(@NonNull final BluetoothDevice device) {
// Notify user about changing the state to DISCONNECTING
final Intent broadcast = new Intent(BROADCAST_CONNECTION_STATE);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_CONNECTION_STATE, STATE_DISCONNECTING);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
/**
* This method should return false if the service needs to do some asynchronous work after if has disconnected from the device.
* In that case the {@link #stopService()} method must be called when done.
*
* @return true (default) to automatically stop the service when device is disconnected. False otherwise.
*/
protected boolean stopWhenDisconnected() {
return true;
}
@Override
public void onDeviceDisconnected(@NonNull final BluetoothDevice device) {
// Note 1: Do not use the device argument here unless you change calling onDeviceDisconnected from the binder above
// Note 2: if BleManager#shouldAutoConnect() for this device returned true, this callback will be
// invoked ONLY when user requested disconnection (using Disconnect button). If the device
// disconnects due to a link loss, the onLinkLossOccurred(BluetoothDevice) method will be called instead.
final Intent broadcast = new Intent(BROADCAST_CONNECTION_STATE);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_CONNECTION_STATE, STATE_DISCONNECTED);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
if (stopWhenDisconnected())
stopService();
}
protected void stopService() {
// user requested disconnection. We must stop the service
Logger.v(logSession, "Stopping service...");
stopSelf();
}
@Override
public void onLinkLossOccurred(@NonNull final BluetoothDevice device) {
final Intent broadcast = new Intent(BROADCAST_CONNECTION_STATE);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_CONNECTION_STATE, STATE_LINK_LOSS);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onServicesDiscovered(@NonNull final BluetoothDevice device, final boolean optionalServicesFound) {
final Intent broadcast = new Intent(BROADCAST_SERVICES_DISCOVERED);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_SERVICE_PRIMARY, true);
broadcast.putExtra(EXTRA_SERVICE_SECONDARY, optionalServicesFound);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onDeviceReady(@NonNull final BluetoothDevice device) {
final Intent broadcast = new Intent(BROADCAST_DEVICE_READY);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onDeviceNotSupported(@NonNull final BluetoothDevice device) {
final Intent broadcast = new Intent(BROADCAST_SERVICES_DISCOVERED);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_SERVICE_PRIMARY, false);
broadcast.putExtra(EXTRA_SERVICE_SECONDARY, false);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
// no need for disconnecting, it will be disconnected by the manager automatically
}
@Override
public void onBatteryValueReceived(@NonNull final BluetoothDevice device, final int value) {
final Intent broadcast = new Intent(BROADCAST_BATTERY_LEVEL);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_BATTERY_LEVEL, value);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onBondingRequired(@NonNull final BluetoothDevice device) {
showToast(R.string.csc_bonding);
final Intent broadcast = new Intent(BROADCAST_BOND_STATE);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_BOND_STATE, BluetoothDevice.BOND_BONDING);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onBonded(@NonNull final BluetoothDevice device) {
showToast(R.string.csc_bonded);
final Intent broadcast = new Intent(BROADCAST_BOND_STATE);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_BOND_STATE, BluetoothDevice.BOND_BONDED);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onBondingFailed(@NonNull final BluetoothDevice device) {
showToast(R.string.csc_bonding_failed);
final Intent broadcast = new Intent(BROADCAST_BOND_STATE);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onError(@NonNull final BluetoothDevice device, @NonNull final String message, final int errorCode) {
final Intent broadcast = new Intent(BROADCAST_ERROR);
broadcast.putExtra(EXTRA_DEVICE, bluetoothDevice);
broadcast.putExtra(EXTRA_ERROR_MESSAGE, message);
broadcast.putExtra(EXTRA_ERROR_CODE, errorCode);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
/**
* Shows a message as a Toast notification. This method is thread safe, you can call it from any thread
*
* @param messageResId an resource id of the message to be shown
*/
protected void showToast(final int messageResId) {
handler.post(() -> Toast.makeText(BleProfileService.this, messageResId, Toast.LENGTH_SHORT).show());
}
/**
* Shows a message as a Toast notification. This method is thread safe, you can call it from any thread
*
* @param message a message to be shown
*/
protected void showToast(final String message) {
handler.post(() -> Toast.makeText(BleProfileService.this, message, Toast.LENGTH_SHORT).show());
}
/**
* Returns the log session that can be used to append log entries. The method returns <code>null</code> if the nRF Logger app was not installed. It is safe to use logger when
* {@link #onServiceStarted()} has been called.
*
* @return the log session
*/
protected ILogSession getLogSession() {
return logSession;
}
/**
* Returns the device address
*
* @return device address
*/
protected String getDeviceAddress() {
return bluetoothDevice.getAddress();
}
/**
* Returns the Bluetooth device object
*
* @return bluetooth device
*/
protected BluetoothDevice getBluetoothDevice() {
return bluetoothDevice;
}
/**
* Returns the device name
*
* @return the device name
*/
protected String getDeviceName() {
return deviceName;
}
/**
* Returns <code>true</code> if the device is connected to the sensor.
*
* @return <code>true</code> if device is connected to the sensor, <code>false</code> otherwise
*/
protected boolean isConnected() {
return bleManager != null && bleManager.isConnected();
}
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.csc.service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import androidx.annotation.FloatRange
import no.nordicsemi.android.ble.common.callback.csc.CyclingSpeedAndCadenceMeasurementDataCallback
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.csc.batery.BatteryManager
import no.nordicsemi.android.csc.batery.CSCMeasurementParser.parse
import no.nordicsemi.android.log.LogContract
import java.util.*
private const val SETTINGS_WHEEL_SIZE_DEFAULT = 2340
internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks?>(context) {
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
private var wheelSize = SETTINGS_WHEEL_SIZE_DEFAULT
override fun getGattCallback(): BatteryManagerGattCallback {
return CSCManagerGattCallback()
}
fun setWheelSize(value: Int) {
wheelSize = value
}
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving indication, etc.
*/
private inner class CSCManagerGattCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
// CSC characteristic is required
setNotificationCallback(cscMeasurementCharacteristic)
.with(object : CyclingSpeedAndCadenceMeasurementDataCallback() {
override fun onDataReceived(device: BluetoothDevice, data: Data) {
log(LogContract.Log.Level.APPLICATION, "\"" + parse(data) + "\" received")
// Pass through received data
super.onDataReceived(device, data)
}
override fun getWheelCircumference(): Float {
return wheelSize.toFloat()
}
override fun onDistanceChanged(
device: BluetoothDevice,
@FloatRange(from = 0.0) totalDistance: Float,
@FloatRange(from = 0.0) distance: Float,
@FloatRange(from = 0.0) speed: Float
) {
mCallbacks!!.onDistanceChanged(device, totalDistance, distance, speed)
}
override fun onCrankDataChanged(
device: BluetoothDevice,
@FloatRange(from = 0.0) crankCadence: Float,
gearRatio: Float
) {
mCallbacks!!.onCrankDataChanged(device, crankCadence, gearRatio)
}
override fun onInvalidDataReceived(
device: BluetoothDevice,
data: Data
) {
log(Log.WARN, "Invalid CSC Measurement data received: $data")
}
})
enableNotifications(cscMeasurementCharacteristic).enqueue()
}
public override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(CYCLING_SPEED_AND_CADENCE_SERVICE_UUID)
if (service != null) {
cscMeasurementCharacteristic = service.getCharacteristic(
CSC_MEASUREMENT_CHARACTERISTIC_UUID
)
}
return true
}
override fun onDeviceDisconnected() {
super.onDeviceDisconnected()
cscMeasurementCharacteristic = null
}
override fun onServicesInvalidated() {}
}
companion object {
/** Cycling Speed and Cadence service UUID. */
val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID =
UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
/** Cycling Speed and Cadence Measurement characteristic UUID. */
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID =
UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.csc.service
import no.nordicsemi.android.ble.common.profile.csc.CyclingSpeedAndCadenceCallback
internal interface CSCManagerCallbacks : BatteryManagerCallbacks, CyclingSpeedAndCadenceCallback

View File

@@ -0,0 +1,189 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.csc.service
import android.app.Notification
import android.app.NotificationManager
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import no.nordicsemi.android.broadcast.BluetoothDataReadBroadcast
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.batery.LoggableBleManager
import no.nordicsemi.android.events.CrankDataChanged
import no.nordicsemi.android.events.OnBatteryLevelChanged
import no.nordicsemi.android.events.OnDistanceChangedEvent
import no.nordicsemi.android.log.Logger
import javax.inject.Inject
@AndroidEntryPoint
internal class CSCService : BleProfileService(), CSCManagerCallbacks {
private var manager: CSCManager? = null
@Inject lateinit var localBroadcast: BluetoothDataReadBroadcast
override fun initializeManager(): LoggableBleManager<CSCManagerCallbacks?> {
return CSCManager(this).also { manager = it }
}
override fun onCreate() {
super.onCreate()
val filter = IntentFilter()
filter.addAction(ACTION_DISCONNECT)
registerReceiver(disconnectActionBroadcastReceiver, filter)
localBroadcast.wheelSize.onEach {
manager?.setWheelSize(it)
}.launchIn(GlobalScope)
}
override fun onDestroy() {
// when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService
cancelNotification()
unregisterReceiver(disconnectActionBroadcastReceiver)
super.onDestroy()
}
override fun onRebind() {
stopForegroundService()
if (isConnected) {
// This method will read the Battery Level value, if possible and then try to enable battery notifications (if it has NOTIFY property).
// If the Battery Level characteristic has only the NOTIFY property, it will only try to enable notifications.
manager!!.readBatteryLevelCharacteristic()
}
}
override fun onUnbind() {
// When we are connected, but the application is not open, we are not really interested in battery level notifications.
// But we will still be receiving other values, if enabled.
if (isConnected) manager!!.disableBatteryLevelCharacteristicNotifications()
startForegroundService()
}
override fun onDistanceChanged(
device: BluetoothDevice,
totalDistance: Float,
distance: Float,
speed: Float
) {
localBroadcast.offer(OnDistanceChangedEvent(bluetoothDevice, speed, distance, totalDistance))
}
override fun onCrankDataChanged(
device: BluetoothDevice,
crankCadence: Float,
gearRatio: Float
) {
localBroadcast.offer(CrankDataChanged(bluetoothDevice, crankCadence.toInt(), gearRatio))
}
override fun onBatteryLevelChanged(device: BluetoothDevice, batteryLevel: Int) {
localBroadcast.offer(OnBatteryLevelChanged(bluetoothDevice, batteryLevel))
}
/**
* Sets the service as a foreground service
*/
private fun startForegroundService() {
// when the activity closes we need to show the notification that user is connected to the peripheral sensor
// We start the service as a foreground service as Android 8.0 (Oreo) onwards kills any running background services
val notification = createNotification(R.string.csc_notification_connected_message, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForeground(NOTIFICATION_ID, notification)
} else {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, notification)
}
}
/**
* Stops the service as a foreground service
*/
private fun stopForegroundService() {
// when the activity rebinds to the service, remove the notification and stop the foreground service
// on devices running Android 8.0 (Oreo) or above
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
stopForeground(true)
} else {
cancelNotification()
}
}
/**
* Creates the notification
*
* @param messageResId the message resource id. The message must have one String parameter,<br></br>
* f.e. `<string name="name">%s is connected</string>`
* @param defaults
*/
private fun createNotification(messageResId: Int, defaults: Int): Notification {
TODO()
// final Intent parentIntent = new Intent(this, FeaturesActivity.class);
// parentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// final Intent targetIntent = new Intent(this, CSCActivity.class);
//
// final Intent disconnect = new Intent(ACTION_DISCONNECT);
// final PendingIntent disconnectAction = PendingIntent.getBroadcast(this, DISCONNECT_REQ, disconnect, PendingIntent.FLAG_UPDATE_CURRENT);
//
// // both activities above have launchMode="singleTask" in the AndroidManifest.xml file, so if the task is already running, it will be resumed
// final PendingIntent pendingIntent = PendingIntent.getActivities(this, OPEN_ACTIVITY_REQ, new Intent[]{parentIntent, targetIntent}, PendingIntent.FLAG_UPDATE_CURRENT);
// final NotificationCompat.Builder builder = new NotificationCompat.Builder(this, ToolboxApplication.CONNECTED_DEVICE_CHANNEL);
// builder.setContentIntent(pendingIntent);
// builder.setContentTitle(getString(R.string.app_name)).setContentText(getString(messageResId, getDeviceName()));
// builder.setSmallIcon(R.drawable.ic_stat_notify_csc);
// builder.setShowWhen(defaults != 0).setDefaults(defaults).setAutoCancel(true).setOngoing(true);
// builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_bluetooth, getString(R.string.csc_notification_action_disconnect), disconnectAction));
//
// return builder.build();
}
/**
* Cancels the existing notification. If there is no active notification this method does nothing
*/
private fun cancelNotification() {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.cancel(NOTIFICATION_ID)
}
/**
* This broadcast receiver listens for [.ACTION_DISCONNECT] that may be fired by pressing Disconnect action button on the notification.
*/
private val disconnectActionBroadcastReceiver: BroadcastReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Logger.i(logSession, "[Notification] Disconnect action pressed")
if (isConnected) binder.disconnect() else stopSelf()
}
}
companion object {
private const val ACTION_DISCONNECT = "no.nordicsemi.android.nrftoolbox.csc.ACTION_DISCONNECT"
private const val NOTIFICATION_ID = 200
}
}

View File

@@ -0,0 +1,55 @@
package no.nordicsemi.android.csc.view
import no.nordicsemi.android.events.EMPTY
internal sealed class CSCViewState {
fun ensureConnectedState(): CSCViewConnectedState {
return (this as? CSCViewConnectedState)
?: throw IllegalStateException("Wrong state. Device not connected.")
}
fun ensureDisconnectedState(): CSCViewNotConnectedState {
return (this as? CSCViewNotConnectedState)
?: throw IllegalStateException("Wrong state. Device should be connected.")
}
}
//TODO("Change to navigation")
internal data class CSCViewNotConnectedState(
val showScannerDialog: Boolean = false
) : CSCViewState()
internal data class CSCViewConnectedState(
val showDialog: Boolean = false,
val scanDevices: Boolean = false,
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,
val speed: Float = 0f,
val cadence: Int = 0,
val distance: Float = 0f,
val totalDistance: Float = 0f,
val gearRatio: Float = 0f,
val batteryLevel: Int = 0,
val wheelSize: String = String.EMPTY
) : CSCViewState() {
fun displaySpeed(): String {
return speed.toString()
}
fun displayCadence(): String {
return cadence.toString()
}
fun displayDistance(): String {
return distance.toString()
}
fun displayTotalDistance(): String {
return totalDistance.toString()
}
fun displayBatteryLever(): String {
return batteryLevel.toString()
}
}

View File

@@ -0,0 +1,19 @@
package no.nordicsemi.android.csc.view
import android.bluetooth.BluetoothDevice
internal sealed class CSCViewEvent
internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent()
internal data class OnWheelSizeSelected(val wheelSize: Int, val wheelSizeDisplayInfo: String) : CSCViewEvent()
internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : CSCViewEvent()
internal object OnDisconnectButtonClick : CSCViewEvent()
internal object OnConnectButtonClick : CSCViewEvent()
internal object OnMovedToScannerScreen : CSCViewEvent()
internal data class OnBluetoothDeviceSelected(val device: BluetoothDevice) : CSCViewEvent()

View File

@@ -0,0 +1,135 @@
package no.nordicsemi.android.csc.view
import android.bluetooth.BluetoothDevice
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.service.CSCService
import no.nordicsemi.android.events.exhaustive
@Composable
internal fun CscScreen(navController: NavController, viewModel: CscViewModel = hiltViewModel()) {
val secondScreenResult = navController.currentBackStackEntry
?.savedStateHandle
?.getLiveData<BluetoothDevice>("result")?.observeAsState()
secondScreenResult?.value?.let {
viewModel.onEvent(OnBluetoothDeviceSelected(it))
val intent = Intent(LocalContext.current, CSCService::class.java).apply {
putExtra("no.nordicsemi.android.nrftoolbox.EXTRA_DEVICE_ADDRESS", it.address)
}
LocalContext.current.startService(intent)
navController.currentBackStackEntry
?.savedStateHandle
?.set("result", null)
}
val state = viewModel.state.collectAsState().value
CSCView(navController, state) { viewModel.onEvent(it) }
}
@Composable
private fun CSCView(navController: NavController, state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) })
when (state) {
is CSCViewConnectedState -> ConnectedView(state) { onEvent(it) }
is CSCViewNotConnectedState -> NotConnectedScreen(navController, state) {
onEvent(it)
}
}.exhaustive
}
}
@Composable
private fun NotConnectedScreen(
navController: NavController,
state: CSCViewNotConnectedState,
onEvent: (CSCViewEvent) -> Unit
) {
if (state.showScannerDialog) {
navController.navigate("scanner-destination")
onEvent(OnMovedToScannerScreen)
}
NotConnectedView(onEvent)
}
@Composable
private fun NotConnectedView(
onEvent: (CSCViewEvent) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.csc_no_connection))
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { onEvent(OnConnectButtonClick) }) {
Text(text = stringResource(id = R.string.csc_connect))
}
}
}
@Composable
private fun ConnectedView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
if (state.showDialog) {
SelectWheelSizeDialog { onEvent(it) }
}
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
WheelSizeView(state, onEvent)
SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) }
SensorsReadingView(state = state)
Button(onClick = { onEvent(OnDisconnectButtonClick) }) {
Text(text = stringResource(id = R.string.csc_disconnect))
}
}
}
@Preview
@Composable
private fun NotConnectedPreview() {
NotConnectedView { }
}
@Preview
@Composable
private fun ConnectedPreview() {
ConnectedView(CSCViewConnectedState()) { }
}

View File

@@ -0,0 +1,101 @@
package no.nordicsemi.android.csc.view
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import no.nordicsemi.android.broadcast.BluetoothDataReadBroadcast
import no.nordicsemi.android.events.CSCServiceEvent
import no.nordicsemi.android.events.CrankDataChanged
import no.nordicsemi.android.events.OnBatteryLevelChanged
import no.nordicsemi.android.events.OnDistanceChangedEvent
import no.nordicsemi.android.events.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class CscViewModel @Inject constructor(
private val localBroadcast: BluetoothDataReadBroadcast
) : ViewModel() {
val state = MutableStateFlow<CSCViewState>(CSCViewNotConnectedState())
init {
localBroadcast.events.onEach {
(it as? CSCServiceEvent)?.let { withContext(Dispatchers.Main) { consumeEvent(it) }}
}.launchIn(viewModelScope)
}
private fun consumeEvent(event: CSCServiceEvent) {
val newValue = when (event) {
is CrankDataChanged -> createNewState(event)
is OnBatteryLevelChanged -> createNewState(event)
is OnDistanceChangedEvent -> createNewState(event)
}
state.value = newValue
}
private fun createNewState(event: CrankDataChanged): CSCViewConnectedState {
return state.value.ensureConnectedState().copy(
cadence = event.crankCadence,
gearRatio = event.gearRatio
)
}
private fun createNewState(event: OnBatteryLevelChanged): CSCViewConnectedState {
return state.value.ensureConnectedState().copy(
batteryLevel = event.batteryLevel
)
}
private fun createNewState(event: OnDistanceChangedEvent): CSCViewConnectedState {
return state.value.ensureConnectedState().copy(
speed = event.speed,
distance = event.distance,
totalDistance = event.totalDistance
)
}
fun onEvent(event: CSCViewEvent) {
when (event) {
is OnSelectedSpeedUnitSelected -> onSelectedSpeedUnit(event)
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
is OnWheelSizeSelected -> onWheelSizeChanged(event)
OnDisconnectButtonClick -> TODO()
OnConnectButtonClick -> onConnectButtonClick()
OnMovedToScannerScreen -> onOnMovedToScannerScreen()
is OnBluetoothDeviceSelected -> onBluetoothDeviceSelected()
}.exhaustive
}
private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) {
state.tryEmit(state.value.ensureConnectedState().copy(selectedSpeedUnit = event.selectedSpeedUnit))
}
private fun onShowDialogEvent() {
state.tryEmit(state.value.ensureConnectedState().copy(showDialog = true))
}
private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
localBroadcast.setWheelSize(event.wheelSize)
state.tryEmit(state.value.ensureConnectedState().copy(
showDialog = false,
wheelSize = event.wheelSizeDisplayInfo
))
}
private fun onConnectButtonClick() {
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = true))
}
private fun onOnMovedToScannerScreen() {
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = false))
}
private fun onBluetoothDeviceSelected() {
state.tryEmit(CSCViewConnectedState())
}
}

View File

@@ -0,0 +1,48 @@
package no.nordicsemi.android.csc.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.theme.Background
import no.nordicsemi.android.theme.TestTheme
@Composable
internal fun SelectWheelSizeDialog(onEvent: (OnWheelSizeSelected) -> Unit) {
Dialog(onDismissRequest = {}) {
SelectWheelSizeView(onEvent)
}
}
@Composable
private fun SelectWheelSizeView(onEvent: (OnWheelSizeSelected) -> Unit) {
val wheelEntries = stringArrayResource(R.array.wheel_entries)
val wheelValues = stringArrayResource(R.array.wheel_values)
Box(Modifier.padding(16.dp)) {
Column(modifier = Background.whiteRoundedCorners()) {
Text(text = "Wheel size")
wheelEntries.forEachIndexed { i, entry ->
Text(text = entry, modifier = Modifier.clickable {
onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry))
})
}
}
}
}
@Preview
@Composable
internal fun DefaultPreview() {
TestTheme {
SelectWheelSizeView { }
}
}

View File

@@ -0,0 +1,55 @@
package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.theme.Background
@Composable
internal fun SensorsReadingView(state: CSCViewConnectedState) {
Column {
Column(modifier = Background.whiteRoundedCorners()) {
KeyValueField(stringResource(id = R.string.scs_field_speed), state.displaySpeed())
KeyValueField(stringResource(id = R.string.scs_field_cadence), state.displayCadence())
KeyValueField(stringResource(id = R.string.scs_field_distance), state.displayDistance())
KeyValueField(
stringResource(id = R.string.scs_field_total_distance),
state.displayTotalDistance()
)
KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displaySpeed())
}
Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Background.whiteRoundedCorners()) {
KeyValueField(stringResource(id = R.string.scs_field_battery), state.displayBatteryLever())
}
}
}
@Composable
private fun KeyValueField(key: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = key)
Text(text = value)
}
}
@Preview
@Composable
private fun Preview() {
SensorsReadingView(CSCViewConnectedState())
}

View File

@@ -0,0 +1,7 @@
package no.nordicsemi.android.csc.view
internal enum class SpeedUnit {
M_S,
KM_H,
MPH
}

View File

@@ -0,0 +1,49 @@
package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.RadioButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun SpeedUnitRadioGroup(
currentUnit: SpeedUnit,
onEvent: (OnSelectedSpeedUnitSelected) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent)
SpeedUnitRadioButton(currentUnit, SpeedUnit.MPH, onEvent)
SpeedUnitRadioButton(currentUnit, SpeedUnit.M_S, onEvent)
}
}
@Composable
internal fun SpeedUnitRadioButton(
selectedUnit: SpeedUnit,
displayedUnit: SpeedUnit,
onEvent: (OnSelectedSpeedUnitSelected) -> Unit
) {
Row {
RadioButton(
selected = (selectedUnit == displayedUnit),
onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) }
)
Text(text = createSpeedUnitLabel(displayedUnit))
}
}
internal fun createSpeedUnitLabel(unit: SpeedUnit): String {
return when (unit) {
SpeedUnit.M_S -> "m/s"
SpeedUnit.KM_H -> "km/h"
SpeedUnit.MPH -> "mph"
}
}

View File

@@ -0,0 +1,39 @@
package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.csc.R
@Composable
internal fun WheelSizeView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = state.wheelSize,
onValueChange = { },
enabled = false,
label = { Text(text = stringResource(id = R.string.scs_field_wheel_size)) },
trailingIcon = { EditIcon(onEvent = onEvent) }
)
}
@Composable
private fun EditIcon(onEvent: (CSCViewEvent) -> Unit) {
IconButton(onClick = { onEvent(OnShowEditWheelSizeDialogButtonClick) }) {
Icon(Icons.Filled.Edit, "Edit wheel size.")
}
}
@Preview
@Composable
private fun WheelSizeViewPreview() {
WheelSizeView(CSCViewConnectedState()) { }
}

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="csc_title">Cyclic and speed cadence</string>
<string name="csc_bonding">Bonding with the device&#8230;</string>
<string name="csc_bonded">The device is now bonded.</string>
<string name="csc_bonding_failed">Bonding failed.</string>
<string name="csc_disconnect">Disconnect</string>
<string name="csc_no_connection">No device connected</string>
<string name="csc_connect">Connect</string>
<string name="csc_notification_connected_message">%s is connected.</string>
<string name="scs_field_speed">Speed</string>
<string name="scs_field_cadence">Cadence</string>
<string name="scs_field_distance">Distance</string>
<string name="scs_field_total_distance">Total Distance</string>
<string name="scs_field_gear_ratio">Gear Ratio</string>
<string name="scs_field_battery">Battery</string>
<string name="scs_field_wheel_size">Wheel size</string>
<string-array name="wheel_entries">
<item>60&#8211;622</item>
<item>50&#8211;622</item>
<item>47&#8211;622</item>
<item>44&#8211;622</item>
<item>40&#8211;635</item>
<item>40&#8211;622</item>
<item>38&#8211;622</item>
<item>37&#8211;622</item>
<item>35&#8211;622</item>
<item>32&#8211;630</item>
<item>32&#8211;622</item>
<item>32&#8211;622</item>
<item>28&#8211;622</item>
<item>60&#8211;559</item>
<item>28&#8211;622</item>
<item>25&#8211;622</item>
<item>25&#8211;622</item>
<item>23&#8211;622</item>
<item>20&#8211;622</item>
<item>18&#8211;622</item>
<item>35&#8211;630</item>
<item>32&#8211;630</item>
<item>28&#8211;630</item>
<item>57&#8211;559</item>
<item>54&#8211;559</item>
<item>37&#8211;590</item>
<item>23&#8211;622</item>
<item>50&#8211;559</item>
<item>20&#8211;622</item>
<item>54&#8211;559</item>
<item>47&#8211;559</item>
<item>35&#8211;590</item>
<item>37&#8211;590</item>
<item>47&#8211;559</item>
<item>50&#8211;559</item>
<item>44&#8211;559</item>
<item>40&#8211;559</item>
<item>23&#8211;571</item>
<item>20&#8211;571</item>
<item>32&#8211;559</item>
<item>25&#8211;571</item>
<item>34&#8211;540</item>
<item>50&#8211;507</item>
<item>47&#8211;507</item>
<item>28&#8211;451</item>
<item>50&#8211;406</item>
<item>47&#8211;406</item>
<item>28&#8211;369</item>
<item>35&#8211;349</item>
<item>47&#8211;305</item>
</string-array>
<string-array name="wheel_values">
<item>2340</item>
<item>2284</item>
<item>2268</item>
<item>2224</item>
<item>2265</item>
<item>2224</item>
<item>2180</item>
<item>2205</item>
<item>2168</item>
<item>2199</item>
<item>2174</item>
<item>2155</item>
<item>2149</item>
<item>2146</item>
<item>2136</item>
<item>2146</item>
<item>2105</item>
<item>2133</item>
<item>2114</item>
<item>2102</item>
<item>2169</item>
<item>2161</item>
<item>2155</item>
<item>2133</item>
<item>2114</item>
<item>2105</item>
<item>2097</item>
<item>2089</item>
<item>2086</item>
<item>2114</item>
<item>2070</item>
<item>2068</item>
<item>2105</item>
<item>2055</item>
<item>2089</item>
<item>2051</item>
<item>2026</item>
<item>1973</item>
<item>1954</item>
<item>1953</item>
<item>1952</item>
<item>1948</item>
<item>1910</item>
<item>1907</item>
<item>1618</item>
<item>1593</item>
<item>1590</item>
<item>1325</item>
<item>1282</item>
<item>1272</item>
</string-array>
</resources>

View File

@@ -0,0 +1,17 @@
package no.nordicsemi.android.csc
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,7 @@
apply from: rootProject.file("library.gradle")
dependencies {
implementation project(":lib_events")
implementation libs.kotlin.coroutines
}

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.broadcast
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("no.nordicsemi.android.broadcast.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="no.nordicsemi.android.broadcast">
</manifest>

View File

@@ -0,0 +1,34 @@
package no.nordicsemi.android.broadcast
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import no.nordicsemi.android.events.BluetoothReadDataEvent
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BluetoothDataReadBroadcast @Inject constructor() {
private val _event = MutableSharedFlow<BluetoothReadDataEvent>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<BluetoothReadDataEvent> = _event
private val _wheelSize = MutableSharedFlow<Int>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val wheelSize: SharedFlow<Int> = _wheelSize
fun offer(newEvent: BluetoothReadDataEvent) {
_event.tryEmit(newEvent)
}
fun setWheelSize(size: Int) {
_wheelSize.tryEmit(size)
}
}

View File

@@ -0,0 +1,17 @@
package no.nordicsemi.android.broadcast
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

3
lib_events/build.gradle Normal file
View File

@@ -0,0 +1,3 @@
apply from: rootProject.file("library.gradle")
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.events
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("no.nordicsemi.android.events.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="no.nordicsemi.android.events">
</manifest>

View File

@@ -0,0 +1,3 @@
package no.nordicsemi.android.events
sealed class BluetoothReadDataEvent

View File

@@ -0,0 +1,28 @@
package no.nordicsemi.android.events
import android.bluetooth.BluetoothDevice
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
sealed class CSCServiceEvent : BluetoothReadDataEvent(), Parcelable
@Parcelize
data class OnDistanceChangedEvent(
val bluetoothDevice: BluetoothDevice,
val speed: Float,
val distance: Float,
val totalDistance: Float
) : CSCServiceEvent()
@Parcelize
data class CrankDataChanged(
val bluetoothDevice: BluetoothDevice,
val crankCadence: Int,
val gearRatio: Float
) : CSCServiceEvent()
@Parcelize
data class OnBatteryLevelChanged(
val device: BluetoothDevice,
val batteryLevel: Int
) : CSCServiceEvent()

View File

@@ -0,0 +1,7 @@
package no.nordicsemi.android.events
val <T> T.exhaustive
get() = this
val String.Companion.EMPTY
get() = ""

View File

@@ -0,0 +1,17 @@
package no.nordicsemi.android.events
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

14
lib_scanner/build.gradle Normal file
View File

@@ -0,0 +1,14 @@
apply from: rootProject.file("library.gradle")
apply plugin: 'kotlin-parcelize'
dependencies {
implementation project(":lib_theme")
implementation project(":lib_events")
implementation libs.material
implementation libs.google.permissions
implementation libs.bundles.compose
implementation libs.compose.lifecycle
implementation libs.compose.activity
}

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.scanner
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("no.nordicsemi.android.scanner.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="no.nordicsemi.android.scanner">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<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"
tools:ignore="CoarseFineLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
</manifest>

View File

@@ -0,0 +1,17 @@
package no.nordicsemi.android.scanner
import android.bluetooth.BluetoothAdapter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal object HiltModule {
@Provides
fun createNordicBleScanner(): BluetoothAdapter? {
return BluetoothAdapter.getDefaultAdapter()
}
}

View File

@@ -0,0 +1,71 @@
package no.nordicsemi.android.scanner
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.events.exhaustive
@Composable
internal fun ListOfDevicesScreen(onDeviceSelected: (BluetoothDevice) -> Unit) {
val viewModel = hiltViewModel<NordicBleScannerViewModel>()
val result = viewModel.scannerResult.collectAsState().value
when (result) {
is DeviceListResult -> DeviceListView(result.devices, onDeviceSelected)
is ScanningErrorResult -> ScanningErrorView()
}.exhaustive
}
@SuppressLint("MissingPermission")
@Composable
private fun DeviceListView(
devices: List<BluetoothDevice>,
onDeviceSelected: (BluetoothDevice) -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(id = R.string.scanner__list_of_devices))
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
itemsIndexed(devices) { _, device ->
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onDeviceSelected(device) }
) {
Column {
Text(device.name ?: stringResource(id = R.string.scanner__no_name))
Spacer(modifier = Modifier.height(8.dp))
Text(text = device.address)
}
}
}
}
}
}
@Composable
private fun ScanningErrorView() {
Text(text = stringResource(id = R.string.scanner__error))
}

View File

@@ -0,0 +1,67 @@
package no.nordicsemi.android.scanner
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@SuppressLint("MissingPermission")
internal class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
val scannerResult = MutableStateFlow<ScanningResult>(DeviceListResult())
private var isScanning = false
private val scanner by lazy { bleAdapter?.bluetoothLeScanner }
private val devices = mutableListOf<BluetoothDevice>()
private val scanningCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
result?.device?.let { devices.addIfNotExist(it) }
scannerResult.value = DeviceListResult(devices)
}
override fun onScanFailed(errorCode: Int) {
scannerResult.value = ScanningErrorResult
}
}
fun getBluetoothStatus(): ScannerStatus {
return when {
bleAdapter == null -> ScannerStatus.NOT_AVAILABLE
bleAdapter.isEnabled -> ScannerStatus.ENABLED
else -> ScannerStatus.DISABLED
}
}
fun startScanning() {
if (isScanning) {
return
}
isScanning = true
scanner?.startScan(scanningCallback)
}
fun stopScanning() {
if (!isScanning) {
return
}
isScanning = false
scanner?.stopScan(scanningCallback)
}
}
sealed class ScanningResult
data class DeviceListResult(val devices: List<BluetoothDevice> = emptyList()) : ScanningResult()
object ScanningErrorResult : ScanningResult()
private fun <T> MutableList<T>.addIfNotExist(value: T) {
if (!contains(value)) {
add(value)
}
}

View File

@@ -0,0 +1,44 @@
package no.nordicsemi.android.scanner
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import no.nordicsemi.android.events.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class NordicBleScannerViewModel @Inject constructor(
private val bleScanner: NordicBleScanner
) : ViewModel() {
val state =
MutableStateFlow(NordicBleScannerState(scannerStatus = ScannerStatus.PERMISSION_REQUIRED))
val scannerResult = bleScanner.scannerResult
fun onEvent(event: ScannerViewEvent) {
when (event) {
ScannerViewEvent.PERMISSION_CHECKED -> onPermissionChecked()
ScannerViewEvent.BLUETOOTH_ENABLED -> onBluetoothEnabled()
ScannerViewEvent.ENABLE_SCANNING -> bleScanner.startScanning()
ScannerViewEvent.DISABLE_SCANNING -> bleScanner.stopScanning()
}.exhaustive
}
private fun onPermissionChecked() {
state.value = state.value.copy(scannerStatus = bleScanner.getBluetoothStatus())
}
private fun onBluetoothEnabled() {
state.value = state.value.copy(scannerStatus = bleScanner.getBluetoothStatus())
bleScanner.startScanning()
}
}
enum class ScannerViewEvent {
PERMISSION_CHECKED, BLUETOOTH_ENABLED, ENABLE_SCANNING, DISABLE_SCANNING
}
internal data class NordicBleScannerState(
val scannerStatus: ScannerStatus
)

View File

@@ -0,0 +1,49 @@
package no.nordicsemi.android.scanner
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import no.nordicsemi.android.events.exhaustive
import no.nordicsemi.android.scanner.bluetooth.BluetoothNotAvailableScreen
import no.nordicsemi.android.scanner.bluetooth.BluetoothNotEnabledScreen
import no.nordicsemi.android.scanner.permissions.RequestPermissionScreen
@Composable
fun ScannerRoute(navController: NavController) {
val viewModel = hiltViewModel<NordicBleScannerViewModel>()
val scannerStatus = viewModel.state.collectAsState().value.scannerStatus
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__devices_list)) })
ScannerScreen(navController, scannerStatus) { viewModel.onEvent(it) }
}
}
@Composable
private fun ScannerScreen(
navController: NavController,
scannerStatus: ScannerStatus,
onEvent: (ScannerViewEvent) -> Unit
) {
when (scannerStatus) {
ScannerStatus.PERMISSION_REQUIRED -> RequestPermissionScreen { onEvent(ScannerViewEvent.PERMISSION_CHECKED) }
ScannerStatus.NOT_AVAILABLE -> BluetoothNotAvailableScreen()
ScannerStatus.DISABLED -> BluetoothNotEnabledScreen { onEvent(ScannerViewEvent.BLUETOOTH_ENABLED) }
ScannerStatus.ENABLED -> {
onEvent(ScannerViewEvent.ENABLE_SCANNING)
ListOfDevicesScreen {
navController.previousBackStackEntry
?.savedStateHandle
?.set("result", it)
navController.popBackStack()
onEvent(ScannerViewEvent.DISABLE_SCANNING)
}
}
}.exhaustive
}

View File

@@ -0,0 +1,5 @@
package no.nordicsemi.android.scanner
enum class ScannerStatus {
PERMISSION_REQUIRED, ENABLED, DISABLED, NOT_AVAILABLE
}

View File

@@ -0,0 +1,34 @@
package no.nordicsemi.android.scanner.bluetooth
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@Composable
internal fun BluetoothNotAvailableScreen() {
Text("Bluetooth not available.")
}
@Composable
internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
val contract = ActivityResultContracts.StartActivityForResult()
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
if (it.resultCode == Activity.RESULT_OK) {
finish()
}
})
Column {
Text(text = "Bluetooth not enabled.")
Text(text = "To enable Bluetooth please open settings.")
Button(onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }) {
Text(text = "Bluetooth not available.")
}
}
}

View File

@@ -0,0 +1,119 @@
package no.nordicsemi.android.scanner.permissions
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionsRequired
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import no.nordicsemi.android.scanner.R
@OptIn(ExperimentalPermissionsApi::class)
@Composable
internal fun RequestPermissionScreen(finish: () -> Unit) {
val permissionsState = rememberMultiplePermissionsState(listOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
// android.Manifest.permission.BLUETOOTH_SCAN,
// android.Manifest.permission.BLUETOOTH_CONNECT
))
PermissionsRequired(
multiplePermissionsState = permissionsState,
permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } },
permissionsNotAvailableContent = { PermissionNotAvailable() }
) {
finish()
}
}
@Composable
private fun PermissionNotGranted(onClick: () -> Unit) {
val doNotShowRationale = rememberSaveable { mutableStateOf(false) }
if (doNotShowRationale.value) {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(id = R.string.scanner__feature_not_available))
}
} else {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(id = R.string.scanner__permission_rationale))
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(onClick = { onClick() }) {
Text(stringResource(id = R.string.scanner__button_ok))
}
Spacer(Modifier.width(8.dp))
Button(onClick = { doNotShowRationale.value = true }) {
Text(stringResource(id = R.string.scanner__button_nope))
}
}
}
}
}
@Composable
private fun PermissionNotAvailable() {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(id = R.string.scanner__permission_denied))
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { openPermissionSettings(context) }) {
Text(stringResource(id = R.string.scanner__open_settings))
}
}
}
private fun openPermissionSettings(context: Context) {
startActivity(
context,
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
),
null
)
}
@Preview
@Composable
private fun PermissionNotGrantedPreview() {
PermissionNotGranted { }
}
@Preview
@Composable
private fun PermissionNotAvailablePreview() {
PermissionNotAvailable()
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="scanner__devices_list">BLE devices</string>
<string name="scanner__permission_rationale">The location permission is required when using Bluetooth LE, because surrounding devices can expose user\'s location. Please grant the permission.</string>
<string name="scanner__permission_denied">Location permission denied. Please, grant us access on the Settings screen.</string>
<string name="scanner__button_ok">OK</string>
<string name="scanner__button_nope">Nope</string>
<string name="scanner__open_settings">Open settings</string>
<string name="scanner__feature_not_available">Feature not available</string>
<string name="scanner__list_of_devices">List of devices</string>
<string name="scanner__error">Scanning failed due to technical reason.</string>
<string name="scanner__no_name">Name: NONE</string>
</resources>

View File

@@ -0,0 +1,17 @@
package no.nordicsemi.android.scanner
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

9
lib_theme/build.gradle Normal file
View File

@@ -0,0 +1,9 @@
apply from: rootProject.file("library.gradle")
dependencies {
implementation libs.material
implementation libs.bundles.compose
implementation libs.compose.lifecycle
implementation libs.compose.activity
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="no.nordicsemi.android.theme">
</manifest>

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
object Background {
@Composable
fun whiteRoundedCorners(): Modifier {
return Modifier
.background(Color(0xffffffff))
.padding(16.dp)
.verticalScroll(rememberScrollState())
.clip(RoundedCornerShape(10.dp))
}
}

View File

@@ -0,0 +1,26 @@
package no.nordicsemi.android.theme
import androidx.compose.ui.graphics.Color
object NordicColors {
val Primary = Color(0xFF00A9CE)
val PrimaryLight = Color(0xFF5fdbff)
val PrimaryDark = Color(0xFF007a9d)
val Secondary = Color(0xFF0077c8)
val SecondaryLight = Color(0xFF57c0e2)
val SecondaryDark = Color(0xFF004c97)
val Text = Color(0xFF00A9CE)
val NordicBlue = Color(0xFF00A9CE)
val NordicBlueDark = Color(0xFF0090B0)
val NordicSky = Color(0xFF6AD1E3)
val NordicBlueLate = Color(0xFF0033A0)
val NordicLake = Color(0xFF0077C8)
val NordicLightGray = Color(0xFFD9E1E2)
val NordicMediumGray = Color(0xFF768692)
val NordicDarkGray = Color(0xFF333F48)
val NordicGrass = Color(0xFFD0DF00)
val NordicSun = Color(0xFFFFCD00)
val NordicRed = Color(0xFFEE2F4E)
val NordicFall = Color(0xFFF58220)
}

View File

@@ -1,4 +1,4 @@
package no.nordicsemi.android.nrftoolbox.ui.theme
package no.nordicsemi.android.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes

View File

@@ -1,30 +1,37 @@
package no.nordicsemi.android.nrftoolbox.ui.theme
package no.nordicsemi.android.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
//TODO
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
primary = NordicColors.Primary,
primaryVariant = NordicColors.PrimaryDark,
secondary = NordicColors.Secondary,
secondaryVariant = NordicColors.SecondaryDark,
onSecondary = Color.White,
onPrimary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
background = Color.White,
surface = Color.White,
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
/* Other default colors to override
background = Color.White,
surface = Color.White,
primary = NordicColors.Primary,
primaryVariant = NordicColors.PrimaryDark,
secondary = NordicColors.Secondary,
secondaryVariant = NordicColors.SecondaryDark,
onSecondary = Color.White,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
*/
background = Color.White,
surface = Color.White,
)
@Composable

View File

@@ -1,4 +1,4 @@
package no.nordicsemi.android.nrftoolbox.ui.theme
package no.nordicsemi.android.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#FF00A9CE</color>
<color name="colorPrimaryDark">#FF007a9d</color>
<color name="colorOnPrimary">#FFFFFFFF</color>
<color name="colorSecondary">#FF0077c8</color>
<color name="colorSecondaryDark">#FF004c97</color>
<color name="colorOnSecondary">#FFFFFFFF</color>
</resources>

View File

@@ -2,13 +2,13 @@
<!-- Base application theme. -->
<style name="Theme.Test" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryVariant">@color/colorPrimaryDark</item>
<item name="colorOnPrimary">@color/colorOnPrimary</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<item name="colorSecondary">@color/colorSecondary</item>
<item name="colorSecondaryVariant">@color/colorSecondaryDark</item>
<item name="colorOnSecondary">@color/colorOnSecondary</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
@@ -22,4 +22,4 @@
<style name="Theme.Test.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
<style name="Theme.Test.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
</resources>
</resources>

59
library.gradle Normal file
View File

@@ -0,0 +1,59 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'dagger.hilt.android.plugin'
apply plugin: 'kotlin-kapt'
android {
compileSdk android_api_version
defaultConfig {
minSdk android_min_api_version
targetSdk android_api_version
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
/**
* There is an issue with recomposition performance when data objects doesn't belong to the module
* when composition is enabled, because those objects cannot be properly compared for the changes.
* Better to leave enabled for all modules.
*/
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion kotlin_version
}
buildFeatures {
compose true
}
hilt {
enableExperimentalClasspathAggregation = true
}
}
dependencies {
implementation libs.bundles.compose
implementation libs.bundles.hilt
kapt libs.bundles.hiltkapt
}

View File

@@ -10,18 +10,39 @@ dependencyResolutionManagement {
versionCatalogs {
libs {
version('compose', '1.0.2')
alias('nordic-ble-common').to('no.nordicsemi.android:ble-common:2.2.0')
alias('nordic-log').to('no.nordicsemi.android:log:2.3.0')
alias('nordic-scanner').to('no.nordicsemi.android.support.v18:scanner:1.5.0')
alias('material').to('com.google.android.material:material:1.4.0')
version('lifecycle', '2.3.1')
alias('lifecycle-activity').to('androidx.lifecycle', 'lifecycle-runtime-ktx').versionRef('lifecycle')
alias('lifecycle-service').to('androidx.lifecycle', 'lifecycle-service').versionRef('lifecycle')
alias('androidx-core').to('androidx.core:core-ktx:1.6.0')
alias('material').to('com.google.android.material:material:1.4.0')
alias('lifecycle').to('androidx.lifecycle:lifecycle-runtime-ktx:2.3.1')
alias('compose-activity').to('androidx.activity:activity-compose:1.3.1')
alias('compose-lifecycle').to('androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07')
version('compose', '1.0.2')
alias('compose-livedata').to('androidx.compose.runtime', 'runtime-livedata').versionRef('compose')
alias('compose-ui').to('androidx.compose.ui', 'ui').versionRef('compose')
alias('compose-material').to('androidx.compose.material', 'material').versionRef('compose')
alias('compose-tooling-preview').to('androidx.compose.ui', 'ui-tooling-preview').versionRef('compose')
alias('compose-navigation').to('androidx.navigation:navigation-compose:2.4.0-alpha09')
bundle('compose', ['compose-livedata', 'compose-ui', 'compose-material', 'compose-tooling-preview', 'compose-navigation'])
bundle('compose', ['compose-ui', 'compose-material', 'compose-tooling-preview'])
version('hilt', '2.38.1')
alias('hilt-android').to('com.google.dagger', 'hilt-android').versionRef('hilt')
alias('hilt-compiler').to('com.google.dagger', 'hilt-compiler').versionRef('hilt')
alias('hilt-compose').to('androidx.hilt:hilt-navigation-compose:1.0.0-alpha03')
alias('hilt-lifecycle').to('androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02')
alias('hilt-lifecyclecompiler').to('androidx.hilt:hilt-compiler:1.0.0-alpha02')
bundle('hilt', ['hilt-android', 'hilt-compose', 'hilt-lifecycle'])
bundle('hiltkapt', ['hilt-compiler', 'hilt-lifecyclecompiler'])
alias('kotlin-coroutines').to('org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2')
alias('google-permissions').to('com.google.accompanist:accompanist-permissions:0.18.0')
//-- Test ------------------------------------------------------------------------------
alias('test-junit').to('junit:junit:4.13.2')
@@ -33,9 +54,15 @@ dependencyResolutionManagement {
}
}
rootProject.name = "Test"
rootProject.name = "Android-nRF-Toolbox"
include ':app'
include ':feature_csc'
include ':lib_broadcast'
include ':lib_events'
if (file('../Android-BLE-Library').exists()) {
includeBuild('../Android-BLE-Library')
}
include ':lib_theme'
include ':lib_scanner'