aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/com/arslaancodes
diff options
context:
space:
mode:
authorArslaan Pathan <[email protected]>2026-03-30 22:13:08 +1300
committerArslaan Pathan <[email protected]>2026-03-30 22:13:08 +1300
commite92a1f4a6babca0827410426ae59cc7b24f401da (patch)
treea9e93b75c41876368a4f66a7e4e9665c6832388f /app/src/main/java/com/arslaancodes
downloadzwzn-freefit-android-e92a1f4a6babca0827410426ae59cc7b24f401da.tar.xz
zwzn-freefit-android-e92a1f4a6babca0827410426ae59cc7b24f401da.zip
chore: Initial commit
Diffstat (limited to 'app/src/main/java/com/arslaancodes')
-rw-r--r--app/src/main/java/com/arslaancodes/zwznfreefit/BleManager.kt121
-rw-r--r--app/src/main/java/com/arslaancodes/zwznfreefit/DeviceActivity.kt73
-rw-r--r--app/src/main/java/com/arslaancodes/zwznfreefit/MainActivity.kt75
3 files changed, 269 insertions, 0 deletions
diff --git a/app/src/main/java/com/arslaancodes/zwznfreefit/BleManager.kt b/app/src/main/java/com/arslaancodes/zwznfreefit/BleManager.kt
new file mode 100644
index 0000000..c138ead
--- /dev/null
+++ b/app/src/main/java/com/arslaancodes/zwznfreefit/BleManager.kt
@@ -0,0 +1,121 @@
+package com.arslaancodes.zwznfreefit
+
+import android.bluetooth.*
+import android.bluetooth.le.*
+import android.content.Context
+import android.os.ParcelUuid
+import java.util.UUID
+
+val SERVICE_UUID = UUID.fromString("6E40FC00-B5A3-F393-E0A9-E50E24DCCA9E")
+val WRITE_UUID = UUID.fromString("6E40FC20-B5A3-F393-E0A9-E50E24DCCA9E")
+val NOTIFY_UUID = UUID.fromString("6E40FC21-B5A3-F393-E0A9-E50E24DCCA9E")
+
+class BleManager(private val context: Context) {
+ companion object {
+ lateinit var instance: BleManager
+ }
+
+ init {
+ instance = this
+ }
+
+ private val adapter = BluetoothAdapter.getDefaultAdapter()
+ private var gatt: BluetoothGatt? = null
+ private var onNotify: ((ByteArray) -> Unit)? = null
+
+ fun scan(onFound: (BluetoothDevice) -> Unit) {
+ val scanner = adapter.bluetoothLeScanner
+ android.util.Log.d("BLE", "Starting scan, scanner=$scanner")
+ if (scanner == null) {
+ android.util.Log.e("BLE", "Scanner is null! Bluetooth off?")
+ return
+ }
+ val settings = ScanSettings.Builder().build()
+ scanner.startScan(null, settings, object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ android.util.Log.d("BLE", "Found: ${result.device.name} ${result.device.address}")
+ onFound(result.device)
+ }
+ override fun onScanFailed(errorCode: Int) {
+ android.util.Log.e("BLE", "Scan failed: $errorCode")
+ }
+ })
+ }
+
+ fun connect(device: BluetoothDevice, onConnected: (BluetoothGatt) -> Unit) {
+ gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
+ override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ g.discoverServices()
+ }
+ }
+ override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
+ val notifyChar = g.getService(SERVICE_UUID)?.getCharacteristic(NOTIFY_UUID)
+ if (notifyChar != null) {
+ g.setCharacteristicNotification(notifyChar, true)
+ val descriptor = notifyChar.getDescriptor(
+ UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+ )
+ descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ g.writeDescriptor(descriptor)
+ }
+ onConnected(g)
+ }
+ override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
+ if (characteristic.uuid == NOTIFY_UUID) {
+ onNotify?.invoke(characteristic.value)
+ }
+ }
+ })
+ }
+
+ fun write(data: ByteArray) {
+ val char = gatt?.getService(SERVICE_UUID)?.getCharacteristic(WRITE_UUID)
+ char?.value = data
+ gatt?.writeCharacteristic(char)
+ }
+
+ fun syncTime() {
+ val now = System.currentTimeMillis() / 1000
+ val tzOffset = java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
+
+ val packet = ByteArray(12)
+ packet[0] = 0x01
+ packet[1] = (now shr 24).toByte()
+ packet[2] = (now shr 16).toByte()
+ packet[3] = (now shr 8).toByte()
+ packet[4] = now.toByte()
+ packet[5] = (tzOffset shr 24).toByte()
+ packet[6] = (tzOffset shr 16).toByte()
+ packet[7] = (tzOffset shr 8).toByte()
+ packet[8] = tzOffset.toByte()
+ packet[9] = 0x00
+ packet[10] = 0x00 // language code, 0 = english
+ packet[11] = 0x00
+
+ write(packet)
+ }
+
+ fun switchFindBand(flag: Int) {
+ // find/vibrate band
+ // these functions are usually named after their freefit/ferefit counterparts
+ val packet = ByteArray(2)
+ packet[0] = 0x51 // command
+ packet[1] = flag.toByte() // 0x01 = start, 0x00 = stop
+ write(packet)
+ }
+
+ fun configRealTimeMeasure(type: Int, start: Boolean, onResult: (ByteArray) -> Unit) {
+ // measure heart rate/other stuff
+ // confirmed working types so far is just 0x00 for heart rate, will RE more later
+ // yet again, function named after freefit counterparts from RE'd protocol/decompiled APKs
+ // all code in this file is intellectual property of Arslaan Pathan, no pieces of decompiled freefit code have been used anywhere in the app
+ // freefit decompilation was merely used as a reference for reverse-engineering the protocol
+ onNotify = onResult
+ val packet = ByteArray(3)
+ packet[0] = 0x60.toByte()
+ packet[1] = type.toByte()
+ packet[2] = if (start) 0x01.toByte() else 0x00.toByte()
+ write(packet)
+ }
+}
diff --git a/app/src/main/java/com/arslaancodes/zwznfreefit/DeviceActivity.kt b/app/src/main/java/com/arslaancodes/zwznfreefit/DeviceActivity.kt
new file mode 100644
index 0000000..38862bd
--- /dev/null
+++ b/app/src/main/java/com/arslaancodes/zwznfreefit/DeviceActivity.kt
@@ -0,0 +1,73 @@
+package com.arslaancodes.zwznfreefit
+
+import android.bluetooth.BluetoothAdapter
+import android.os.Bundle
+import android.widget.TextView
+import android.widget.Button
+import androidx.appcompat.app.AppCompatActivity
+
+class DeviceActivity : AppCompatActivity() {
+ private lateinit var statusText: TextView
+ private val handler = android.os.Handler(android.os.Looper.getMainLooper())
+ private val syncRunnable = object : Runnable {
+ override fun run() {
+ BleManager.instance.syncTime()
+ handler.postDelayed(this, 5 * 60 * 1000)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ handler.post(syncRunnable)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ handler.removeCallbacks(syncRunnable)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_device)
+ window.decorView.systemUiVisibility = (
+ android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ )
+
+ val address = intent.getStringExtra("device_address")
+ val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
+
+ statusText = findViewById(R.id.deviceStatusText)
+ statusText.text = "Connected to: ${device.name ?: device.address}"
+ BleManager.instance.syncTime();
+ findViewById<Button>(R.id.syncTimeButton).setOnClickListener {
+ BleManager.instance.syncTime()
+ }
+
+ findViewById<Button>(R.id.findBandButton).setOnClickListener {
+ BleManager.instance.switchFindBand(0x01)
+ }
+
+ findViewById<Button>(R.id.stopFindBandButton).setOnClickListener {
+ BleManager.instance.switchFindBand(0x00)
+ }
+
+ findViewById<Button>(R.id.configRealTimeMeasureHeartRateButton).setOnClickListener {
+ BleManager.instance.configRealTimeMeasure(0x00, true, { data ->
+ runOnUiThread {
+ if (data[0] == 0x94.toByte()) {
+ val heartRate = data[1].toInt() and 0xFF
+ statusText.text = "Got generic measurement ${heartRate}"
+ }
+ if (data[0] == 0xE1.toByte()) {
+ val rate1 = data[1].toInt() and 0xFF
+ val rate2 = data[5].toInt() and 0xFF
+ val rate3 = data[6].toInt() and 0xFF
+ val rate4 = data[7].toInt() and 0xFF
+ statusText.text = "Got aggregate rates ${rate1}, ${rate2}, ${rate3}, ${rate4}"
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/app/src/main/java/com/arslaancodes/zwznfreefit/MainActivity.kt b/app/src/main/java/com/arslaancodes/zwznfreefit/MainActivity.kt
new file mode 100644
index 0000000..59bff65
--- /dev/null
+++ b/app/src/main/java/com/arslaancodes/zwznfreefit/MainActivity.kt
@@ -0,0 +1,75 @@
+package com.arslaancodes.zwznfreefit
+
+import android.Manifest
+import android.bluetooth.BluetoothDevice
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.widget.*
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import android.content.Intent
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var bleManager: BleManager
+ private lateinit var statusText: TextView
+ private lateinit var scanButton: Button
+ private lateinit var deviceList: ListView
+
+ private val devices = mutableListOf<BluetoothDevice>()
+ private val deviceNames = mutableListOf<String>()
+ private lateinit var adapter: ArrayAdapter<String>
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+ window.decorView.systemUiVisibility = (
+ android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ )
+
+ bleManager = BleManager(this)
+ statusText = findViewById(R.id.statusText)
+ scanButton = findViewById(R.id.scanButton)
+ deviceList = findViewById(R.id.deviceList)
+
+ adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, deviceNames)
+ deviceList.adapter = adapter
+
+ deviceList.setOnItemClickListener { _, _, position, _ ->
+ val device = devices[position]
+ statusText.text = "Connecting to ${device.name ?: device.address}..."
+ bleManager.connect(device) {
+ runOnUiThread {
+ val intent = Intent(this, DeviceActivity::class.java)
+ intent.putExtra("device_address", device.address)
+ startActivity(intent)
+ }
+ }
+ }
+
+ scanButton.setOnClickListener {
+ if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this,
+ arrayOf(
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ ), 1)
+ return@setOnClickListener
+ }
+ devices.clear()
+ deviceNames.clear()
+ adapter.notifyDataSetChanged()
+ statusText.text = "Scanning..."
+ bleManager.scan { device ->
+ runOnUiThread {
+ if (devices.none { it.address == device.address }) {
+ devices.add(device)
+ deviceNames.add(device.name ?: device.address)
+ adapter.notifyDataSetChanged()
+ }
+ }
+ }
+ }
+ }
+}