From 404f46acf813a996a3d9a77ae79b7ba65a2f14b4 Mon Sep 17 00:00:00 2001 From: eddy Date: Wed, 29 Jul 2026 19:58:44 +0200 Subject: [PATCH] refactor: extract BLE logic from MainActivity to BleRelayManager --- .../java/com/eddy/atomrelay/MainActivity.kt | 403 +++--------------- 1 file changed, 63 insertions(+), 340 deletions(-) diff --git a/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt b/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt index 08321f0..ff2abd2 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt @@ -1,193 +1,75 @@ // SPDX-License-Identifier: GPL-3.0-or-later 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.pm.PackageManager import android.os.Bundle import android.os.Handler import android.os.Looper -import android.os.ParcelUuid -import android.util.Log import android.view.MotionEvent import android.widget.Button import android.widget.ImageButton import android.widget.TextView import androidx.appcompat.app.AppCompatActivity 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 { - 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 SCAN_TIMEOUT_MS = 30000L - private const val RECONNECT_DELAY_MS = 2000L private const val POST_SETTINGS_RESCAN_MS = 500L private const val ON_RESUME_SCAN_DELAY_MS = 300L - private const val MAX_SCAN_RETRIES = 3 } - private var bluetoothGatt: BluetoothGatt? = null - private var relayCharacteristic: BluetoothGattCharacteristic? = null - private var isScanning = false - private var scanTimedOut = false - private var scanTimeoutRetries = 0 + private lateinit var manager: BleRelayManager 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() - private var lastTheme: String? = null private lateinit var statusText: TextView private lateinit var relay1Button: Button private lateinit var relay2Button: Button + private var lastTheme: String? = null - private val scanCallback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - val device = result.device - val address = device?.address ?: return - val name = device?.name ?: "" - Log.d(TAG, "Found device: $name ($address)") - if (triedAddresses.contains(address)) { - Log.d(TAG, "Skipping already-tried device $address") - return + override fun onStatusChanged(status: BleStatus) { + when (status) { + BleStatus.Connected -> { + statusText.text = getString(R.string.status_connected) + setupRelayButton() + setupRelay2Button() + relay1Button.isEnabled = true + relay2Button.isEnabled = true } - stopScan() - connectToDevice(device) - } - } - - 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") + BleStatus.Disconnected -> { + statusText.text = getString(R.string.status_connecting) + relay1Button.isEnabled = false + relay2Button.isEnabled = false } - if (newState == BluetoothProfile.STATE_CONNECTED) { - if (hmacKey == null) { - Log.w(TAG, "GATT connected but no key — disconnecting") - runOnUiThread { statusText.text = getString(R.string.status_no_key) } - gatt.disconnect() - return + BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning) + BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting) + BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key) + BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device) + BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found) + 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) setContentView(R.layout.activity_main) + manager = BleRelayManager(this, this) + statusText = findViewById(R.id.statusText) relay1Button = findViewById(R.id.relay1Button) relay2Button = findViewById(R.id.relay2Button) @@ -214,14 +98,10 @@ class MainActivity : AppCompatActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) { - stopScan() - bluetoothGatt?.close() - bluetoothGatt = null - relayCharacteristic = null - seq = 1 - triedAddresses.clear() - loadState() - handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS) + manager.disconnect() + manager.resetTriedAddresses() + manager.updateKeys() + handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS) } } @@ -233,54 +113,17 @@ class MainActivity : AppCompatActivity() { recreate() return } - loadState() - if (hmacKey == null || deviceUuid == null) { - statusText.text = getString(R.string.status_no_key) - } else if (!isScanning && bluetoothGatt == null) { - triedAddresses.clear() - handler.postDelayed(scanRunnable, ON_RESUME_SCAN_DELAY_MS) + if (!manager.hasPermissions()) { + requestPermissions() + return } + manager.resetTriedAddresses() + handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS) } override fun onPause() { super.onPause() - handler.removeCallbacks(scanRunnable) - 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 + manager.stopScan() } @Deprecated("Deprecated in Java") @@ -288,11 +131,9 @@ class MainActivity : AppCompatActivity() { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == 1) { if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { - startScan() + manager.startScan() } else { - statusText.text = getString(R.string.status_permissions_denied) - relay1Button.isEnabled = false - relay2Button.isEnabled = false + onStatusChanged(BleStatus.PermissionsDenied) } } } @@ -303,12 +144,12 @@ class MainActivity : AppCompatActivity() { relay1Button.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { - sendRelayCommand(0x01.toByte()) + manager.sendRelayCommand(0x01.toByte()) relay1Button.text = getString(R.string.button_on) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - sendRelayCommand(0x00.toByte()) + manager.sendRelayCommand(0x00.toByte()) relay1Button.text = getString(R.string.button_hold) true } @@ -323,12 +164,12 @@ class MainActivity : AppCompatActivity() { relay2Button.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { - sendRelayCommand(0x02.toByte()) + manager.sendRelayCommand(0x02.toByte()) relay2Button.text = getString(R.string.button_on) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - sendRelayCommand(0x03.toByte()) + manager.sendRelayCommand(0x03.toByte()) relay2Button.text = getString(R.string.button_hold) 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 { - 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() { - ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1) + val perms = manager.getRequiredPermissions() + ActivityCompat.requestPermissions(this, perms, 1) } override fun onDestroy() { super.onDestroy() handler.removeCallbacksAndMessages(null) - stopScan() - bluetoothGatt?.close() + manager.destroy() } -} \ No newline at end of file +}