TECH

App for Fitness Device in React Native: How to Develop Guide from Technical Lead

Published: April 22, 2024

40 min read

The COVID-19 pandemic significantly changed our fitness habits, sparking interest in how to build a yoga mobile app, as now more people prefer exercising at home with their choice of equipment, including yoga, to maintain their physical and mental health.

In 2023, the worldwide revenue of the fitness app market was $5 billion, and it's projected to double this number by 2028. According to Statista, 833.3 million users worldwide have used fitness apps in 2023. This number is expected to grow to 1,048 million by 2028. This exponential growth is a compelling motivation to develop a complete React Native fitness app, don’t you think?

The fitness apps market continues to show a steady increase in users worldwide.

The fitness apps market continues to show a steady increase in users worldwide (image by Statista)

Today, more and more equipment for workouts has Bluetooth in place. This equipment can be used in various types of applications, including corporate fitness app development, to enhance the user experience and provide more personalized fitness tracking and guidance. Thus, you might be tempted to learn how to build a fitness tracking app that enables users to connect to those devices, track their progress, and control their fitness devices using a smartphone.

In this article, we’re going to take a look at how to create a wellness app with Bluetooth connectivity in React Native, find out the differences between major protocols, the ways to identify the protocols & the device itself, and, of course, to control it. Then, we’ll discuss the potential pitfalls each protocol has and how we can overcome them. Lastly, we’ll show you how we implemented a fitness app idea in React Native for our client.

We’re not going to have all the code written in this article because it’d take hours to read, and the actual ways you overcome the pitfalls might differ, depending on your tech stack and personal wishes.

This is a detailed guide for all developers involved in Bluetooth app development, particularly those connecting fitness devices to the React Native workout app or simply willing to learn more. Even though we write our code in React Native, most of the article code should be platform-agnostic, so you can skip the React Native part and get to the protocols themselves.

 
 

⚙️ BLE Hardware Details

We've already described how Bluetooth works in our blog. Please take a look to revise your knowledge.

To keep it simple, we have a lot of different BLE devices. Each of them has unique services, descriptors, and characteristics to work with. Let’s take a closer look at this structure:

Example of BLE device structure

BLE device structure (shots from O’Reilly)

 
 

Device

The device has a few important fields, which could help us working with it:

Name

You can show the name somewhere on your UI for the users. You might also be tempted to filter all the devices by name only, but it's not the best way, especially for fitness devices. In this industry, the devices are made by a lot of different manufacturers, and they might give them any name they want. Thus, be careful with it.

ID

It is a unique ID between the phone and the fitness device. It doesn't match between two different phones. However, you can save it locally so that the next time you see a device with this ID, you understand it's the device you need.

 
 

Characteristics

What is important for us to correctly manage the Bluetooth state is understanding how to work with Characteristic — the main gateway for communication between Client (our iOS and Android app) and Server (BLE device). The device might have a lot of different characteristics, and they contain a few important fields for us:

 

  • UUID

That's the stable UUID, that you can use to distinguish characteristics among others. It always has the very same form, and only 4 symbols ({XXXX}) differ in every characteristic: 0000{XXXX}-0000-1000-8000-00805f9b34fb

One of the most widely-used UUID is 0000fff1-0000-1000-8000-00805f9b34fb. However, keep in mind that if you find a BLE device, which has this characteristic UUID — it doesn't always mean it's the device you’re looking for. Thus, you might end up connecting to unnecessary devices just because they have the very same UUID.

 

  • isWritableWithoutResponse

This field determines whether we can send data without the response (characteristic wouldn't let us know when it processed the command). If true, we can call writeWithoutResponse.

 

  • isWritableWithResponse

This field determines whether we can send data with the response(characteristic would let us know when it processed the command). If true, we can call writeWithResponse.

In React Native, .writeWithResponse(data) returns Promise<Characteristic>

 

  • isNotifiable

This field determines when we can receive notifications from the characteristic (for instance, for some devices, we receive a real-time update that the workout is in progress). If isNotifying is true, we can subscribe to the monitor function and pass our handler to it:

  • isReadable

This field determines if we can read the characteristic's value. Some characteristics don’t notify us of any changes, however, we still want to get data. In this case, if isReadable is true, we can at some point call .read()

 

  • Value

It's probably the most important field for characteristics. It contains a base64 string, which could be parsed. Whenever we call any .write, underneath the characteristic changes the value. Whenever we call the .read, we receive the value itself.

 
 

Characteristic value

It doesn't matter whether we write or read data, we work with Characteric's value which is a base64-string representation of Uint8Array.

To make transformations we use base64-js:

In case we need to write data for Characteristic, we need to send base64 string — we use base64.fromByteArray ([0, 12, 30, 50, ...])

In case we received a notification (or called a read) and we need to work with the Characteristic value — we’ll use base64.toByteArray (characteristic.value)

What byte array we send in, and how we parse the response — heavily depends on the exact device and protocol it works with.

Understanding the complexity of BLE hardware is crucial when estimating the cost to make a Fitbit or any similar fitness app, as device connectivity can add complexity to the fitness and workout app development process.

 
 

🛠 How to Setup Bluetooth/BLE in React Native

What about Expo? As of autumn 2024, Expo doesn’t offer you to work with BLE. To make sure your app can communicate with Bluetooth, you need to eject your workout app and continue as a bare React Native app.

As for now, we suggest using react-native-ble-plx as the best solution for making the connection between a React Native app and a BLE device. Please note that later on, the library might differ or we might have another competitor. Though, it shouldn’t change the logic of how we approach the task.

Please follow the installation guide for react-native-ble-plx:

  • For iOS: don’t forget to add BLE capabilities to your Xcode project;
  • For Android: react-native-ble-plx don’t point out the differences in your AndroidManifest since Android 12. Still, you need to add a few permissions and explicitly require them.

 
 

Our requirements

When working with Bluetooth, we have a lot of things to cope with:

  • listeners (device connection/disconnection/subscription to data change);
  • scanning (finding new devices, having the ability to connect to them);
  • state management, etc.

Due to the hardware limitations, we want scanning to run only once for our React Native fitness app. Because if we stop-start scanning in the application, we end up in a situation where we break the Bluetooth connection.

Additionally, we want our listeners to always be up-to-date. We also want to avoid possible race-conditions and issues with state, when updating it and having a new value only in the next render, which could break the app in this render.

Having these 2 ideas in mind, we understand that React doesn't really work well with it. States would lead to endless re-runs of the scanning process. Our listeners wouldn't be up-to-date, and there may be a lot of other possible issues.

Technical requirements for fitness device integration

We had some strict requirements for fitness device integration (image by Jordon Cheung)

Of course, we could "hack" React: either use the refs all the way or just make all the dependencies empty. However, such manipulations make the code very hard to read and difficult to maintain since any possible change could break the way we work with it.

The best solution we see is having a special class, which handles all the hardware computation and inner management, and takes a few dependencies to update React state. We also create a helper hook, which wraps the class, and then, we make sure we can work with the dependencies and have state changes.

Let's take a look at how we can do it:

We'll use basically the same approach across all modules we create in our fitness app React Native.

Now, as we've talked about some theoretical structural things, let's get to handling different important cases.

 
 

Enabling Permissions

Permission request differs for both iOS and Android:

  • For iOS, the permissions are automatically asked. There’s nothing extra you need to do on your side.
  • For Android, instead, you need to ask for a few permissions explicitly. Until Android 12, it was only ACCESS_FINE_LOCATION. Right now, they also need BLUETOOTH_SCAN and BLUETOOTH_CONNECT. For instance:

 
 

Permissions: validating if users turned on Bluetooth

Sometimes it might be important to understand if the users actually have Bluetooth/location on, so you could prompt them to do so.

You can use the react-native-connectivity-status module for it.

Thus, our useBle code would look something like this:

 
 

Permissions: react to the user turning on Bluetooth or location

Imagine a user got to your React Native gym app without location/Bluetooth turned on the mobile device. You show them an alert, saying they need to turn it on. Then we need to enable the scanning back and "unlock" the Bluetooth from the user.

We can use react-native-connectivity-status for it as well:

Now that we've covered all the possible permissions and hardware issues, related to Bluetooth or location (on Android) being unavailable, let's move on to the next topic — device scanning.

 
 

Scanning

To perform scanning, we first need to create a react-native-ble-plx manager:

We need to create it only once, right at the start of the React Native fitness app development with BLE mobile app development services, because if we created it multiple times — Bluetooth wouldn't work well.

We've already discussed how we want to structure our scanning — we do it inside a separate class. The scanning will start only once, and all the outer dependencies will be replaced using refs. We believe it’s the best approach, however, if you'd like to keep it all inside the React hook (which we don't recommend doing), please take care of the following aspects:

  1. Before calling the next startDeviceScan (because, for instance, dependencies changed), we need to call .stopDeviceScan. If we don't do it, we fall into a memory leak.
  2. If we call multiple start-stop-start-stop device scanning, your app might end up not being able to scan at all.

If you're not careful with the dependencies, you might have too many dependencies and .startDeviceScan would be excessively triggered. You can't afford it, and you need to be careful with it in your React Native workout app to make sure your Bluetooth connection is stable.

Scanning can be tricky but we know how to handle it

Scanning can be tricky but we know how to handle it (image by Murat Gursoy)

What can you do?

  1. Reduce the number of outer dependencies.
  2. If you have a state and you need to update it — consider using setState(prevValue => newValue) syntax because in this case, you don't need extra dependencies with useEffect.
  3. In case you have a state and you really need a previous value, consider using useRef. It doesn't lead to rerenders, and you don't need to add it to your useEffect dependencies.

Now, let's get back to how we could handle it as a separate class. We need to keep a few things in mind:

  1. We need to let our React Native fitness app know something has gone wrong.
  2. We need to update our state from our class.

addItemToList in the example is a custom function, which makes sure we have no duplicates.

Please note that these 2 functions (changeOnSyncDevices, changeOnBleError) are custom changeOn functions, which are updated within useEffect. We need it because the dependencies might change, and it won't affect the constructor. We need custom useEffects to make sure we can update the handlers and listeners as we progress inside the React Native fitness app.

You might've noticed that we pass the allowDuplicates here. We need it for a corner case. If we don't pass it, iOS wouldn't scan the same device if, for instance, you connected it, then disconnected, and then connected once again. iOS would consider it as the same device ➡️ it wouldn't let us know it's reconnected.

However, with this code, we might fall into the case when we connect to the same device a lot of times. To make sure it doesn't happen, we can add a field to the class, responsible for storing, whether a device is connected with it or not. Also, we need to handle device disconnections as well to make sure we don't store any non-available devices.

You can apply any additional logic you want regarding scanning/connection. Just one thing to keep in mind — avoid extending your dependencies.

Looks great!

 
 

Navigating between screens

Inside your React Native gym app, you'll often see a few screens that require Bluetooth connection and work with devices — and a few ones that don't, which could be an important factor in your IoT project cost estimation. Thus, you might be tempted to start all the scanning operations on specific screens because it eases the process of retrieving/showing the data.

We wouldn't advise doing it because, once again, multiple scan-stops might break scanning at all. Therefore, it'd be better if you wrapped all your code inside a context. Yet, you should somehow understand whether you should actively scan right now or not. You can use another isActive field inside a class. To make sure it's up to date, you can use the listener updates within our hook.

Using this code above, on every screen you call useBleContext, active would be set to true, and all the scanning would process automatically. Once you leave this screen or go to another tab, active is set back to false, and no scanning happens. For more information on managing Bluetooth connections in your app, check out our Web Bluetooth tutorial.

 
 

🗂 Major Fitness Protocols: Similarities & Differences

You can treat protocol as a special language for communicating between your complete React Native fitness app and BLE device. It determines a few things:

  1. What you can do with the device.
  2. How you can accomplish your goals.
  3. Characteristics you’ll need to work with.
  4. When we need to send commands (basically call .write() with some data).

In our guide, we'll talk about 3 different protocols used when working with fitness devices and how they can influence the health and fitness app retention rate:

Let’s first talk about each of them in general, and then learn how we can seamlessly build them into the React Native workout app, avoiding extra pitfalls and headaches.

 
 

FTMS Protocol

FTMS Protocol seems to be a protocol with the biggest number of available features. Here are some of the most important ones:

Few characteristics to write to

Change level characteristic(2ad9)
Change pause/resume characteristic, connect characteristic (2ad3)

Receives workout changes via notification

We monitor device characteristics and receive real-time updates. We don't need to do anything to receive a notification

Has a characteristic to read from the device’s general data

For example, whether it has an incline, resistance management, and how many levels are available (2acc)

Enabled subscriptions

Using a few characteristics you can subscribe to receive info about device/current resistance/whether the workout is started

Big number of situations when we can send a request:

1️⃣ Establish a connection
2️⃣ Pause the workout
3️⃣ Resume workout
4️⃣ Change resistance/target speed
5️⃣ Send user info

Has a lot of characteristics to subscribe to receive workout info — each for every type of device

Rower (2ad1)
Treadmill (2acd)
Bike (2ad2)
Crosstrainer (2ace)
Stepper (2acf)

Last but not least — how we parse the received characteristic value. Imagine we received a notification from ROWER (2ad1). As always, it's a base64 string which we can then parse to an array of numbers. However, what we receive in an array depends on the first two bytes:

[CONTROL_BYTE_0, CONTROL_BYTE_1, VALUE_BYTE_0, VALUE_BYTE_1, ....]

That's how it could look: [12, 0, 13, 6, 5, 3];

The important question for us is: how do we understand what [13, 6, 5, 3] stands for?

It depends on the values we see in the first two control bytes. To work with it properly, we need to convert CONTROL_BYTE_0 and CONTROL_BYTE_1 to the binary system — [1100, 0]. Then, we need to take a look at each bit and check its value. The way you can do it is described in the documentation. Here’s a quick example!

For instance, our protocol says that if on bit 0 we have value 1 — we have 2 bytes of speed:

  • In case we have value 1 on bit 1 — we have 2 bytes of distance.
  • If we have value 1 on bit 2 — we have 2 bytes of calories.
  • Finally, in case we have value 1 on bit 3 — we have 2 bytes of energy.

Remember that we take a look at the binary string in reverse. So when we want to know bit 1 - we need to do "0011"[1] instead of "1100"[1]

Let's sum up what we have for now:

"0011"[0] == 0

➡️

we don't have speed

"0011"[1] == 0

we don't have distance

"0011"[2] == 1

we have 2 bytes of calories

"0011"[3] == 1

we have 2 bytes of energy

Then we go step by step and take a look at our values — [13, 6, 5, 3]. Because the first two bytes to take would be the "calorie" bytes — the calories are 13 + 6. Then, we have 2 bytes of energy, and we start not from the beginning, but from the end of the previous take. It'd be 5+3. Simply speaking, we shift our values.

RESPONSE: speed - not set, distance - not set, calories - 19, power - 8

However, if on CONTROL_BYTE_0 the value is 10 instead of 12 — we wouldn't have calories. If we turn 10 to the binary we receive 0101, which means that instead of calories we have distance:

RESPONSE: speed - not set, distance - 19, calories - not set, power - 8

Note: that’s just a basic example. Most of the time, the output value is not a sum. You’d rather have bytes summed with some multipliers. Therefore, this example is simplified on purpose.

 

To sum up:

So what functionality do we expect from our protocol?

  1. We need to be able to .write() to different characteristics under different circumstances.
  2. Read from one characteristic under some circumstances.
  3. Subscribe to different characteristics, and correctly parse the response, shifting the values.

 
 

Delightech

As for now, it seems to be a very niche protocol. You can access its documentation here. Despite offering a range of important features, their number is quite limited, yet, different from the FTMS one.

Let's take a look at the main capabilities of the protocol:

  • It has a single characteristic for both writes and monitors (fff1). All the requests should be correctly prefixed so that we understand we work with Delightech.

  • Even though we have one characteristic for all Delightech operations, we have different goals for writing. We distinguish the needed goal by our payload:

    • When working with the info command, we pass 64 as the first byte of the payload. This command includes information on whether the workout has started; connect, pause workout; user info (like weight), etc.
    • When it comes to workout command, we need this one to "ask" our characteristic to send us a notification with an updated user payload — we pass 32 as the first byte of the payload. We usually run this command every second — it means that every single second we’ll receive a notification. Yet, we can change this number to any other. For example, we can send this command every 10 seconds, and, thus, receive a notification every 10 seconds.
  • Notification is sent only when we send the write command. Basically, instead of subscribing to a real notification, we ping our BLE device every second.

  • To sum up, there are only 3 possible situations when we send requests to a BLE device:

    • Establish a connection with a mobile app.
    • Send user info.
    • Every second send request with information about the workout (within this request we also control whether the workout is active or not, and change the resistance/target speed).
An example of the popular fitness app in the world

Each fitness app should show the relevant information about the workout (image by Nexique)

Let’s talk a bit about parsing the values. Instead of shifting our values, depending on the first bytes, we ALWAYS expect them in the same positions. For instance, if we expect calories — they'll always be sent as 6th byte, no matter whether we have distance or speed or any other field.

However, we have some exceptions here. Some fields might be empty for particular devices (for instance, when we run — we don't receive any rpm; or we won’t receive heart rate if we don’t use a heart rate sensor). We understand what fields are sent by taking a look at the "CONTROL_BYTE". Imagine the following notification:

[CONTROL_BYTE, SPEED_HIGH, SPEED_LOW, CALORIES_HIGH, CALORIES_LOW, DISTANCE_HIGH, DISTANCE_LOW]

As you can see, we always receive the speed, calories, distance, etc. in the same position. However, if, for instance, bit 5 of CONTROL_BYTE is 1 — we omit CALORIES_HIGH, CALORIES_LOW. We don't take a look at them, at all.

The last thing — we can take a look at the notification we receive, and based on it, understand what type of device we work with (bike/treadmill/rower/etc). Some fields also depend on what type of device we work with.

 

To sum up:

  1. We need to be able to .write() to the same characteristic under different circumstances (with different values).
  2. We don't .read() from any fields.
  3. We need to subscribe to a single characteristic, and every second trigger .write() to receive a notification. We don't shift any values, but read them, depending on CONTROL_BYTE.

 
 

Yeekang/Fitshow/Thinkfit

Documentation on this protocol can be found here. However, it’s in Chinese so there might be some issues understanding it. English simplified version can be found here. As before, let’s review some key points of the protocol.

First thing first, we have 2 different characteristics to work with. All the values we send/receive from characteristic should be prefixed with 2 first bytes — [0x02, 0x42-0x45]. The second byte marks the exact command we want to send/parse.

Also, we have a special characteristic to write to (fff2). Even though for all the writes we have a single characteristic, we have multiple possible occasions to send write:

  1. Connect to the React Native fitness app.
  2. Write a command to receive data about the device (available fields, max resistance, etc).
  3. Send user's data (weight, height, etc.).
  4. Start workout.
  5. Pause workout.
  6. Resume workout.
  7. Stop workout.
  8. Change resistance/target speed.
  9. Every single second send 2 requests to receive a notification about the workout.
The visual example of the BLE fitness app

The connection of the fitness app with the BLE workout device keeps track of the workout performance (image by Alexandr Fesun)

When using this protocol, we don’t have any characteristics to read and have 1 to subscribe to notifications.

Workout notification is sent only when we send the write command, just like in Delightech. Basically, instead of subscribing to a real notification, we ping our BLE device every second.

Value parsing also works the same way as Delightech: instead of shifting our values, depending on the first bytes, we ALWAYS expect them in the same positions. For instance, if we expect calories, they'll always be sent as the 6th byte, not taking a look at whether we have distanceб speed or any other field.

Yet, there are a few differences worth mentioning:

  1. As you remember, we send 2 requests, and we receive 2 notifications regarding the workout. They're prefixed with different bytes. For instance, we'll always have speed if the notification is prefixed with [0x02, 0x42, 0x02]. No need to look at the specific bits.

  2. We can't know the exact device. And the exact device doesn't change the response we receive.

 

To sum up:

  1. We need to be able to write() to the same(fff2) characteristic a few times a second while the workout is in progress. Also, we have quite a few other occasions where we call this characteristic.
  2. We don’t .read() from any fields.
  3. We need to subscribe to a single characteristic, and every time we trigger .write() to receive a notification. We don't shift any values, but read them, depending on a few first bytes.

 
 

📱 Top Things to Keep in Mind when Enabling Multiple Protocol Support

Since now we know key points about each of the protocols, we want to make our fitness app React Native efficiently work with any of them. To make it happen, we need to work on a few crucial things:

  1. We need to understand what kind of protocol we work with right now.
  • We can't understand it by device's name (because each manufacturer might come up with its own name).
  • We can't rely on users knowing the protocol. 99.9% of the users don't know their protocol, and the app shouldn't break if the user misclicks.
  • Therefore, we should somehow understand the protocol ourselves.
  1. We need to send specific write commands in specific situations, attaching maybe dynamic values (for instance, resistance, weight, or something else). These commands are mostly different for each protocol.

  2. We need to parse the response and correctly understand what exactly our device has sent to us. It depends on the protocol because the responses have nothing in common.

Let’s take a closer look at these steps!

 
 

Step 1: Understanding what protocol we work with

Each protocol has something unique, which could help us highlight it among the devices.

 

  • FTMS Protocol

The protocol uses very unique identifiers. For instance, its bike- or rower-specific identifiers aren’t used anywhere else. Thus, if we receive a notification from a characteristic or read a characteristic with some FTMS identifiers — we can be sure it's FTMS.

 

  • Delightech & Yeekang/Fitshow/Thinkfit

Both of these protocols use the same identifier. Moreover, this UUID (fff1) is widely used among other Bluetooth devices. Since we can't rely on the identifier itself, we should look at the value we receive — protocols’ responses are always prefixed with the same fields.

We can then basically write a protocol interface and basic implementation:

Here we state that the protocol might have an array of possible characteristic UUIDs, using which we can determine the specific protocol we work with. Also, the protocol might have an array of possible characteristic UUIDs or an array of prefixes for the protocol. Using either of them, we also can determine the protocol.

Also, we can specify protocol.helper.ts, which would take a Protocol, and provide us with a way to understand the device is of Protocol:

Later on, we can use the ProtocolHelpers inside our AppDevice to know if the device is of Protocol:

Having done this, we'll always have an available #currentProtocolHelper inside our AppDevice, which would make all the data parsing / .write() sends. If we don't know the #currentProtocolHelper — it means we weren't able to understand what protocol we work with. In this case, we can show some errors with a timeout.

 
 

Step 2: Sending some particular write commands in specific moments

First thing first, let's agree on what we mean by “particular commands”. In fact, it's just a write command:

  • Sent to a specific characteristic.
  • Sent with specific values (might be dynamic).
  • Sent at a specific moment.
  • Some commands can be sent only for some protocols.

A list of possible commands we might send consists of:

Each of these CommandType is used at least in one or more protocols. Inside our AppDevice, we specify our intention to send some information to the protocol.

However, that’s where the tricky part happens. To send a command, we need to understand what protocol to send it to. At the same time, some protocols (Delightech and Fitshow) don't respond until we send the CONNECT command. How do we handle this chicken and egg situation?

Well, we could send the commands to all the possible protocols if their identifiers are available. So if we send the FTMS-like data to Delightech, they’d just ignore it. However, if we send Delightech data to Delightech — we receive a Delightech response and, thus, can use ProtocolHelper afterward. Simple as that!

Let's firstly update our protocol to let us know what command it can handle:

Here, for a Protocol, we determine commands by creating a record, that contains all the CommandTypes we want to send to the protocol. We may not want to send one of the commands, that’s why it's Partial. We also may send a few requests within one command (for instance, for Yeekang/Fitshow/Thinkfit every second we send 2 write requests to receive a workout notification).

Command consists of the UUID of characteristic to send to (for instance, fff1), and an array of values. Each value can have a few different fields:

Hardcoded fields

For instance, within the write command, we want to have 0x02 at the first byte, and 0x40 at the second byte

Dynamic fields

Can be target speed, resistance, weight, height, whether a workout has started, etc. We'll talk a bit later on how to pass them

Checksum fields

For some protocols, we want to have at some byte a sum of all the bytes before. That's why we have a separate field. We also pass the starting index because the checksum might not always start from the first byte (depends on the protocol)

Here's an example of how it can look:

Then, we need to map these commands for every protocol to a definite array of numbers, which we could later use with Bluetooth:

And, finally, we can use it inside the AppDevice. Remember that if we don’t know the protocol for sure and send a command — we basically send it to every protocol.

You should be careful and send a lot of events with a delay. Android doesn't handle multiple .writes at a time well. The delay should be at least 200ms.

At this point, we have everything we need to work with different devices. We learned how to:

  • send data to all the possible protocols;
  • based on the response, understand what protocol we work with;
  • correctly send data to the protocol we work with.

For now, there’s one last piece left to make it work (except for UI, state management, and all the unnecessary things 😉). Let’s figure out how to correctly process the workout response!

 
 

✅ How to Process Workout Responses

First of all, let's agree on our goals. The idea of this article is to teach you:

  1. Receive a notification with the workout data — done ✅
  2. Parse notification with workout data (burned calories, distance, speed, RPM, heart rate) — we're here 🙂
  3. Store the workout data somewhere and maybe show the UI changes to the user.
It's important to correctly parse notifications, considering all the differences and similarities

It's important to correctly parse notifications, considering all the differences and similarities (image by fajr fitr)

To correctly parse notifications, we need to understand what distinctions and similarities notifications of different protocols have:

  • Since we receive data under different characteristics, they have different IDs. Thus, we need to have it configured somehow.

  • Each field (speed, calories, distance, etc.) might take up to 4 bytes. The output value would be a sum of all these bytes with some multipliers. For instance, we can have 2 bytes for the distance — [1, 250]. The first one will have a *1000 multiplier, and the second byte — a *1 multiplier. So to find how far we’ve got, we need to sum 1 * 1000 + 250 * 1 => 1250meters. We need to be able to mark multipliers inside our code.

  • Different protocols can send data in either decimal or hexadecimal system. We need to make our React Native gym app handle the field.

  • The last important thing — where and when you should find the exact byte. As you remember, it differs a lot between Fitshow+Delightech and FTMS. In Fitshow+Delightech, we can always find the value on the same byte(s), if we have the required bytes/bits on the first few indices. In FTMS, the position of, for instance, distance, not only depends on the required bytes/bits for the distance, but also on the bytes/bits of the previous fields.

Talking about Fitshow/Delightech, the solution is straightforward here. We need to add an additional field, telling which bytes should satisfy the response before we save the new value. For instance, we can add a separate BytePosition interface. It might also take the array of bits within the byte we need to satisfy the rules:

The field might look something like this:

Here we state that we want to save a new speed, if we received a notification:

  • from characteristic 'fff1';
  • when on the 0 index the value is 0x20, and on the 1 index the bits are 1 on 2 index, and 0 on 3 index.

Also, we want to have this field:

  • in a decimal format;
  • take it from the index 4th byte;
  • take 2 bytes, and multiply them with [10, 1].

Let's take a look at the example of how it could look:

{value: [0x20, 0x8, X, X, 10, 10, X, X], uuid: 'fff1'}

We wouldn't save it because the 0x8 has the 0 on 2 index and 1 on 3 index

{value: [0x20, 0x4, X, X, 10, 10, X, X], uuid: 'fff1'}

We would save it because all the rules match. Using the multipliers, the new speed would be 110 km/h. Pretty fast, huh?

{value: [0x20, 0x4, X, X, 10, 10, X, X], uuid: 'fff2'}

We wouldn't save it because of the UUID mismatch

It works just fine for Delightech and Fitshow as it allows to correctly understand the values. However, developers have identified a few flaws with the FTMS. As you might remember, we have value shifts for the FTMS. It means that, for instance, if we have speed, we will have the distance at byte 4. However, if we don't have speed, we will have the distance at byte 2. And it goes crazier the more combinations we have.

In React Native development, there are 2 possible solutions:

  1. We can set up a special class/function to handle the offsets here.

For instance, we pass the characteristic array, the UUID, and all the fields we have, and it goes one by one in an array (starting with the first value), increasing the startingByte for all the next ones. Thus, we'll add a separate field sholudShift or something like this. It's a working solution, that doesn’t have any serious downsides.

However, what I don’t like about it is that we lose the flexibility and divide our parsing into 2 different pipelines, with only 1 flag changing the behavior. In case we have another protocol with something in between — it might not work that well.

  1. Alternatively, we can have a special mapper function that makes all the offsets in the build-time and leaves us with thousands of different matchRules, depending on what we have.

For instance:

  • If we have no other values, look at the speed at byte 2.
  • If we have only distance, look at the speed at byte 4.
  • If we have distance and calories, look at the speed at byte 6.
  • If we have distance, calories, and RPM, look at the speed at byte 8.

And we'll have several rules like these for each field. Then, during the parsing, we'd just find the appropriate matchRule, and if we do find any — look at the startingByte and make the computations.

With this implementation, our code for Fitshow, Delightech, and FTMS looks basically the same. The only difference is that FTMS is much bigger 🙂

You can choose either of these solutions, but we used the second one, and didn't see any issues with it so far.

Let's take a look at how our mapper could look:

Here we have a simple switch on whether the field should be offset or not. If yes, we go through the positions, adding 1 rule to the existing matchRules and increasing the startingByte. Basically, the situation when we have ONLY this field (for instance, the speed at position 2) changes to the situation where we have every possible combination of fields. For instance, speed, distance, calories, speed, and RPM. In this case, speed is at position 10.

Let's take a look at how our Protocol might look:

Using the deviceFields, you can understand what fields you can work with and save them once you understand the protocol. Let's finalize and see how it could work:

Here we introduce 2 new functions — getNewDeviceField and getNewValue. We use them to:

  • return the updated device field;
  • understand what matchRule to use;
  • make all the necessary math to find the value.

At this point, I think we have everything to make sure we are building a fitness app React Native that correctly processes all the protocols, saves the values, understands the protocols, and has no issues adding another protocol as it won’t change any line of the code. In fact, we could even bring the protocols to some outer API and load it on demand to lower our app bundle.

Still, there are quite a few protocol-related issues, which we'll briefly discuss in the next part.

 
 

Delightech

  • When you disconnect the device from your React Native fitness app — the device turns off. Yet, even though the device triggers all the "disconnect" events inside our code, it still has Bluetooth turned on for around 7 seconds. Moreover, the device behaves with our Bluetooth as if it was turned on — it means that it's again available for scanning. Default behavior would be as follows: we connect the device, disconnect it, receive the listener's calls, and then get notified once again that the device is available.

However, the disconnection process is already in progress. It means that the device will anyway NOT respond to our calls and won’t let us know it's off. Instead, we'll just behave with the device as if it's turned on, meanwhile it's not. The solution here is to have a delay between disconnecting and accepting the same scanned device.

  • It’s a normal thing for many users to pause a workout. During the pause, all the values are sent as 0. So when we resume the workout, it goes back from 0. It means that if we were working out for some time, for example, rode 1 km and then paused, after resuming we'll have 0 in the account.

The solution here is to catch the paused behavior and store the values which need to be added up in a special state. Later on, we sum up the values before and after the pause. You can add a custom isAccumulative field to DeviceField to make it reusable.

  • Don't send too many write requests to receive more real-time updates. If you send requests more often than once a second — your device will end up disconnected. One second is a preferred interval.

 
 

FTMS Protocol

  • You can automatically detect if the workout is paused. When the workout is paused, all the data is passed as 0. However, if in case of Delightech, when the workout resumes it stays at 0 and increases over time, for FTMS it’s brought back to the initial value when unpaused. It means that you should avoid these 0 values only when paused. As always, you can add an additional field to DeviceField.

  • Even though we have a separate characteristic for each device type, not all the manufacturers follow this rule. You might have a few notifications from different characteristics within one device. For instance, if you exercise on a bike, you might receive values from bike, rower, and cross-trainer characteristics.

  • FTMS doc doesn't really share the characteristic UUID with us. You can take a look at them here.

There are many more interesting things regarding FTMS. If you want to dig into it, take a look at this article and check the comment section as well. It helped us a lot to sort out a lot of questions.

 

Yeekang / Fitshow / Thinkfit

Don't send too many write requests to receive more real-time updates. If you send requests more often than once a second — your device ends up disconnected. One second is a preferred interval.

The only important difference from what we expected to see based on the docs — we noticed was the wrong data inside the protocol PDF file. To "obtain movement" (distance/calories/count/time) the protocol says the first bytes are [0x02, 0x43, 0x01]. However, it was [0x2c, 0x0b]. We're not sure if it's true for all the Fitshow/Thinkfit devices, but it might be useful to take a look at both prefixes and work with them.

 
 

🔄 Reverse Engineering: Getting BLE Logs Based on the Existing Apps

Right now you have all the information you might need regarding the architecture, setup, and protocols when creating a React Native gym app for fitness devices. The last thing to cope with is making the correct mapping for the devices. You can use the protocol, although it might not always be easy for you to understand what command follows what response.

If you have a device in place, you can connect using any existing fitness-related app (or using Sportplus 🙂) to your device, and record all the Bluetooth logs. Here's a short guide on how to do it.

In case you want to know more about the integration of BLE devices into your React Native fitness app, you can read our article dedicated to this topic:

You can, later on, go through the logs using Wireshark and understand all the Bluetooth commands you need to send and receive. You need to have a fitness machine for that and an Android device (not available on iOS).

In case you don't have either of that, we would be glad to share with you a list of our logs here.

 
 

🔧 To Sum Up: How to Create an App for Fitness Device in React Native?

Now, we'd like to explain how our developers apply these skills in practice. So, we’ll share our expertise in making a complete React Native fitness app for SportPlus. Recognizing the growing demand among customers to connect their equipment to mobile devices, the client tasked us with the creation of an app compatible with a variety of fitness devices.

Fitness mobile apps are more effective than web apps for workouts

SportPlus app enables smooth connection with fitness equipment (image by Stormotion)

Building a React Native workout app for fitness devices is a journey that encompasses various stages, from protocol validation and UX wireframing to frontend development and quality assurance. Drawing from our experience with SportPlus, we’ll provide insights into each step of developing such an application.

 
 

Step 1: Validate the Protocol

We decided to start with protocol validation since the success of the app lies in its ability to communicate effectively with the supported equipment. Initially expecting a single protocol, we soon discovered that the reality was far more complex, with 3 distinct protocols in play. However, we swiftly came to an effective approach.

Our solution? A generic protocol handler. This system enables the fitness application to identify and adapt to the specific protocols used by different fitness devices. This involved parsing unique identifiers and special data within characteristic values to identify the protocol, ensuring that the app interacts only with compatible devices.

Upon receiving a notification from one of the protocols, our application expects the equipment to belong to that protocol and operates exclusively with it. However, in cases where no response is received within a certain timeframe, we deduce that the device is not a compatible training machine and initiate a disconnection.

The generic protocol handler helps the fitness app find the right device by checking either characteristic UUID or characteristicArray. Let's summarize and see how it works in our case:

So, what sets our generic protocol handler apart?

  1. The emphasis on flexibility and adaptability. We understand the importance of future-proofing our application against evolving protocols and device standards. By doing so, we ensure that our application can seamlessly integrate with new workout equipment, without requiring significant redevelopment.

  2. The focus on versatility and reliability. The generic protocol handler is equipped with commands tailored to each of the three protocols we encountered. This means that even in scenarios where the specific protocol in use is unknown, our application can transmit data to all possible characteristics in the format expected by each protocol.

So, a desire for simplicity and maintainability guided our decision to implement a generic protocol handler. Rather than navigating a complex maze of if statements tailored to each protocol, we opted for a streamlined approach that promotes reusability and scalability. This abstraction shields our application from the intricacies of individual protocols, allowing for smoother integration and easier maintenance in the long run.

 
 

Step 2: Wireframe the UX

In order to prepare the relevant design of the fitness app React Native, we began UX wireframing. This crucial step involves creating a blueprint of the application's layout and navigation flow, laying the groundwork for intuitive user interaction.

Since the success of a fitness device application relies on UX, we prioritize wireframing to ensure that every aspect of the interface serves a purpose. By mapping out the various screens and functionalities, we gain valuable insights into how users will navigate through the app and interact with its features.

High-quality products, such as fitness apps, should focus on UX design

UX-wireframing of the fitness app ensures a seamless user experience (image by Stormotion)

During the wireframing phase, our focus is on simplicity and clarity. We aim to create a clean and uncluttered layout that allows users to easily access the information they need. By establishing clear navigation paths and logical screen transitions, we ensure that users can navigate the app with minimal effort.

In essence, UX wireframing serves as the foundation of the design process, laying the groundwork for a user-friendly and intuitive application interface.

 

Step 3: Develop the UX/UI Design

With the wireframe blueprint in place, we transition to UX / UI design, where we bring our concepts to life through visual aesthetics and interactive elements. This stage involves refining the wireframe layout and adding visual elements to create a polished and engaging user interface.

Understanding that technical functionality is just one piece of the puzzle, we prioritize UX / UI design to ensure that the application not only works well but also looks and feels appealing to users. Our goal is to create a seamless and immersive user experience that encourages users to engage with the app and achieve their fitness goals.

Central to our design philosophy is the concept of user empowerment. We aim to give users the tools they need to take control of their fitness journey, whether it's setting personal goals, customizing workout routines, or tracking performance metrics over time. Every aspect of the UI design is carefully crafted to improve usability and facilitate user interaction.

If you’d like to know how to create an engaging UI using React Native, you could read our article dedicated to this topic:

Also, we provide comprehensive services for building a personal training app from scratch, considering all your preferences.

Step 4: Initiate FrontEnd Development

With the foundation laid in protocol validation and UX / UI design, the next step was to bring our vision to life through frontend development. We transformed our design into functional components that seamlessly integrated with the underlying protocols and backend systems.

React Native development is more effective than Java or Javascript

The frontend development of fitness apps is the culmination of the discovery stage and UX/UI design (image by Stormotion)

A key focus during frontend development was flexibility and scalability. We designed our fitness application architecture to accommodate future enhancements and changes, ensuring that it could adapt to evolving user needs and technological advancements.

By following best practices and leveraging the latest tools and frameworks, we were able to deliver a high-performance application that met the client’s' requirements.

 
 

Step 5: Execute Quality Assurance and Testing

No application is complete without thorough testing to ensure its reliability and performance across various devices and usage scenarios. Our QA process encompassed such testing types:

1. Functional testing.

We conducted extensive functional testing to identify and address any bugs or issues that could impact the user experience. It’s the main type of testing we used to verify functionally, from logging into the account to connecting the equipment via Bluetooth.

2. Non-functional testing, which includes such types:

  • Performance testing. Performance testing was another crucial aspect, ensuring that our application could handle the demands of real-world usage without slowdowns or glitches.

  • Compatibility testing. We tested our application against a range of fitness devices to ensure compatibility and consistency. And ensured our app functions work correctly across different devices and environments.

  • Usability testing. Usability testing aims to assess the user-friendliness and intuitiveness of software for its target users. This testing type is indeed crucial for ensuring that fitness applications meet user expectations and needs. This type of testing demands a significant level of experience from QA engineers who specialize in testing such applications.

  • Localization testing. We verified that the system functions work well when adapted to different languages, cultures, or regions, ensuring a consistent user experience across diverse audiences.

Please note that each fitness product requires a unique approach and testing types that are selected depending on customer requirements and specifications of the product.

Ostap Shtypuk, QA Engineer @ Stormotion

At Stormotion, we're dedicated to finding solutions for every problem we encounter by exploring various approaches. For example, testing the active session flow of SportPlus app became more challenging due to the requirement of physical devices. However, we resolved this challenge by implementing mocked devices, enabling testers and customers to assess the features appearance and functionality, even without access to physical equipment.

Ostap Shtypuk, QA Engineer @ Stormotion

Certainly, except for the minor feature described above, we prioritized thoroughly testing the interaction between the equipment and the mobile application. In projects involving interactions between two devices, ensuring a stable connection and seamless operation is paramount. Additionally, it was crucial to test the connection of several pieces of equipment simultaneously. To verify such scenarios, we required multiple physical equipment or trainers to be present in one location.

The testing phase ensures a smooth connection between the fitness app and the BLE equipment

The testing phase ensures a smooth connection between the fitness app and the BLE equipment (image by Stormotion)

Testing was crucial in SportPlus app development, like any other fitness app with connection equipment to mobile devices. Apart from confirming functionality, we prioritized ensuring Data Accuracy and Security. The data collected and displayed in the application (e.g., step count, calories burned, heart rate) must be accurate and reliable. Also, such apps often store users' personal data, such as name, address, physical metrics, health information, etc, so that's why we needed to identify potential vulnerabilities and ensure appropriate data protection measures are in place.

As a result, SportPlus has a user-friendly fitness app, and we’ve got one more happy client. So, with careful planning and diligent execution, any development team can create a complete React Native fitness app that resonates with users and drives business growth. Understanding the cost of fitness apps is also crucial to ensure the project's financial feasibility and long-term success.

To better understand what to expect, you can explore the price of Peloton app development, which ranges from $92,050 to $145,000 depending on features and platform choices.

 
 

👂 Takeaways

As you see, React Native offers a powerful and efficient way to develop fitness mobile applications for BLE devices that work seamlessly across iOS and Android. Let’s summarize the key takeaways:

  • The recommended solution for connecting a React Native app to a BLE device is using react-native-ble-plx. To manage Bluetooth permissions, scanning, and navigation between screens, you should use a separate class, with a helper hook wrapping it for state updates.
  • Each protocol — FTMS, Delightech, and Yeekang / Fitshow / Thinkfit — acts as a specialized language for app-device communication. While FTMS offers extensive features, Delightech simplifies with single-characteristic operations, and Yeekang/Fitshow/Thinkfit ensures consistent parsing.
  • Handling multiple protocols requires understanding unique identifiers, defining write commands, and employing parsing strategies. Notably, Delightech devices need a disconnect delay, FTMS protocols may reset values during pauses, and caution is advised with Yeekang / Fitshow / Thinkfit write requests.
  • Implementing a generic protocol handler may offer a streamlined approach, promoting flexibility, adaptability, and scalability across various protocols.

By understanding the core concepts of Bluetooth communication and integrating with relevant fitness device protocols, you can build a feature-rich React Native fitness app.

If you’re looking for a Tech Partner to integrate your application with a fitness device of your choice, drop us a line and we’ll see how we can help you!

Contact Us!

Questions you may have

Take a look at how we solve challenges to meet project requirements

How do you ensure code quality and maintainability in React Native projects?

We ensure code quality and maintainability in React Native projects through regular code reviews, automated testing, adherence to coding standards, and modular architecture design. Continuous integration and deployment pipelines also help maintain code quality by catching issues early and ensuring consistency across the codebase.

How do you approach cross-platform compatibility issues in React Native apps?

Cross-platform compatibility in React Native apps is achieved by utilizing platform-specific modules and APIs when necessary, employing responsive design principles, and testing on various devices and operating systems. Additionally, we rely on libraries like React Native Elements and React Native Paper to provide components that adapt to different platforms.

Can you explain your experience integrating third-party services and APIs into React Native apps?

Integrating third-party services and APIs into React Native apps involves thorough research, understanding API documentation, and utilizing community-supported packages. We prioritize well-documented and well-supported APIs, ensure secure authentication mechanisms, and implement error handling and fallback strategies for robust integration.

Can you suggest any features that could give our app a competitive edge in the current fitness app market?

We recommend incorporating features like personalized workout recommendations, social sharing functionalities for community engagement, and gamification elements such as challenges and rewards. Seamless integration with different BLE devices for comprehensive tracking will also help you stay ahead of the curve.

How do you ensure a seamless user experience across devices and operating systems?

We meticulously follow platform-specific design guidelines and optimize performance through efficient rendering techniques and caching strategies. Extensive testing across a myriad of devices and OS versions ensures that our app performs flawlessly regardless of the user's setup.

How do you ensure the app complies with data protection laws (such as GDPR or HIPAA) relevant to the fitness industry?

We prioritize robust data encryption and security measures to safeguard user information. Obtaining explicit user consent for data collection and processing is a cornerstone of our privacy practices. Regular audits and updates to the privacy policies ensure ongoing compliance with stringent data protection laws such as GDPR and HIPAA.

Can you discuss your experience with implementing secure payment processes for in-app purchases?

Implementing secure payment processes for in-app purchases entails utilizing trusted payment gateways, encrypting sensitive user data during transactions, and adhering to PCI DSS standards for handling payment information. Additionally, implementing two-factor authentication and fraud detection mechanisms adds an extra layer of security.

Read also

How can we help you?

Our clients say

Stormotion client David Lesser, CEO from [object Object]

They were a delight to work with. And they delivered the product we wanted. Stormotion fostered an enjoyable work atmosphere and focused on delivering a bug-free solution.

David Lesser, CEO

Numina