refactor: extract BLE logic from MainActivity to BleRelayManager

This commit is contained in:
eddy 2026-07-29 19:58:44 +02:00
commit 404f46acf8

View file

@ -1,193 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
package com.eddy.atomrelay package com.eddy.atomrelay
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.UUID
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity(), BleCallbacks {
companion object { companion object {
private const val TAG = "AtomRelay"
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val DEVICE_NAME = "SylDa_Server"
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
private const val REQUEST_SETTINGS = 1 private const val REQUEST_SETTINGS = 1
private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L
private const val POST_SETTINGS_RESCAN_MS = 500L private const val POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L private const val ON_RESUME_SCAN_DELAY_MS = 300L
private const val MAX_SCAN_RETRIES = 3
} }
private var bluetoothGatt: BluetoothGatt? = null private lateinit var manager: BleRelayManager
private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false
private var scanTimedOut = false
private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
private var seq = 1
private var hmacKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
private var lastTheme: String? = null
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var relay1Button: Button private lateinit var relay1Button: Button
private lateinit var relay2Button: Button private lateinit var relay2Button: Button
private var lastTheme: String? = null
private val scanCallback = object : ScanCallback() { override fun onStatusChanged(status: BleStatus) {
override fun onScanResult(callbackType: Int, result: ScanResult) { when (status) {
val device = result.device BleStatus.Connected -> {
val address = device?.address ?: return statusText.text = getString(R.string.status_connected)
val name = device?.name ?: "" setupRelayButton()
Log.d(TAG, "Found device: $name ($address)") setupRelay2Button()
if (triedAddresses.contains(address)) { relay1Button.isEnabled = true
Log.d(TAG, "Skipping already-tried device $address") relay2Button.isEnabled = true
return
} }
stopScan() BleStatus.Disconnected -> {
connectToDevice(device) statusText.text = getString(R.string.status_connecting)
} relay1Button.isEnabled = false
} relay2Button.isEnabled = false
private val gattCallback = object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "GATT connection state change error: status=$status newState=$newState")
} }
if (newState == BluetoothProfile.STATE_CONNECTED) { BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning)
if (hmacKey == null) { BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting)
Log.w(TAG, "GATT connected but no key — disconnecting") BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key)
runOnUiThread { statusText.text = getString(R.string.status_no_key) } BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device)
gatt.disconnect() BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found)
return BleStatus.ServiceError -> statusText.text = getString(R.string.status_service_error)
BleStatus.CommError -> statusText.text = getString(R.string.status_comm_error)
BleStatus.BluetoothDisabled -> statusText.text = getString(R.string.status_bluetooth_disabled)
BleStatus.PermissionsDenied -> {
statusText.text = getString(R.string.status_permissions_denied)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
BleStatus.DeviceNotFound -> {
relay1Button.setOnTouchListener(null)
relay2Button.setOnTouchListener(null)
relay1Button.setOnClickListener(null)
relay2Button.setOnClickListener(null)
relay1Button.text = getString(R.string.button_retry_scan)
relay2Button.text = getString(R.string.button_hold)
relay2Button.isEnabled = false
relay1Button.isEnabled = true
relay1Button.setOnClickListener {
setupRelayButton()
setupRelay2Button()
manager.startScan()
} }
Log.d(TAG, "GATT connected")
bluetoothGatt = gatt
runOnUiThread { statusText.text = getString(R.string.status_discovering_services) }
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected")
runOnUiThread {
statusText.text = getString(R.string.status_connecting)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
seq = 1
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "Service discovery failed (status=$status)")
runOnUiThread { statusText.text = getString(R.string.status_service_error) }
return
}
Log.d(TAG, "Services discovered, requesting MTU...")
gatt.requestMtu(256)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "MTU negotiation failed (status=$status)")
}
Log.d(TAG, "MTU changed to $mtu")
val service = gatt.getService(SERVICE_UUID)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
if (relayCharacteristic != null && uuidChar != null) {
Log.d(TAG, "Reading device UUID characteristic...")
gatt.readCharacteristic(uuidChar)
} else {
Log.e(TAG, "Service or characteristics not found")
runOnUiThread { statusText.text = getString(R.string.status_service_not_found) }
}
} else {
Log.e(TAG, "Service not found")
runOnUiThread { statusText.text = getString(R.string.status_service_not_found) }
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val storedUuid = deviceUuid
if (storedUuid != null && value.contentEquals(storedUuid)) {
Log.d(TAG, "UUID verified, setting up relay")
relayCharacteristic?.let { rc ->
gatt.setCharacteristicNotification(rc, true)
val descriptor = rc.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
runOnUiThread {
setupRelayButton()
setupRelay2Button()
statusText.text = getString(R.string.status_connected)
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
} else {
Log.w(TAG, "UUID mismatch — disconnecting")
triedAddresses.add(gatt.device.address)
runOnUiThread { statusText.text = getString(R.string.status_wrong_device) }
gatt.disconnect()
}
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d(TAG, "Write confirmed by local BLE stack (status=$status)")
} else {
Log.w(TAG, "Write failed (status=$status)")
runOnUiThread { statusText.text = getString(R.string.status_comm_error) }
} }
} }
} }
@ -198,6 +80,8 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
manager = BleRelayManager(this, this)
statusText = findViewById(R.id.statusText) statusText = findViewById(R.id.statusText)
relay1Button = findViewById(R.id.relay1Button) relay1Button = findViewById(R.id.relay1Button)
relay2Button = findViewById(R.id.relay2Button) relay2Button = findViewById(R.id.relay2Button)
@ -214,14 +98,10 @@ class MainActivity : AppCompatActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) { if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) {
stopScan() manager.disconnect()
bluetoothGatt?.close() manager.resetTriedAddresses()
bluetoothGatt = null manager.updateKeys()
relayCharacteristic = null handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
seq = 1
triedAddresses.clear()
loadState()
handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS)
} }
} }
@ -233,54 +113,17 @@ class MainActivity : AppCompatActivity() {
recreate() recreate()
return return
} }
loadState() if (!manager.hasPermissions()) {
if (hmacKey == null || deviceUuid == null) { requestPermissions()
statusText.text = getString(R.string.status_no_key) return
} else if (!isScanning && bluetoothGatt == null) {
triedAddresses.clear()
handler.postDelayed(scanRunnable, ON_RESUME_SCAN_DELAY_MS)
} }
manager.resetTriedAddresses()
handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS)
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
handler.removeCallbacks(scanRunnable) manager.stopScan()
stopScan()
}
private fun loadState() {
val prefs = PrefsHelper.getPrefs(this)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
hmacKey = hexKey?.let { hexStringToBytes(it) }
val hexUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}, UUID: ${if (deviceUuid != null) "${deviceUuid!!.size} bytes" else "none"}, seq=$seq")
}
private fun hexStringToBytes(hex: String): ByteArray? {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray {
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
val hmacData = ByteArray(5)
hmacData[0] = cmd
System.arraycopy(seqBytes, 0, hmacData, 1, 4)
val hmac = hmacSha256(hmacData, key)
val packet = ByteArray(HMAC_PKT_SIZE)
packet[0] = cmd
System.arraycopy(seqBytes, 0, packet, 1, 4)
System.arraycopy(hmac, 0, packet, 5, HMAC_LEN)
return packet
} }
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
@ -288,11 +131,9 @@ class MainActivity : AppCompatActivity() {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) { if (requestCode == 1) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startScan() manager.startScan()
} else { } else {
statusText.text = getString(R.string.status_permissions_denied) onStatusChanged(BleStatus.PermissionsDenied)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
} }
} }
} }
@ -303,12 +144,12 @@ class MainActivity : AppCompatActivity() {
relay1Button.setOnTouchListener { _, event -> relay1Button.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
sendRelayCommand(0x01.toByte()) manager.sendRelayCommand(0x01.toByte())
relay1Button.text = getString(R.string.button_on) relay1Button.text = getString(R.string.button_on)
true true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(0x00.toByte()) manager.sendRelayCommand(0x00.toByte())
relay1Button.text = getString(R.string.button_hold) relay1Button.text = getString(R.string.button_hold)
true true
} }
@ -323,12 +164,12 @@ class MainActivity : AppCompatActivity() {
relay2Button.setOnTouchListener { _, event -> relay2Button.setOnTouchListener { _, event ->
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
sendRelayCommand(0x02.toByte()) manager.sendRelayCommand(0x02.toByte())
relay2Button.text = getString(R.string.button_on) relay2Button.text = getString(R.string.button_on)
true true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
sendRelayCommand(0x03.toByte()) manager.sendRelayCommand(0x03.toByte())
relay2Button.text = getString(R.string.button_hold) relay2Button.text = getString(R.string.button_hold)
true true
} }
@ -337,132 +178,14 @@ class MainActivity : AppCompatActivity() {
} }
} }
@SuppressLint("MissingPermission")
private fun startScan() {
if (isScanning) return
if (!hasPermissions()) {
requestPermissions()
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || !adapter.isEnabled) {
statusText.text = getString(R.string.status_bluetooth_disabled)
return
}
if (hmacKey == null || deviceUuid == null) {
statusText.text = getString(R.string.status_no_key)
return
}
isScanning = true
scanTimedOut = false
statusText.text = getString(R.string.status_scanning)
Log.d(TAG, "Starting BLE scan with service UUID filter")
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
adapter.bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
scanTimeoutRunnable = Runnable {
if (isScanning) {
scanTimedOut = true
stopScan()
}
}
handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS)
}
@SuppressLint("MissingPermission")
private fun stopScan() {
if (!isScanning) return
isScanning = false
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (scanTimedOut) {
if (scanTimeoutRetries < MAX_SCAN_RETRIES) {
scanTimeoutRetries++
Log.d(TAG, "Scan timeout, retry $scanTimeoutRetries/$MAX_SCAN_RETRIES")
statusText.text = getString(R.string.status_scanning)
handler.postDelayed(scanRunnable, 2000L)
return
}
statusText.text = getString(R.string.status_device_not_found)
relay1Button.setOnTouchListener(null)
relay2Button.setOnTouchListener(null)
relay1Button.setOnClickListener(null)
relay2Button.setOnClickListener(null)
relay1Button.text = getString(R.string.button_retry_scan)
relay2Button.text = getString(R.string.button_hold)
relay2Button.isEnabled = false
relay1Button.isEnabled = true
relay1Button.setOnClickListener {
setupRelayButton()
setupRelay2Button()
startScan()
}
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
runOnUiThread { statusText.text = getString(R.string.status_connecting) }
Log.d(TAG, "Connecting to ${device.address}")
bluetoothGatt = device.connectGatt(this, false, gattCallback)
}
@SuppressLint("MissingPermission")
private fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return
val packet = buildPacket(cmd, key)
char.value = packet
// WRITE_TYPE_NO_RESPONSE is intentional. Previously, WRITE_TYPE_DEFAULT (with ACK) was
// used, but the BLE write acknowledgement took long enough that the user could release
// the button before the ACK arrived. When ACTION_UP fired during a pending write, the
// release was silently ignored and the relay (and app) got stuck in the ON state.
// WRITE_TYPE_NO_RESPONSE removes that latency and allows immediate ON→OFF sequences.
// This means the UI reflects the user's intent ("pressed" / "released") rather than
// confirmed relay state — an acceptable trade-off for a hold-to-activate interaction.
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
seq++
if (seq > PrefsHelper.SEQ_MAX) seq = 1
PrefsHelper.getPrefs(this).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply()
Log.d(TAG, "Sent relay cmd 0x${cmd.toString(16)} (seq=${seq - 1})")
}
private fun getRequiredPermissions(): Array<String> {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
private fun hasPermissions(): Boolean {
return getRequiredPermissions().all {
ActivityCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestPermissions() { private fun requestPermissions() {
ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1) val perms = manager.getRequiredPermissions()
ActivityCompat.requestPermissions(this, perms, 1)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
stopScan() manager.destroy()
bluetoothGatt?.close()
} }
} }