feat(components): ActionButton stopAlert + typography type fixes

This commit is contained in:
fightrate-bot committed 2026-06-16 01:50:26 -03:00
1 parent 2c51fd2623
commit bbe2919c2c
89 files changed
+75993 -9961

No files matched your search

+12 -3
View File
@@ -45,7 +45,7 @@ react {
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
hermesCommand = "D:/h/node_modules/react-native/sdks/hermesc/win64-bin/hermesc.exe"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
@@ -77,14 +77,23 @@ android {
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.fightratern"
namespace "com.healthtracking.fightrate"
defaultConfig {
applicationId "com.fightratern"
applicationId "com.healthtracking.fightrate"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
splits {
abi {
enable true
reset()
include 'arm64-v8a'
universalApk false
}
}
signingConfigs {
debug {
storeFile file('debug.keystore')
+16
View File
@@ -1,6 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
android:name=".MainApplication"
@@ -23,5 +34,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".capture.FightRateCaptureService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>
@@ -1,4 +1,4 @@
package com.fightratern
package com.healthtracking.fightrate
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
@@ -1,4 +1,4 @@
package com.fightratern
package com.healthtracking.fightrate
import android.app.Application
import com.facebook.react.PackageList
@@ -6,6 +6,7 @@ import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.healthtracking.fightrate.capture.FightRateNativePackage
class MainApplication : Application(), ReactApplication {
@@ -14,9 +15,8 @@ class MainApplication : Application(), ReactApplication {
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
},
add(FightRateNativePackage())
}
)
}
@@ -0,0 +1,358 @@
package com.healthtracking.fightrate.capture
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
class FightRateCaptureModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private val store by lazy { FightRateCaptureStore(reactContext.applicationContext) }
private var receiverRegistered = false
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
FightRateCaptureService.ACTION_SAMPLE -> emit(EVENT_SAMPLE, sampleFromIntent(intent))
FightRateCaptureService.ACTION_STATUS -> emit(EVENT_STATUS, statusFromIntent(intent))
FightRateCaptureService.ACTION_DEVICES -> emit(EVENT_DEVICES, devicesFromIntent(intent))
FightRateCaptureService.ACTION_METADATA -> emit(EVENT_METADATA, metadataFromIntent(intent))
}
}
}
override fun getName(): String = NAME
override fun initialize() {
super.initialize()
registerReceiverIfNeeded()
restoreMonitoringIfNeeded()
}
override fun invalidate() {
unregisterReceiverIfNeeded()
super.invalidate()
}
@ReactMethod
fun addListener(eventName: String) {
registerReceiverIfNeeded()
}
@ReactMethod
fun removeListeners(count: Int) = Unit
@ReactMethod
fun startMonitoring(deviceId: String?, deviceName: String?, promise: Promise) {
runCatching {
FightRateCaptureService.startMonitoring(reactContext.applicationContext, deviceId, deviceName)
promise.resolve(snapshotMap())
}.onFailure { promise.reject("FIGHTRATE_MONITOR_START_FAILED", it) }
}
@ReactMethod
fun startCapture(deviceId: String?, deviceName: String?, promise: Promise) {
startWorkout(deviceId, deviceName, promise)
}
@ReactMethod
fun startWorkout(deviceId: String?, deviceName: String?, promise: Promise) {
runCatching {
FightRateCaptureService.startWorkout(reactContext.applicationContext, deviceId, deviceName)
promise.resolve(snapshotMap())
}.onFailure { promise.reject("FIGHTRATE_START_FAILED", it) }
}
@ReactMethod
fun connectToDevice(deviceId: String, deviceName: String?, promise: Promise) {
runCatching {
FightRateCaptureService.connect(reactContext.applicationContext, deviceId, deviceName)
promise.resolve(snapshotMap())
}.onFailure { promise.reject("FIGHTRATE_CONNECT_FAILED", it) }
}
@ReactMethod
fun stopCapture(promise: Promise) {
stopWorkout(promise)
}
@ReactMethod
fun stopWorkout(promise: Promise) {
runCatching {
FightRateCaptureService.stopWorkout(reactContext.applicationContext)
promise.resolve(snapshotMap())
}.onFailure { promise.reject("FIGHTRATE_STOP_FAILED", it) }
}
@ReactMethod
fun stopMonitoring(promise: Promise) {
runCatching {
FightRateCaptureService.stop(reactContext.applicationContext)
promise.resolve(snapshotMap(forceIdle = true))
}.onFailure { promise.reject("FIGHTRATE_MONITOR_STOP_FAILED", it) }
}
@ReactMethod
fun getSnapshot(promise: Promise) {
runCatching { promise.resolve(snapshotMap()) }
.onFailure { promise.reject("FIGHTRATE_SNAPSHOT_FAILED", it) }
}
@ReactMethod
fun getSessionSamples(sessionId: String, promise: Promise) {
runCatching {
val id = sessionId.toLongOrNull() ?: return@runCatching Arguments.createArray()
samplesToWritableArray(store.samplesForSession(id, MAX_SESSION_DETAIL_SAMPLES))
}.onSuccess { promise.resolve(it) }
.onFailure { promise.reject("FIGHTRATE_SESSION_SAMPLES_FAILED", it) }
}
@ReactMethod
fun getContinuousSamples(limit: Double, promise: Promise) {
runCatching {
val requestedLimit = limit.toInt()
val samples = if (requestedLimit <= 0) {
store.allContinuousSamples()
} else {
store.recentSamples(requestedLimit.coerceIn(1, MAX_CONTINUOUS_HISTORY_SAMPLES))
}
samplesToWritableArray(samples)
}.onSuccess { promise.resolve(it) }
.onFailure { promise.reject("FIGHTRATE_CONTINUOUS_SAMPLES_FAILED", it) }
}
@ReactMethod
fun isIgnoringBatteryOptimizations(promise: Promise) {
promise.resolve(isBatteryOptimizationIgnored())
}
@ReactMethod
fun openBatteryOptimizationSettings(promise: Promise) {
runCatching {
val packageName = reactContext.packageName
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !isBatteryOptimizationIgnored()) {
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
} else {
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
reactContext.startActivity(intent)
promise.resolve(true)
}.onFailure { promise.reject("FIGHTRATE_BATTERY_SETTINGS_FAILED", it) }
}
private fun registerReceiverIfNeeded() {
if (receiverRegistered) return
val filter = IntentFilter().apply {
addAction(FightRateCaptureService.ACTION_SAMPLE)
addAction(FightRateCaptureService.ACTION_STATUS)
addAction(FightRateCaptureService.ACTION_DEVICES)
addAction(FightRateCaptureService.ACTION_METADATA)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
reactContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
reactContext.registerReceiver(receiver, filter)
}
receiverRegistered = true
}
private fun unregisterReceiverIfNeeded() {
if (!receiverRegistered) return
runCatching { reactContext.unregisterReceiver(receiver) }
receiverRegistered = false
}
private fun restoreMonitoringIfNeeded() {
val context = reactContext.applicationContext
if (FightRateCaptureService.isMonitoringEnabled(context)) {
FightRateCaptureService.startMonitoring(context)
}
}
private fun snapshotMap(forceIdle: Boolean = false): WritableMap {
val active = if (forceIdle) null else store.activeSession()
val context = reactContext.applicationContext
val monitoring = !forceIdle && FightRateCaptureService.isMonitoringEnabled(context)
val sessions = store.finishedSessionSummaries(MAX_SESSION_SUMMARIES)
val samples = store.recentSamples(MAX_SNAPSHOT_SAMPLES)
val latestSample = samples.lastOrNull()
val hasFreshSample = latestSample?.let { FightRateCaptureService.isFreshTimestamp(it.timestampMillis) } == true
val connectedDevice = FightRateCaptureService.lastKnownDevice(context)?.let { nativeDeviceMap(it) }
?: active?.let { sessionDeviceMap(it) }
val map = Arguments.createMap()
val phase = when {
forceIdle -> FightRateCaptureService.PHASE_IDLE
!monitoring -> FightRateCaptureService.PHASE_IDLE
samples.isEmpty() -> FightRateCaptureService.PHASE_SCANNING
hasFreshSample -> FightRateCaptureService.PHASE_RECEIVING
else -> FightRateCaptureService.PHASE_CONNECTED
}
map.putString("phase", phase)
map.putBoolean("isMonitoring", monitoring)
map.putBoolean("isCapturing", active != null && !forceIdle)
map.putDouble("startedAt", (active?.startedAtMillis ?: 0L).toDouble())
map.putString("statusMessage", when {
forceIdle || !monitoring -> "Monitor parado."
active != null && hasFreshSample -> "Janela de treino ativa. Monitor contínuo gravando."
active != null -> "Janela ativa, mas sem BPM válido recente. Aguardando sensor."
hasFreshSample -> "Monitor contínuo ativo. Recebendo BPM real."
else -> "Monitor contínuo ativo. Sem batimento válido recente."
})
map.putMap("connectedDevice", connectedDevice)
map.putArray("discoveredDevices", Arguments.createArray())
map.putArray("samples", samplesToWritableArray(samples))
map.putArray("sessions", sessionsToWritableArray(sessions))
map.putArray("services", Arguments.createArray())
map.putArray("characteristics", Arguments.createArray())
map.putBoolean("batteryOptimizationIgnored", isBatteryOptimizationIgnored())
return map
}
private fun sessionDeviceMap(session: StoredSession): WritableMap? {
if (session.deviceId.isNullOrBlank() && session.deviceName.isNullOrBlank()) return null
return Arguments.createMap().apply {
putString("id", session.deviceId ?: "")
putString("name", session.deviceName ?: "Cinta cardíaca BLE")
putInt("rssi", Int.MIN_VALUE)
putInt("batteryPercent", -1)
putString("bodySensorLocation", null)
}
}
private fun nativeDeviceMap(device: NativeDiscoveredDevice): WritableMap = Arguments.createMap().apply {
putString("id", device.id)
putString("name", device.name)
putInt("rssi", device.rssi)
putInt("batteryPercent", -1)
putString("bodySensorLocation", null)
}
private fun sampleFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
putDouble("id", intent.getLongExtra(FightRateCaptureService.EXTRA_SAMPLE_ID, -1L).toDouble())
val sessionId = intent.getLongExtra(FightRateCaptureService.EXTRA_SESSION_ID, -1L)
if (sessionId >= 0L) putDouble("sessionId", sessionId.toDouble()) else putNull("sessionId")
putDouble("timestamp", intent.getLongExtra(FightRateCaptureService.EXTRA_TIMESTAMP, 0L).toDouble())
putInt("bpm", intent.getIntExtra(FightRateCaptureService.EXTRA_BPM, 0))
if (intent.hasExtra(FightRateCaptureService.EXTRA_CONTACT_DETECTED)) {
putBoolean("contactDetected", intent.getBooleanExtra(FightRateCaptureService.EXTRA_CONTACT_DETECTED, false))
} else {
putNull("contactDetected")
}
putArray("rrIntervalsMs", intArrayListToWritable(intent.getIntegerArrayListExtra(FightRateCaptureService.EXTRA_RR_INTERVALS)))
putString("deviceName", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_NAME))
}
private fun statusFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
putString("phase", intent.getStringExtra(FightRateCaptureService.EXTRA_PHASE) ?: FightRateCaptureService.PHASE_IDLE)
putString("statusMessage", intent.getStringExtra(FightRateCaptureService.EXTRA_STATUS_MESSAGE))
val sessionId = intent.getLongExtra(FightRateCaptureService.EXTRA_SESSION_ID, -1L)
if (sessionId >= 0L) putDouble("sessionId", sessionId.toDouble()) else putNull("sessionId")
putMap("connectedDevice", Arguments.createMap().apply {
putString("id", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_ID) ?: "")
putString("name", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_NAME) ?: "Cinta cardíaca BLE")
putInt("rssi", intent.getIntExtra(FightRateCaptureService.EXTRA_RSSI, Int.MIN_VALUE))
putInt("batteryPercent", intent.getIntExtra(FightRateCaptureService.EXTRA_BATTERY_PERCENT, -1))
putString("bodySensorLocation", intent.getStringExtra(FightRateCaptureService.EXTRA_BODY_SENSOR_LOCATION))
})
}
private fun devicesFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
val array = Arguments.createArray()
@Suppress("DEPRECATION")
val devices = intent.getParcelableArrayListExtra<Bundle>(FightRateCaptureService.EXTRA_DEVICES).orEmpty()
devices.forEach { bundle ->
array.pushMap(Arguments.createMap().apply {
putString("id", bundle.getString("id") ?: "")
putString("name", bundle.getString("name") ?: "Sensor BLE sem nome")
putInt("rssi", bundle.getInt("rssi", Int.MIN_VALUE))
putArray("serviceUuids", stringArrayListToWritable(bundle.getStringArrayList("serviceUuids")))
})
}
putArray("devices", array)
}
private fun metadataFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
putArray("services", stringArrayListToWritable(intent.getStringArrayListExtra(FightRateCaptureService.EXTRA_SERVICES)))
putArray("characteristics", stringArrayListToWritable(intent.getStringArrayListExtra(FightRateCaptureService.EXTRA_CHARACTERISTICS)))
}
private fun samplesToWritableArray(samples: List<StoredSample>): WritableArray = Arguments.createArray().also { array ->
samples.forEach { sample ->
array.pushMap(Arguments.createMap().apply {
putDouble("id", sample.id.toDouble())
sample.sessionId?.let { putDouble("sessionId", it.toDouble()) } ?: putNull("sessionId")
putDouble("timestamp", sample.timestampMillis.toDouble())
putInt("bpm", sample.bpm)
if (sample.contactDetected == null) putNull("contactDetected") else putBoolean("contactDetected", sample.contactDetected)
putArray("rrIntervalsMs", intArrayListToWritable(ArrayList(sample.rrIntervalsMs)))
putString("deviceName", sample.deviceName)
})
}
}
private fun sessionsToWritableArray(sessions: List<StoredSessionSummary>): WritableArray = Arguments.createArray().also { array ->
sessions.forEach { session ->
array.pushMap(Arguments.createMap().apply {
putString("id", session.id.toString())
putDouble("startedAt", session.startedAtMillis.toDouble())
putDouble("endedAt", session.endedAtMillis.toDouble())
putDouble("durationMs", session.durationMillis.toDouble())
putString("deviceName", session.deviceName)
putInt("sampleCount", session.sampleCount)
putInt("averageBpm", session.averageBpm)
putInt("peakBpm", session.peakBpm)
putInt("minBpm", session.minBpm)
})
}
}
private fun intArrayListToWritable(values: ArrayList<Int>?): WritableArray = Arguments.createArray().also { array ->
values.orEmpty().forEach { array.pushInt(it) }
}
private fun stringArrayListToWritable(values: ArrayList<String>?): WritableArray = Arguments.createArray().also { array ->
values.orEmpty().forEach { array.pushString(it) }
}
private fun emit(eventName: String, payload: WritableMap) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, payload)
}
private fun isBatteryOptimizationIgnored(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val powerManager = reactContext.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(reactContext.packageName)
}
companion object {
const val NAME = "FightRateCapture"
private const val EVENT_SAMPLE = "FightRateSample"
private const val EVENT_STATUS = "FightRateStatus"
private const val EVENT_DEVICES = "FightRateDevices"
private const val EVENT_METADATA = "FightRateMetadata"
private const val MAX_SNAPSHOT_SAMPLES = 1800
private const val MAX_SESSION_SUMMARIES = 60
private const val MAX_SESSION_DETAIL_SAMPLES = 7200
private const val MAX_CONTINUOUS_HISTORY_SAMPLES = 20_000
}
}
@@ -0,0 +1,892 @@
package com.healthtracking.fightrate.capture
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.healthtracking.fightrate.MainActivity
import com.healthtracking.fightrate.R
import java.util.UUID
import kotlin.math.roundToInt
class FightRateCaptureService : Service() {
private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var store: FightRateCaptureStore
private val bluetoothManager by lazy { getSystemService(BluetoothManager::class.java) }
private val bluetoothAdapter by lazy { bluetoothManager?.adapter }
private val bluetoothScanner get() = bluetoothAdapter?.bluetoothLeScanner
private var gatt: BluetoothGatt? = null
private var activeSessionId: Long? = null
private var manualStop = true
private var currentDeviceId: String? = null
private var currentDeviceName: String? = null
private var currentRssi: Int? = null
private var batteryPercent: Int? = null
private var bodySensorLocation: String? = null
private var reconnectAttempts = 0
private val discoveredDevices = linkedMapOf<String, NativeDiscoveredDevice>()
private var lastNotificationUpdateMillis = 0L
private var lastRssiReadMillis = 0L
private var lastNotificationBpm: Int? = null
private var recentBpm = ArrayDeque<Pair<Long, Int>>()
private var lastValidMeasurementMillis = 0L
private var lastInvalidMeasurementMillis = 0L
private var notifyRetryCount = 0
override fun onCreate() {
super.onCreate()
store = FightRateCaptureStore(applicationContext)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_MONITORING -> startMonitoring(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
ACTION_START -> startWorkoutWindow(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
ACTION_CONNECT_DEVICE -> connectKnownDevice(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
ACTION_STOP_WINDOW -> stopWorkoutWindow(manual = true)
ACTION_STOP -> stopMonitoring(manual = true)
else -> restoreCaptureIfNeeded()
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopScan()
closeGatt()
super.onDestroy()
}
@SuppressLint("MissingPermission")
private fun startMonitoring(deviceId: String?, deviceName: String?) {
manualStop = false
currentDeviceId = deviceId ?: currentDeviceId ?: prefs().getString(KEY_LAST_DEVICE_ID, null)
currentDeviceName = deviceName ?: currentDeviceName ?: prefs().getString(KEY_LAST_DEVICE_NAME, null)
currentRssi = currentRssi ?: lastStoredRssi()
persistState(manual = false)
startForeground(NOTIFICATION_ID, buildNotification("Monitor cardíaco contínuo ativo..."))
emitStatus(PHASE_SCANNING, "Monitor contínuo ativo. Procurando a cinta cardíaca BLE.")
if (gatt != null) {
emitStatus(PHASE_RECEIVING, "Monitor contínuo recebendo BPM real.")
return
}
val knownDeviceId = currentDeviceId
if (!knownDeviceId.isNullOrBlank()) {
connectKnownDevice(knownDeviceId, currentDeviceName)
} else {
startScan()
}
}
@SuppressLint("MissingPermission")
private fun startWorkoutWindow(deviceId: String?, deviceName: String?) {
startMonitoring(deviceId, deviceName)
val session = store.startSession(System.currentTimeMillis(), currentDeviceId, currentDeviceName)
activeSessionId = session.id
persistState(manual = false)
emitStatus(PHASE_RECEIVING, "Janela de treino aberta. O monitor segue gravando continuamente.")
updateNotification("Treino em andamento · monitor contínuo ativo")
}
@SuppressLint("MissingPermission")
private fun connectKnownDevice(deviceId: String?, deviceName: String?) {
if (deviceId.isNullOrBlank()) {
startScan()
return
}
if (!hasConnectPermission()) {
emitStatus(PHASE_PERMISSION_DENIED, "Permissão de conexão Bluetooth não concedida.")
return
}
val adapter = bluetoothAdapter
if (adapter?.isEnabled != true) {
emitStatus(PHASE_BLUETOOTH_OFF, "Bluetooth desligado.")
return
}
currentDeviceId = deviceId
currentDeviceName = deviceName ?: currentDeviceName ?: "Cinta cardíaca BLE"
activeSessionId?.let { store.updateSessionDevice(it, currentDeviceId, currentDeviceName) }
persistState(manual = false)
stopScan()
closeGatt()
emitStatus(PHASE_CONNECTING, "Conectando em ${friendlyDeviceName(currentDeviceName)}...")
updateNotification("Conectando em ${friendlyDeviceName(currentDeviceName)}...")
runCatching {
val device = adapter.getRemoteDevice(deviceId)
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(this, false, gattCallback)
}
}.onFailure { error ->
emitStatus(PHASE_ERROR, error.message ?: "Falha ao abrir conexão BLE.")
scheduleReconnect()
}
}
@SuppressLint("MissingPermission")
private fun startScan() {
if (!hasScanPermission()) {
emitStatus(PHASE_PERMISSION_DENIED, "Permissão de busca Bluetooth não concedida.")
return
}
val adapter = bluetoothAdapter
if (adapter?.isEnabled != true) {
emitStatus(PHASE_BLUETOOTH_OFF, "Bluetooth desligado.")
return
}
stopScan()
discoveredDevices.clear()
emitDevices()
emitStatus(PHASE_SCANNING, "Buscando sensores cardíacos BLE próximos.")
updateNotification("Buscando cinta cardíaca BLE...")
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
bluetoothScanner?.startScan(null, settings, scanCallback)
mainHandler.removeCallbacks(scanTimeoutRunnable)
mainHandler.postDelayed(scanTimeoutRunnable, SCAN_WINDOW_MILLIS)
}
@SuppressLint("MissingPermission")
private fun stopScan() {
runCatching { bluetoothScanner?.stopScan(scanCallback) }
mainHandler.removeCallbacks(scanTimeoutRunnable)
}
private val scanTimeoutRunnable = Runnable {
if (manualStop || gatt != null) return@Runnable
emitStatus(PHASE_SENSOR_NOT_FOUND, "Nenhuma cinta cardíaca encontrada. Vou tentar de novo em 15s.")
updateNotification("Sensor não encontrado. Nova busca em 15s...")
mainHandler.postDelayed({ if (!manualStop && gatt == null) startScan() }, SCAN_RETRY_MILLIS)
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleScanResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach(::handleScanResult)
}
override fun onScanFailed(errorCode: Int) {
emitStatus(PHASE_ERROR, "Falha no scan BLE: $errorCode")
mainHandler.postDelayed({ if (!manualStop) startScan() }, SCAN_RETRY_MILLIS)
}
}
@SuppressLint("MissingPermission")
private fun handleScanResult(result: ScanResult) {
val device = result.device ?: return
val name = result.scanRecord?.deviceName ?: device.name ?: "Sensor BLE sem nome"
val serviceUuids = result.scanRecord?.serviceUuids?.map { it.uuid.toString() }.orEmpty()
val candidate = NativeDiscoveredDevice(device.address, name, result.rssi, serviceUuids)
discoveredDevices[device.address] = candidate
emitDevices()
if (!isHeartRateCandidate(candidate)) return
currentRssi = result.rssi
persistState(manual = false)
connectKnownDevice(device.address, name)
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (manualStop) return
when {
status != BluetoothGatt.GATT_SUCCESS -> {
emitStatus(PHASE_ERROR, "Falha GATT: $status. Tentando reconectar.")
closeGatt(gatt)
scheduleReconnect()
}
newState == BluetoothProfile.STATE_CONNECTED -> {
reconnectAttempts = 0
currentDeviceId = gatt.device.address
currentDeviceName = safeDeviceName(gatt.device)
activeSessionId?.let { store.updateSessionDevice(it, currentDeviceId, currentDeviceName) }
persistState(manual = false)
emitStatus(PHASE_CONNECTED, "Sensor conectado. Descobrindo serviços.")
readRssiNow(gatt)
updateNotification("Sensor conectado. Preparando leitura...")
if (!gatt.discoverServices()) {
emitStatus(PHASE_ERROR, "Não foi possível descobrir serviços BLE.")
scheduleReconnect()
}
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
closeGatt(gatt)
emitStatus(PHASE_ERROR, "Sensor desconectado. Tentando reconectar.")
scheduleReconnect()
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (manualStop) return
if (status != BluetoothGatt.GATT_SUCCESS) {
emitStatus(PHASE_ERROR, "Falha ao descobrir serviços BLE: $status")
scheduleReconnect()
return
}
emitMetadata(gatt.services)
val service = gatt.getService(HEART_RATE_SERVICE_UUID)
if (service == null) {
emitStatus(PHASE_ERROR, "Serviço Heart Rate 180D não encontrado no sensor.")
scheduleReconnect()
return
}
subscribeToHeartRate(gatt, service)
}
@Deprecated("Deprecated by Android framework")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
handleMeasurement(characteristic.value)
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
handleMeasurement(value)
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (manualStop || descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
val characteristic = descriptor.characteristic
if (characteristic?.uuid != HEART_RATE_MEASUREMENT_UUID) return
if (status == BluetoothGatt.GATT_SUCCESS) {
notifyRetryCount = 0
emitStatus(PHASE_RECEIVING, "2A37 confirmado. Aguardando o primeiro BPM real.")
updateNotification("2A37 ativo. Aguardando batimentos reais...")
readRssiNow(gatt)
readBodyLocationOrBattery(gatt)
scheduleMeasurementWatchdog(gatt)
} else {
emitStatus(PHASE_ERROR, "Falha ao ativar notify 2A37: $status. Tentando reconectar.")
scheduleReconnect()
}
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
currentRssi = rssi
persistState(manual = false)
emitStatus(PHASE_RECEIVING, null)
}
}
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) return
when (characteristic.uuid) {
BATTERY_LEVEL_UUID -> {
batteryPercent = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)?.coerceIn(0, 100)
emitStatus(PHASE_RECEIVING, null)
}
BODY_SENSOR_LOCATION_UUID -> {
bodySensorLocation = bodyLocationName(characteristic.value?.firstOrNull()?.toInt()?.and(0xFF))
readBattery(gatt)
}
}
}
}
@SuppressLint("MissingPermission")
private fun subscribeToHeartRate(gatt: BluetoothGatt, service: BluetoothGattService) {
val characteristic = service.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
if (characteristic == null) {
emitStatus(PHASE_ERROR, "Característica Heart Rate Measurement 2A37 não encontrada.")
scheduleReconnect()
return
}
val supportsNotify = characteristic.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
val supportsIndicate = characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0
if (!supportsNotify && !supportsIndicate) {
emitStatus(PHASE_ERROR, "2A37 existe, mas não expõe notify/indicate.")
scheduleReconnect()
return
}
if (!gatt.setCharacteristicNotification(characteristic, true)) {
emitStatus(PHASE_ERROR, "Android recusou setCharacteristicNotification em 2A37.")
scheduleReconnect()
return
}
val descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
if (descriptor == null) {
emitStatus(PHASE_ERROR, "Descriptor CCCD 2902 não encontrado para 2A37.")
scheduleReconnect()
return
}
val descriptorValue = if (supportsNotify) {
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
} else {
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
}
val writeStarted = writeDescriptorCompat(gatt, descriptor, descriptorValue)
if (!writeStarted) {
emitStatus(PHASE_ERROR, "Não foi possível escrever CCCD 2902 para 2A37.")
scheduleReconnect()
return
}
emitStatus(PHASE_CONNECTED, "Ativando notify 2A37. Dados serão salvos assim que chegarem.")
updateNotification("Ativando leitura 2A37...")
}
@SuppressLint("MissingPermission")
private fun writeDescriptorCompat(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
value: ByteArray,
): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
descriptor.value = value
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
}
private fun scheduleMeasurementWatchdog(gatt: BluetoothGatt) {
val watchedGatt = gatt
val armedAt = System.currentTimeMillis()
mainHandler.postDelayed({
if (manualStop || this.gatt !== watchedGatt) return@postDelayed
val hasReadingAfterArm = lastValidMeasurementMillis >= armedAt
if (hasReadingAfterArm) return@postDelayed
val hasInvalidAfterArm = lastInvalidMeasurementMillis >= armedAt
if (hasInvalidAfterArm) {
emitStatus(PHASE_CONNECTED, "Sensor conectado, mas sem batimento válido. Aguardando contato/sinal real.")
updateNotification("Sem BPM válido · ajuste a cinta/sensor")
scheduleSignalStaleWatchdog()
return@postDelayed
}
notifyRetryCount += 1
if (notifyRetryCount <= 2) {
emitStatus(PHASE_CONNECTED, "Sensor reconhecido, mas ainda sem BPM. Reativando notify 2A37.")
watchedGatt.getService(HEART_RATE_SERVICE_UUID)?.let { subscribeToHeartRate(watchedGatt, it) }
} else {
emitStatus(PHASE_ERROR, "Sensor reconhecido sem enviar BPM. Reconectando para reabrir 2A37.")
scheduleReconnect()
}
}, FIRST_MEASUREMENT_TIMEOUT_MILLIS)
}
private fun handleMeasurement(value: ByteArray?) {
if (manualStop || value == null || value.isEmpty()) return
val parsed = runCatching { HeartRateMeasurementParser.parse(value) }.getOrNull() ?: return
val now = System.currentTimeMillis()
if (!isValidMeasurement(parsed)) {
handleInvalidMeasurement(parsed, now)
return
}
lastValidMeasurementMillis = now
val sessionId = activeSessionId ?: store.activeSession()?.id
val sample = store.insertSample(
activeSessionId = sessionId,
timestampMillis = now,
bpm = parsed.bpm,
contactDetected = parsed.contactDetected,
rrIntervalsMs = parsed.rrIntervalsMs,
deviceName = currentDeviceName,
)
reconnectAttempts = 0
addRecentBpm(now, parsed.bpm)
emitSample(sample)
emitStatus(PHASE_RECEIVING, "Recebendo BPM real de ${friendlyDeviceName(currentDeviceName)}.")
updateNotificationIfNeeded(now, parsed.bpm)
readRssiEventually()
scheduleSignalStaleWatchdog()
}
private fun handleInvalidMeasurement(parsed: ParsedHeartRateMeasurement, timestamp: Long) {
lastInvalidMeasurementMillis = timestamp
recentBpm.clear()
lastNotificationBpm = null
val reason = invalidMeasurementReason(parsed)
emitStatus(PHASE_CONNECTED, "Sensor conectado, mas $reason. Aguardando batimento real.")
updateNotification("Sem BPM válido · $reason")
scheduleSignalStaleWatchdog()
}
private fun isValidMeasurement(parsed: ParsedHeartRateMeasurement): Boolean {
return parsed.bpm in MIN_VALID_BPM..MAX_VALID_BPM && parsed.contactDetected != false
}
private fun invalidMeasurementReason(parsed: ParsedHeartRateMeasurement): String {
return when {
parsed.contactDetected == false -> "sem contato detectado"
parsed.bpm <= 0 -> "o sensor reportou 0 bpm"
parsed.bpm < MIN_VALID_BPM -> "BPM abaixo do plausível"
parsed.bpm > MAX_VALID_BPM -> "BPM acima do plausível"
else -> "sem sinal válido"
}
}
private val signalStaleRunnable = Runnable {
if (manualStop || gatt == null) return@Runnable
val now = System.currentTimeMillis()
val elapsed = now - lastValidMeasurementMillis
if (lastValidMeasurementMillis > 0L && elapsed >= VALID_SAMPLE_STALE_MILLIS) {
recentBpm.clear()
lastNotificationBpm = null
emitStatus(PHASE_CONNECTED, "Sem batimento válido há ${elapsed / 1000}s. Aguardando novos dados reais.")
updateNotification("Sem BPM válido · aguardando sensor")
}
}
private fun scheduleSignalStaleWatchdog() {
mainHandler.removeCallbacks(signalStaleRunnable)
mainHandler.postDelayed(signalStaleRunnable, VALID_SAMPLE_STALE_MILLIS)
}
private fun currentSignalPhase(): String {
if (gatt == null) return PHASE_SCANNING
return if (FightRateCaptureService.isFreshTimestamp(lastValidMeasurementMillis)) {
PHASE_RECEIVING
} else {
PHASE_CONNECTED
}
}
@SuppressLint("MissingPermission")
private fun readRssiEventually() {
val now = System.currentTimeMillis()
if (now - lastRssiReadMillis < RSSI_READ_INTERVAL_MILLIS) return
lastRssiReadMillis = now
readRssiNow(gatt)
}
@SuppressLint("MissingPermission")
private fun readRssiNow(target: BluetoothGatt?) {
if (!hasConnectPermission()) return
runCatching { target?.readRemoteRssi() }
}
private fun stopWorkoutWindow(manual: Boolean) {
store.finishActiveSession(System.currentTimeMillis())
activeSessionId = null
val message = if (manual) "Treino salvo. Monitor cardíaco continua ativo." else "Janela de treino encerrada."
emitStatus(currentSignalPhase(), message)
updateNotification(if (currentSignalPhase() == PHASE_RECEIVING) "Monitor contínuo ativo · sem janela de treino" else "Monitor ativo · aguardando BPM válido")
}
private fun stopMonitoring(manual: Boolean) {
manualStop = manual
persistState(manual = manual)
stopScan()
closeGatt()
mainHandler.removeCallbacks(signalStaleRunnable)
store.finishActiveSession(System.currentTimeMillis())
activeSessionId = null
recentBpm.clear()
emitStatus(PHASE_IDLE, if (manual) "Monitor parado manualmente." else "Monitor encerrado.")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun restoreCaptureIfNeeded() {
manualStop = prefs().getBoolean(KEY_MANUAL_STOP, true)
if (manualStop) return
activeSessionId = store.activeSession()?.id
currentDeviceId = prefs().getString(KEY_LAST_DEVICE_ID, null)
currentDeviceName = prefs().getString(KEY_LAST_DEVICE_NAME, null)
startForeground(NOTIFICATION_ID, buildNotification("Retomando monitor contínuo..."))
if (!currentDeviceId.isNullOrBlank()) connectKnownDevice(currentDeviceId, currentDeviceName) else startScan()
}
private fun scheduleReconnect() {
if (manualStop) return
val delayMillis = when (reconnectAttempts++) {
0 -> 2_000L
1 -> 5_000L
2 -> 10_000L
else -> 30_000L
}
updateNotification("Reconectando em ${delayMillis / 1000}s...")
mainHandler.postDelayed({
if (manualStop) return@postDelayed
val deviceId = currentDeviceId
if (!deviceId.isNullOrBlank()) connectKnownDevice(deviceId, currentDeviceName) else startScan()
}, delayMillis)
}
private fun closeGatt() {
runCatching { gatt?.disconnect() }
runCatching { gatt?.close() }
gatt = null
mainHandler.removeCallbacks(signalStaleRunnable)
}
private fun closeGatt(target: BluetoothGatt) {
if (gatt === target) gatt = null
runCatching { target.close() }
}
private fun addRecentBpm(timestamp: Long, bpm: Int) {
recentBpm.addLast(timestamp to bpm)
val cutoff = timestamp - 60_000L
while (recentBpm.isNotEmpty() && recentBpm.first().first < cutoff) {
recentBpm.removeFirst()
}
}
private fun updateNotificationIfNeeded(timestamp: Long, bpm: Int) {
val rollingAverage = if (recentBpm.isEmpty()) bpm else recentBpm.map { it.second }.average().roundToInt()
val changedEnough = lastNotificationBpm?.let { kotlin.math.abs(rollingAverage - it) >= 3 } ?: true
if (timestamp - lastNotificationUpdateMillis < NOTIFICATION_THROTTLE_MILLIS && !changedEnough) return
lastNotificationUpdateMillis = timestamp
lastNotificationBpm = rollingAverage
updateNotification("${rollingAverage} BPM média 60s · ${friendlyDeviceName(currentDeviceName)}")
}
private fun buildNotification(text: String): Notification {
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = PendingIntent.getService(
this,
1,
Intent(this, FightRateCaptureService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("FightRate capturando")
.setContentText(text)
.setContentIntent(openIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(R.mipmap.ic_launcher, "Parar", stopIntent)
.build()
}
private fun updateNotification(text: String) {
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text))
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(CHANNEL_ID, "Captura contínua", NotificationManager.IMPORTANCE_LOW).apply {
description = "Mantém a cinta cardíaca BLE ativa com a tela bloqueada."
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun emitSample(sample: StoredSample) {
val intent = Intent(ACTION_SAMPLE).setPackage(packageName)
.putExtra(EXTRA_SAMPLE_ID, sample.id)
.putExtra(EXTRA_TIMESTAMP, sample.timestampMillis)
.putExtra(EXTRA_BPM, sample.bpm)
.putIntegerArrayListExtra(EXTRA_RR_INTERVALS, ArrayList(sample.rrIntervalsMs))
.putExtra(EXTRA_DEVICE_NAME, sample.deviceName)
sample.sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
sample.contactDetected?.let { intent.putExtra(EXTRA_CONTACT_DETECTED, it) }
sendBroadcast(intent)
}
private fun emitDevices() {
sendBroadcast(
Intent(ACTION_DEVICES).setPackage(packageName)
.putParcelableArrayListExtra(EXTRA_DEVICES, ArrayList(discoveredDevices.values.map { it.toBundle() })),
)
}
private fun emitMetadata(services: List<BluetoothGattService>) {
val serviceRows = services.map { service ->
"${knownServiceName(service.uuid)} · ${shortUuid(service.uuid)} · ${service.characteristics.size} dados"
}
val characteristicRows = services.flatMap { service ->
service.characteristics.map { characteristic ->
"${knownCharacteristicName(characteristic.uuid)} · ${shortUuid(characteristic.uuid)} · ${propertySummary(characteristic.properties)}"
}
}
sendBroadcast(
Intent(ACTION_METADATA).setPackage(packageName)
.putStringArrayListExtra(EXTRA_SERVICES, ArrayList(serviceRows))
.putStringArrayListExtra(EXTRA_CHARACTERISTICS, ArrayList(characteristicRows)),
)
}
private fun emitStatus(phase: String, message: String?) {
sendBroadcast(
Intent(ACTION_STATUS).setPackage(packageName)
.putExtra(EXTRA_PHASE, phase)
.putExtra(EXTRA_STATUS_MESSAGE, message)
.putExtra(EXTRA_SESSION_ID, activeSessionId ?: -1L)
.putExtra(EXTRA_DEVICE_ID, currentDeviceId)
.putExtra(EXTRA_DEVICE_NAME, currentDeviceName)
.putExtra(EXTRA_RSSI, currentRssi ?: Int.MIN_VALUE)
.putExtra(EXTRA_BATTERY_PERCENT, batteryPercent ?: -1)
.putExtra(EXTRA_BODY_SENSOR_LOCATION, bodySensorLocation),
)
}
@SuppressLint("MissingPermission")
private fun readBodyLocationOrBattery(gatt: BluetoothGatt) {
val bodyStarted = gatt.getService(HEART_RATE_SERVICE_UUID)
?.getCharacteristic(BODY_SENSOR_LOCATION_UUID)
?.let { gatt.readCharacteristic(it) } == true
if (!bodyStarted) readBattery(gatt)
}
@SuppressLint("MissingPermission")
private fun readBattery(gatt: BluetoothGatt) {
gatt.getService(BATTERY_SERVICE_UUID)
?.getCharacteristic(BATTERY_LEVEL_UUID)
?.let { gatt.readCharacteristic(it) }
}
private fun isHeartRateCandidate(device: NativeDiscoveredDevice): Boolean {
val name = device.name.lowercase()
val uuids = device.serviceUuids.map { it.lowercase() }
return uuids.any { it == HEART_RATE_SERVICE_UUID.toString() || it.contains("180d") } ||
name.contains("magene") ||
name.contains("h003") ||
name.contains("heart") ||
name.contains("hrm")
}
@SuppressLint("MissingPermission")
private fun safeDeviceName(device: BluetoothDevice): String = runCatching {
device.name ?: currentDeviceName ?: "Cinta cardíaca BLE"
}.getOrDefault(currentDeviceName ?: "Cinta cardíaca BLE")
private fun friendlyDeviceName(name: String?): String = when {
name.isNullOrBlank() -> "cinta cardíaca BLE"
name.contains("h003", ignoreCase = true) || name.contains("magene", ignoreCase = true) -> "Magene H003"
name.contains("heart", ignoreCase = true) || name.contains("hrm", ignoreCase = true) -> "cinta cardíaca BLE"
else -> name
}
private fun hasScanPermission(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
}
private fun hasConnectPermission(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
}
private fun prefs() = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
private fun lastStoredRssi(): Int? {
val value = prefs().getInt(KEY_LAST_RSSI, Int.MIN_VALUE)
return if (value == Int.MIN_VALUE) null else value
}
private fun persistState(manual: Boolean) {
val editor = prefs().edit()
.putBoolean(KEY_MANUAL_STOP, manual)
.putString(KEY_LAST_DEVICE_ID, currentDeviceId)
.putString(KEY_LAST_DEVICE_NAME, currentDeviceName)
currentRssi?.let { editor.putInt(KEY_LAST_RSSI, it) }
editor.apply()
}
private fun shortUuid(uuid: UUID): String {
val text = uuid.toString()
return if (text.endsWith("-0000-1000-8000-00805f9b34fb")) "0x${text.substring(4, 8).uppercase()}" else text
}
private fun knownServiceName(uuid: UUID): String = when (uuid) {
HEART_RATE_SERVICE_UUID -> "Heart Rate Service"
BATTERY_SERVICE_UUID -> "Battery Service"
DEVICE_INFORMATION_SERVICE_UUID -> "Device Information"
else -> "Serviço BLE"
}
private fun knownCharacteristicName(uuid: UUID): String = when (uuid) {
HEART_RATE_MEASUREMENT_UUID -> "Heart Rate Measurement"
BODY_SENSOR_LOCATION_UUID -> "Body Sensor Location"
BATTERY_LEVEL_UUID -> "Battery Level"
else -> "Característica BLE"
}
private fun propertySummary(properties: Int): String = buildList {
if (properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) add("read")
if (properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) add("notify")
if (properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) add("indicate")
if (properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) add("write")
}.ifEmpty { listOf("sem propriedade") }.joinToString("/")
private fun bodyLocationName(value: Int?): String? = when (value) {
0 -> "Outro"
1 -> "Peito"
2 -> "Pulso"
3 -> "Dedo"
4 -> "Mão"
5 -> "Orelha"
6 -> "Pé"
else -> null
}
companion object {
const val ACTION_START = "com.healthtracking.fightrate.rn.START_CAPTURE"
const val ACTION_START_MONITORING = "com.healthtracking.fightrate.rn.START_MONITORING"
const val ACTION_CONNECT_DEVICE = "com.healthtracking.fightrate.rn.CONNECT_DEVICE"
const val ACTION_STOP = "com.healthtracking.fightrate.rn.STOP_CAPTURE"
const val ACTION_STOP_WINDOW = "com.healthtracking.fightrate.rn.STOP_WORKOUT_WINDOW"
const val ACTION_SAMPLE = "com.healthtracking.fightrate.rn.SAMPLE"
const val ACTION_STATUS = "com.healthtracking.fightrate.rn.STATUS"
const val ACTION_DEVICES = "com.healthtracking.fightrate.rn.DEVICES"
const val ACTION_METADATA = "com.healthtracking.fightrate.rn.METADATA"
const val EXTRA_DEVICE_ID = "device_id"
const val EXTRA_DEVICE_NAME = "device_name"
const val EXTRA_SAMPLE_ID = "sample_id"
const val EXTRA_SESSION_ID = "session_id"
const val EXTRA_TIMESTAMP = "timestamp"
const val EXTRA_BPM = "bpm"
const val EXTRA_CONTACT_DETECTED = "contact_detected"
const val EXTRA_RR_INTERVALS = "rr_intervals"
const val EXTRA_PHASE = "phase"
const val EXTRA_STATUS_MESSAGE = "status_message"
const val EXTRA_RSSI = "rssi"
const val EXTRA_BATTERY_PERCENT = "battery_percent"
const val EXTRA_BODY_SENSOR_LOCATION = "body_sensor_location"
const val EXTRA_DEVICES = "devices"
const val EXTRA_SERVICES = "services"
const val EXTRA_CHARACTERISTICS = "characteristics"
const val PHASE_IDLE = "idle"
const val PHASE_SCANNING = "scanning"
const val PHASE_CONNECTING = "connecting"
const val PHASE_CONNECTED = "connected"
const val PHASE_RECEIVING = "receiving"
const val PHASE_PERMISSION_DENIED = "permission-denied"
const val PHASE_BLUETOOTH_OFF = "bluetooth-off"
const val PHASE_SENSOR_NOT_FOUND = "sensor-not-found"
const val PHASE_ERROR = "error"
val HEART_RATE_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
val HEART_RATE_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
val BODY_SENSOR_LOCATION_UUID: UUID = UUID.fromString("00002a38-0000-1000-8000-00805f9b34fb")
val BATTERY_SERVICE_UUID: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_UUID: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private val DEVICE_INFORMATION_SERVICE_UUID: UUID = UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb")
private const val PREFS_NAME = "fightrate_rn_capture"
private const val KEY_MANUAL_STOP = "manual_stop"
private const val KEY_LAST_DEVICE_ID = "last_device_id"
private const val KEY_LAST_DEVICE_NAME = "last_device_name"
private const val KEY_LAST_RSSI = "last_rssi"
private const val CHANNEL_ID = "fightrate_continuous_capture"
private const val NOTIFICATION_ID = 18037
private const val SCAN_WINDOW_MILLIS = 20_000L
private const val SCAN_RETRY_MILLIS = 15_000L
private const val NOTIFICATION_THROTTLE_MILLIS = 15_000L
private const val RSSI_READ_INTERVAL_MILLIS = 5_000L
private const val FIRST_MEASUREMENT_TIMEOUT_MILLIS = 12_000L
private const val VALID_SAMPLE_STALE_MILLIS = 12_000L
private const val MIN_VALID_BPM = 30
private const val MAX_VALID_BPM = 240
fun startMonitoring(context: Context, deviceId: String? = null, deviceName: String? = null) {
val intent = Intent(context, FightRateCaptureService::class.java)
.setAction(ACTION_START_MONITORING)
.putExtra(EXTRA_DEVICE_ID, deviceId)
.putExtra(EXTRA_DEVICE_NAME, deviceName)
ContextCompat.startForegroundService(context, intent)
}
fun startWorkout(context: Context, deviceId: String? = null, deviceName: String? = null) {
start(context, deviceId, deviceName)
}
fun start(context: Context, deviceId: String? = null, deviceName: String? = null) {
val intent = Intent(context, FightRateCaptureService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_DEVICE_ID, deviceId)
.putExtra(EXTRA_DEVICE_NAME, deviceName)
ContextCompat.startForegroundService(context, intent)
}
fun connect(context: Context, deviceId: String, deviceName: String?) {
startMonitoring(context, deviceId, deviceName)
}
fun stopWorkout(context: Context) {
context.startService(Intent(context, FightRateCaptureService::class.java).setAction(ACTION_STOP_WINDOW))
}
fun isMonitoringEnabled(context: Context): Boolean =
!context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getBoolean(KEY_MANUAL_STOP, true)
fun isFreshTimestamp(timestampMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean =
timestampMillis > 0L && nowMillis - timestampMillis <= VALID_SAMPLE_STALE_MILLIS
fun lastKnownDevice(context: Context): NativeDiscoveredDevice? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getString(KEY_LAST_DEVICE_ID, null)
val name = prefs.getString(KEY_LAST_DEVICE_NAME, null)
val rssi = prefs.getInt(KEY_LAST_RSSI, Int.MIN_VALUE)
if (id.isNullOrBlank()) return null
return NativeDiscoveredDevice(id, name ?: "Cinta cardíaca BLE", rssi, emptyList())
}
fun stop(context: Context) {
context.startService(Intent(context, FightRateCaptureService::class.java).setAction(ACTION_STOP))
}
}
}
data class NativeDiscoveredDevice(
val id: String,
val name: String,
val rssi: Int,
val serviceUuids: List<String>,
) {
fun toBundle(): android.os.Bundle = android.os.Bundle().apply {
putString("id", id)
putString("name", name)
putInt("rssi", rssi)
putStringArrayList("serviceUuids", ArrayList(serviceUuids))
}
}
@@ -0,0 +1,338 @@
package com.healthtracking.fightrate.capture
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class FightRateCaptureStore(context: Context) : SQLiteOpenHelper(
context,
DATABASE_NAME,
null,
DATABASE_VERSION,
) {
override fun onCreate(db: SQLiteDatabase) {
createWorkoutSessionsTable(db)
createLegacySamplesTable(db)
createContinuousSamplesTable(db)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
createContinuousSamplesTable(db)
db.execSQL(
"""
INSERT INTO $CONTINUOUS_SAMPLE_TABLE (timestamp_millis, bpm, contact_detected, rr_intervals_ms, device_name)
SELECT timestamp_millis, bpm, contact_detected, rr_intervals_ms, device_name
FROM $LEGACY_SAMPLE_TABLE
WHERE NOT EXISTS (SELECT 1 FROM $CONTINUOUS_SAMPLE_TABLE LIMIT 1)
""".trimIndent(),
)
}
}
@Synchronized
fun startSession(nowMillis: Long, deviceId: String?, deviceName: String?): StoredSession {
activeSession()?.let { active ->
updateSessionDevice(active.id, deviceId, deviceName)
return sessionById(active.id) ?: active
}
val values = ContentValues().apply {
put("started_at_millis", nowMillis)
putNull("ended_at_millis")
put("status", STATUS_ACTIVE)
put("device_id", deviceId)
put("device_name", deviceName)
}
val id = writableDatabase.insert(SESSION_TABLE, null, values)
return sessionById(id) ?: StoredSession(id, nowMillis, null, STATUS_ACTIVE, deviceId, deviceName)
}
@Synchronized
fun finishActiveSession(nowMillis: Long): StoredSession? {
val active = activeSession() ?: return null
val values = ContentValues().apply {
put("status", STATUS_FINISHED)
put("ended_at_millis", nowMillis)
}
writableDatabase.update(SESSION_TABLE, values, "id = ?", arrayOf(active.id.toString()))
return sessionById(active.id)
}
@Synchronized
fun insertSample(
activeSessionId: Long?,
timestampMillis: Long,
bpm: Int,
contactDetected: Boolean?,
rrIntervalsMs: List<Int>,
deviceName: String?,
): StoredSample {
val values = ContentValues().apply {
put("timestamp_millis", timestampMillis)
put("bpm", bpm)
if (contactDetected == null) putNull("contact_detected") else put("contact_detected", if (contactDetected) 1 else 0)
put("rr_intervals_ms", rrIntervalsMs.joinToString(","))
put("device_name", deviceName)
}
val id = writableDatabase.insert(CONTINUOUS_SAMPLE_TABLE, null, values)
return StoredSample(id, activeSessionId, timestampMillis, bpm, contactDetected, rrIntervalsMs, deviceName)
}
@Synchronized
fun updateSessionDevice(sessionId: Long, deviceId: String?, deviceName: String?) {
val values = ContentValues().apply {
put("device_id", deviceId)
put("device_name", deviceName)
}
writableDatabase.update(SESSION_TABLE, values, "id = ?", arrayOf(sessionId.toString()))
}
@Synchronized
fun activeSession(): StoredSession? {
val cursor = readableDatabase.query(
SESSION_TABLE,
SESSION_COLUMNS,
"status = ?",
arrayOf(STATUS_ACTIVE),
null,
null,
"started_at_millis DESC",
"1",
)
return cursor.use { if (it.moveToFirst()) it.toSession() else null }
}
@Synchronized
fun sessionById(id: Long): StoredSession? {
val cursor = readableDatabase.query(
SESSION_TABLE,
SESSION_COLUMNS,
"id = ?",
arrayOf(id.toString()),
null,
null,
null,
"1",
)
return cursor.use { if (it.moveToFirst()) it.toSession() else null }
}
@Synchronized
fun recentSamples(limit: Int): List<StoredSample> {
val cursor = readableDatabase.query(
CONTINUOUS_SAMPLE_TABLE,
CONTINUOUS_SAMPLE_COLUMNS,
VALID_SAMPLE_SELECTION,
null,
null,
null,
"timestamp_millis DESC",
limit.toString(),
)
return cursor.use {
buildList {
while (it.moveToNext()) add(it.toContinuousSample(null))
}.asReversed()
}
}
@Synchronized
fun allContinuousSamples(): List<StoredSample> {
val cursor = readableDatabase.query(
CONTINUOUS_SAMPLE_TABLE,
CONTINUOUS_SAMPLE_COLUMNS,
VALID_SAMPLE_SELECTION,
null,
null,
null,
"timestamp_millis ASC",
null,
)
return cursor.use {
buildList {
while (it.moveToNext()) add(it.toContinuousSample(null))
}
}
}
@Synchronized
fun samplesForSession(sessionId: Long, limit: Int): List<StoredSample> {
val session = sessionById(sessionId) ?: return emptyList()
val endedAt = session.endedAtMillis ?: System.currentTimeMillis()
val cursor = readableDatabase.query(
CONTINUOUS_SAMPLE_TABLE,
CONTINUOUS_SAMPLE_COLUMNS,
"$VALID_SAMPLE_SELECTION AND timestamp_millis BETWEEN ? AND ?",
arrayOf(session.startedAtMillis.toString(), endedAt.toString()),
null,
null,
"timestamp_millis ASC",
limit.toString(),
)
return cursor.use {
buildList {
while (it.moveToNext()) add(it.toContinuousSample(sessionId))
}
}
}
@Synchronized
fun finishedSessionSummaries(limit: Int): List<StoredSessionSummary> {
val sql =
"""
SELECT s.id, s.started_at_millis, s.ended_at_millis, s.device_name,
COUNT(hr.id) AS sample_count,
ROUND(AVG(hr.bpm)) AS average_bpm,
MAX(hr.bpm) AS peak_bpm,
MIN(hr.bpm) AS min_bpm
FROM $SESSION_TABLE s
LEFT JOIN $CONTINUOUS_SAMPLE_TABLE hr
ON hr.timestamp_millis BETWEEN s.started_at_millis AND COALESCE(s.ended_at_millis, strftime('%s','now') * 1000)
AND hr.bpm BETWEEN $MIN_VALID_BPM AND $MAX_VALID_BPM
AND (hr.contact_detected IS NULL OR hr.contact_detected = 1)
WHERE s.status = ?
GROUP BY s.id
HAVING sample_count > 0
ORDER BY s.started_at_millis DESC
LIMIT ?
""".trimIndent()
val cursor = readableDatabase.rawQuery(sql, arrayOf(STATUS_FINISHED, limit.toString()))
return cursor.use {
buildList {
while (it.moveToNext()) {
val startedAt = it.getLong(1)
val endedAt = if (it.isNull(2)) startedAt else it.getLong(2)
add(
StoredSessionSummary(
id = it.getLong(0),
startedAtMillis = startedAt,
endedAtMillis = endedAt,
durationMillis = (endedAt - startedAt).coerceAtLeast(0),
deviceName = it.getString(3),
sampleCount = it.getInt(4),
averageBpm = it.getInt(5),
peakBpm = it.getInt(6),
minBpm = it.getInt(7),
),
)
}
}
}
}
private fun createWorkoutSessionsTable(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS $SESSION_TABLE (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at_millis INTEGER NOT NULL,
ended_at_millis INTEGER,
status TEXT NOT NULL,
device_id TEXT,
device_name TEXT
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS idx_sessions_status_start ON $SESSION_TABLE(status, started_at_millis)")
}
private fun createLegacySamplesTable(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS $LEGACY_SAMPLE_TABLE (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
timestamp_millis INTEGER NOT NULL,
bpm INTEGER NOT NULL,
contact_detected INTEGER,
rr_intervals_ms TEXT NOT NULL DEFAULT '',
device_name TEXT,
FOREIGN KEY(session_id) REFERENCES $SESSION_TABLE(id)
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS idx_samples_session_time ON $LEGACY_SAMPLE_TABLE(session_id, timestamp_millis)")
}
private fun createContinuousSamplesTable(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS $CONTINUOUS_SAMPLE_TABLE (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp_millis INTEGER NOT NULL,
bpm INTEGER NOT NULL,
contact_detected INTEGER,
rr_intervals_ms TEXT NOT NULL DEFAULT '',
device_name TEXT
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS idx_continuous_samples_time ON $CONTINUOUS_SAMPLE_TABLE(timestamp_millis)")
}
private fun android.database.Cursor.toSession(): StoredSession = StoredSession(
id = getLong(0),
startedAtMillis = getLong(1),
endedAtMillis = if (isNull(2)) null else getLong(2),
status = getString(3),
deviceId = getString(4),
deviceName = getString(5),
)
private fun android.database.Cursor.toContinuousSample(sessionId: Long?): StoredSample = StoredSample(
id = getLong(0),
sessionId = sessionId,
timestampMillis = getLong(1),
bpm = getInt(2),
contactDetected = if (isNull(3)) null else getInt(3) == 1,
rrIntervalsMs = getString(4).orEmpty().split(',').mapNotNull { it.toIntOrNull() },
deviceName = getString(5),
)
companion object {
private const val DATABASE_NAME = "fightrate_rn_capture.db"
private const val DATABASE_VERSION = 2
private const val SESSION_TABLE = "workout_sessions"
private const val LEGACY_SAMPLE_TABLE = "heart_rate_samples"
private const val CONTINUOUS_SAMPLE_TABLE = "continuous_heart_rate_samples"
const val STATUS_ACTIVE = "active"
const val STATUS_FINISHED = "finished"
private val SESSION_COLUMNS = arrayOf("id", "started_at_millis", "ended_at_millis", "status", "device_id", "device_name")
private val CONTINUOUS_SAMPLE_COLUMNS = arrayOf("id", "timestamp_millis", "bpm", "contact_detected", "rr_intervals_ms", "device_name")
private const val MIN_VALID_BPM = 30
private const val MAX_VALID_BPM = 240
private const val VALID_SAMPLE_SELECTION = "bpm BETWEEN $MIN_VALID_BPM AND $MAX_VALID_BPM AND (contact_detected IS NULL OR contact_detected = 1)"
}
}
data class StoredSession(
val id: Long,
val startedAtMillis: Long,
val endedAtMillis: Long?,
val status: String,
val deviceId: String?,
val deviceName: String?,
)
data class StoredSample(
val id: Long,
val sessionId: Long?,
val timestampMillis: Long,
val bpm: Int,
val contactDetected: Boolean?,
val rrIntervalsMs: List<Int>,
val deviceName: String?,
)
data class StoredSessionSummary(
val id: Long,
val startedAtMillis: Long,
val endedAtMillis: Long,
val durationMillis: Long,
val deviceName: String?,
val sampleCount: Int,
val averageBpm: Int,
val peakBpm: Int,
val minBpm: Int,
)
@@ -0,0 +1,14 @@
package com.healthtracking.fightrate.capture
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class FightRateNativePackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(FightRateCaptureModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<in Nothing, in Nothing>> =
emptyList()
}
@@ -0,0 +1,55 @@
package com.healthtracking.fightrate.capture
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class ParsedHeartRateMeasurement(
val bpm: Int,
val contactDetected: Boolean?,
val rrIntervalsMs: List<Int>,
)
object HeartRateMeasurementParser {
fun parse(value: ByteArray): ParsedHeartRateMeasurement {
require(value.size >= 2) { "Pacote de batimento incompleto" }
val flags = value[0].toInt() and 0xFF
val isUInt16 = flags and 0x01 != 0
val contactSupported = flags and 0x04 != 0
val contactDetected = if (contactSupported) flags and 0x02 != 0 else null
val hasRrIntervals = flags and 0x10 != 0
val hasEnergyExpended = flags and 0x08 != 0
var offset = 1
val bpm = if (isUInt16) {
require(value.size >= 3) { "Pacote de batimento 16-bit incompleto" }
val parsed = ByteBuffer.wrap(value, offset, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
offset += 2
parsed
} else {
val parsed = value[offset].toInt() and 0xFF
offset += 1
parsed
}
if (hasEnergyExpended) {
require(value.size >= offset + 2) { "Pacote de energia expended incompleto" }
offset += 2
}
val rrIntervalsMs = mutableListOf<Int>()
if (hasRrIntervals) {
while (offset + 1 < value.size) {
val rrRaw = ByteBuffer.wrap(value, offset, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
rrIntervalsMs.add(((rrRaw * 1000.0) / 1024.0).toInt())
offset += 2
}
}
return ParsedHeartRateMeasurement(
bpm = bpm,
contactDetected = contactDetected,
rrIntervalsMs = rrIntervalsMs,
)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">FightRateRN</string>
<string name="app_name">FightRate</string>
</resources>