Rename package com.eddy.atomrelay -> nl.eddydevink.sylda, Dutch translations, UI cleanup

- Package renamed to nl.eddydevink.sylda (matches eddydevink.nl domain + Sylda app name)
- All status texts translated to Dutch (Niet verbonden, Verbonden, Zoeken, etc.)
- Removed exchange key button, build number, and success toasts
- Button text: Firmware bijwerken, instruction text shortened
- App title changed to Sylda, 'instellingen' label next to settings gear
- ON/HOLD changed to AAN/VAST
This commit is contained in:
eddy 2026-07-29 23:09:43 +02:00
commit 4c022b8259
13 changed files with 48 additions and 65 deletions

View file

@ -4,14 +4,14 @@ plugins {
}
android {
namespace 'com.eddy.atomrelay'
namespace 'nl.eddydevink.sylda'
// compileSdk/targetSdk stays on 34 (Android 14) by deliberate choice. The target audience
// consists of physically disabled users who are very reluctant to update their phones. Staying
// on API 34 maximises device compatibility. Re-evaluate when the user base has migrated.
compileSdk 34
defaultConfig {
applicationId "com.eddy.atomrelay"
applicationId "nl.eddydevink.sylda"
minSdk 26
targetSdk 34
versionCode 1

View file

@ -13,7 +13,7 @@
<application
android:allowBackup="false"
android:label="Atom Relay"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">

View file

@ -0,0 +1,313 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGatt
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.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class BleRelayManager(
private val context: Context,
private val callbacks: BleCallbacks
) {
companion object {
private const val TAG = "BleRelayManager"
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 SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L
private const val MAX_SCAN_RETRIES = 3
private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32
private const val BLE_MTU = 256
}
private var bluetoothGatt: BluetoothGatt? = null
private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false
private var scanTimedOut = false
private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper())
private var seq = 1
private var hmacKey: ByteArray? = null
private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>()
private val scanRunnable = Runnable { startScan() }
private var scanTimeoutRunnable: Runnable? = null
@SuppressLint("MissingPermission")
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device ?: return
val address = device.address ?: return
Log.d(TAG, "Found device: ${device.name ?: ""} ($address)")
if (triedAddresses.contains(address)) {
Log.d(TAG, "Skipping already-tried device $address")
return
}
stopScan()
connectToDevice(device)
}
}
@SuppressLint("MissingPermission")
fun startScan() {
if (isScanning) return
if (!hasPermissions()) {
handler.post { callbacks.onStatusChanged(BleStatus.PermissionsDenied) }
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || !adapter.isEnabled) {
handler.post { callbacks.onStatusChanged(BleStatus.BluetoothDisabled) }
return
}
loadState()
if (hmacKey == null || deviceUuid == null) {
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
return
}
isScanning = true
scanTimedOut = false
scanTimeoutRetries = 0
handler.post { callbacks.onStatusChanged(BleStatus.Scanning) }
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")
fun stopScan() {
handler.removeCallbacks(scanRunnable)
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++
handler.postDelayed(scanRunnable, 2000L)
return
}
handler.post { callbacks.onStatusChanged(BleStatus.DeviceNotFound) }
}
}
fun hasPermissions(): Boolean {
return getRequiredPermissions().all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
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
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
@SuppressLint("MissingPermission")
fun destroy() {
handler.removeCallbacksAndMessages(null)
stopScan()
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
}
@SuppressLint("MissingPermission")
fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return
val packet = buildPacket(cmd, key)
char.value = packet
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char)
seq++
if (seq > PrefsHelper.SEQ_MAX) seq = 1
PrefsHelper.getPrefs(context).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply()
Log.d(TAG, "Sent relay cmd 0x${cmd.toString(16)} (seq=${seq - 1})")
}
@SuppressLint("MissingPermission")
fun disconnect() {
handler.removeCallbacks(scanRunnable)
scanTimeoutRunnable?.let { handler.removeCallbacks(it) }
stopScan()
bluetoothGatt?.disconnect()
}
fun updateKeys() {
loadState()
}
fun resetTriedAddresses() {
triedAddresses.clear()
}
fun isConnected(): Boolean {
return bluetoothGatt != null
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
handler.post { callbacks.onStatusChanged(BleStatus.Connecting) }
Log.d(TAG, "Connecting to ${device.address}")
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}
private val gattCallback = object : android.bluetooth.BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
if (hmacKey == null) {
Log.w(TAG, "GATT connected but no key -- disconnecting")
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
gatt.disconnect()
return
}
Log.d(TAG, "GATT connected")
bluetoothGatt = gatt
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected")
handler.post { callbacks.onStatusChanged(BleStatus.Disconnected) }
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
seq = 1
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: android.bluetooth.BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "Service discovery failed (status=$status)")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceError) }
return
}
Log.d(TAG, "Services discovered, requesting MTU...")
gatt.requestMtu(BLE_MTU)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) {
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")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
} else {
Log.e(TAG, "Service not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicRead(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.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)
}
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
} else {
Log.w(TAG, "UUID mismatch -- disconnecting")
triedAddresses.add(gatt.device.address)
handler.post { callbacks.onStatusChanged(BleStatus.WrongDevice) }
gatt.disconnect()
}
}
}
@SuppressLint("MissingPermission")
override fun onCharacteristicWrite(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "Write failed (status=$status)")
handler.post { callbacks.onStatusChanged(BleStatus.CommError) }
}
}
}
private fun loadState() {
val prefs = PrefsHelper.getPrefs(context)
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: ${hmacKey?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, 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
}
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
enum class BleStatus {
Scanning,
Connecting,
Connected,
Disconnected,
NoKey,
WrongDevice,
ServiceNotFound,
ServiceError,
CommError,
DeviceNotFound,
BluetoothDisabled,
PermissionsDenied
}
interface BleCallbacks {
fun onStatusChanged(status: BleStatus)
}

View file

@ -0,0 +1,134 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Context
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import org.dkaukov.esp32.core.EspFlasherApi
import org.dkaukov.esp32.io.ProgressCallback
import java.io.IOException
import kotlin.coroutines.coroutineContext
class EspFlasher(private val context: Context) {
companion object {
private const val TAG = "EspFlasher"
private const val USB_SETTLE_MS = 500L
private const val KB = 1024
private const val FLASH_RESET_PCT = 95
private const val FLASH_DONE_PCT = 100
}
interface FlashCallback {
fun onProgress(percent: Int, message: String)
}
suspend fun flashDevice(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
try {
withContext(kotlinx.coroutines.Dispatchers.IO) {
doFlash(usbManager, device, callback)
}
} catch (e: CancellationException) {
Log.i(TAG, "Flash cancelled")
throw e
} catch (e: Exception) {
Log.e(TAG, "Flash failed", e)
throw e
}
}
private class FlashStep(
val nameResId: Int,
val address: Int,
val data: ByteArray,
val baseProgress: Int,
val range: Int,
val compress: Boolean
)
private suspend fun doFlash(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
kotlinx.coroutines.delay(USB_SETTLE_MS)
coroutineContext.ensureActive()
val connection = usbManager.openDevice(device)
?: throw IOException(context.getString(R.string.err_usb_open))
try {
val intf = UsbUtils.findCdcDataInterface(device)
?: throw IOException(context.getString(R.string.err_no_cdc))
if (!connection.claimInterface(intf, true)) {
throw IOException(context.getString(R.string.err_usb_claim))
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: throw IOException(context.getString(R.string.err_no_out))
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: throw IOException(context.getString(R.string.err_no_in))
callback.onProgress(0, context.getString(R.string.flash_connecting))
val transport = UsbSerialTransport(connection, epOut, epIn)
coroutineContext.ensureActive()
val assets = context.assets
val bootloader = assets.open("firmware/bootloader.bin").readBytes()
val partitions = assets.open("firmware/partitions.bin").readBytes()
val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes()
val firmware = assets.open("firmware/firmware.bin").readBytes()
val steps = listOf(
FlashStep(R.string.flash_step_bootloader, 0x0, bootloader, 30, 10, false),
FlashStep(R.string.flash_step_partitions, 0x8000, partitions, 40, 10, false),
FlashStep(R.string.flash_step_boot_app0, 0xe000, bootApp0, 50, 10, false),
FlashStep(R.string.flash_step_firmware, 0x10000, firmware, 60, 35, true)
)
callback.onProgress(5, context.getString(R.string.flash_sync))
val api = EspFlasherApi.connect(transport)
.withCallBack(object : ProgressCallback {
override fun onProgress(pct: Float) {
Log.d(TAG, "Lib progress: $pct%")
}
override fun onInfo(value: String) {
Log.d(TAG, "Info: $value")
}
})
callback.onProgress(10, context.getString(R.string.flash_detect))
val detected = api.chipDetect()
val chipId = detected.getChipId()
Log.i(TAG, "Detected chip: $chipId")
callback.onProgress(15, context.getString(R.string.flash_chip, chipId.readableName))
callback.onProgress(20, context.getString(R.string.flash_stub))
val stub = detected.loadStub()
for (step in steps) {
coroutineContext.ensureActive()
val stepName = context.getString(step.nameResId)
val sizeStr = if (step.data.size > KB) "${step.data.size / KB}KB" else "${step.data.size} bytes"
callback.onProgress(step.baseProgress, context.getString(R.string.flash_writing, stepName, sizeStr))
val writeStub = if (step.compress) stub.withCompression(true) else stub.withCompression(false)
writeStub.writeFlash(step.address, step.data)
val endProgress = step.baseProgress + step.range
callback.onProgress(endProgress.coerceAtMost(FLASH_RESET_PCT), context.getString(R.string.flash_written, stepName))
}
callback.onProgress(FLASH_RESET_PCT, context.getString(R.string.flash_resetting))
stub.reset()
callback.onProgress(FLASH_DONE_PCT, context.getString(R.string.flash_done))
} finally {
connection.close()
}
}
}

View file

@ -0,0 +1,218 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
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 androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity(), BleCallbacks {
companion object {
private const val REQUEST_SETTINGS = 1
private const val POST_SETTINGS_RESCAN_MS = 500L
private const val ON_RESUME_SCAN_DELAY_MS = 300L
}
private lateinit var manager: BleRelayManager
private val handler = Handler(Looper.getMainLooper())
private lateinit var statusText: TextView
private lateinit var relay1Button: Button
private lateinit var relay2Button: Button
private var lastTheme: String? = null
private var triedPermissions = false
override fun onStatusChanged(status: BleStatus) {
when (status) {
BleStatus.Connected -> {
statusText.text = getString(R.string.status_connected)
setupRelayButton()
setupRelay2Button()
relay1Button.isEnabled = true
relay2Button.isEnabled = true
}
BleStatus.Disconnected -> {
statusText.text = getString(R.string.status_connecting)
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
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)
statusText.setOnClickListener {
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", packageName, null)
startActivity(intent)
}
relay1Button.isEnabled = false
relay2Button.isEnabled = false
}
BleStatus.DeviceNotFound -> {
relay1Button.setOnTouchListener(null)
relay2Button.setOnTouchListener(null)
relay1Button.setOnClickListener(null)
relay2Button.setOnClickListener(null)
loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_retry_scan)
loadButtonLabel(relay2Button, PrefsHelper.PREF_LABEL2, R.string.button_hold)
relay2Button.isEnabled = false
relay1Button.isEnabled = true
relay1Button.setOnClickListener {
setupRelayButton()
setupRelay2Button()
manager.startScan()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
PrefsHelper.applyTheme(this)
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)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
settingsButton.setOnClickListener {
startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS)
}
setupRelayButton()
setupRelay2Button()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) {
triedPermissions = false
manager.disconnect()
manager.resetTriedAddresses()
manager.updateKeys()
handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS)
}
}
override fun onResume() {
super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
if (current != lastTheme) {
lastTheme = current
recreate()
return
}
loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_hold)
loadButtonLabel(relay2Button, PrefsHelper.PREF_LABEL2, R.string.button_hold)
if (!manager.hasPermissions()) {
if (!triedPermissions) {
triedPermissions = true
ActivityCompat.requestPermissions(this, manager.getRequiredPermissions(), 1)
}
return
}
if (!manager.isConnected()) {
manager.resetTriedAddresses()
handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS)
}
}
override fun onPause() {
super.onPause()
manager.stopScan()
}
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
manager.startScan()
} else {
triedPermissions = false
onStatusChanged(BleStatus.PermissionsDenied)
}
}
}
private fun setupRelayButton() {
relay1Button.setOnClickListener(null)
loadButtonLabel(relay1Button, PrefsHelper.PREF_LABEL1, R.string.button_hold)
val a = Color.rgb(0xE0, 0xE0, 0xE0)
val b = ContextCompat.getColor(this, R.color.colorAccent)
relay1Button.backgroundTintList = null
relay1Button.setBackgroundColor(a)
relay1Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
manager.sendRelayCommand(0x01.toByte())
relay1Button.setBackgroundColor(b)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
manager.sendRelayCommand(0x00.toByte())
relay1Button.setBackgroundColor(a)
true
}
else -> false
}
}
}
private fun setupRelay2Button() {
relay2Button.setOnClickListener(null)
loadButtonLabel(relay2Button, PrefsHelper.PREF_LABEL2, R.string.button_hold)
val a = Color.rgb(0xE0, 0xE0, 0xE0)
val b = ContextCompat.getColor(this, R.color.colorAccent)
relay2Button.backgroundTintList = null
relay2Button.setBackgroundColor(a)
relay2Button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
manager.sendRelayCommand(0x02.toByte())
relay2Button.setBackgroundColor(b)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
manager.sendRelayCommand(0x03.toByte())
relay2Button.setBackgroundColor(a)
true
}
else -> false
}
}
}
private fun loadButtonLabel(button: Button, prefKey: String, fallbackResId: Int) {
val prefs = PrefsHelper.getPrefs(this)
val label = prefs.getString(prefKey, "")?.trim()
button.text = if (label.isNullOrEmpty()) getString(fallbackResId) else label
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
manager.destroy()
}
}

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.content.Context
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatDelegate
object PrefsHelper {
private const val TAG = "PrefsHelper"
const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key"
const val PREF_UUID = "device_uuid"
const val PREF_SEQ = "seq_counter"
const val PREF_THEME = "theme"
const val PREF_LABEL1 = "label_relay1"
const val PREF_LABEL2 = "label_relay2"
const val SEQ_MAX = 1_000_000
fun applyTheme(context: Context) {
val prefs = getPrefs(context)
val mode = when (prefs.getString(PREF_THEME, "system")) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(mode)
}
fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}

View file

@ -0,0 +1,812 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.app.AlertDialog
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
class SettingsActivity : AppCompatActivity() {
companion object {
const val RESULT_SCAN = 100
private const val TAG = "SettingsActivity"
private const val ESP_VENDOR_ID = 0x303a
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
private const val KEY_TIMEOUT_MS = 5000L
private const val SET_KEY_TIMEOUT_MS = 5000L
private const val MAX_KEY_RETRIES = 5
private const val KEY_SETTLE_MS = 1000L
private const val RESPONSE_WAIT_MS = 500L
private const val USB_BULK_TIMEOUT_MS = 1000
private const val FLUSH_TIMEOUT_MS = 100
private const val READ_CHUNK_TIMEOUT_MS = 500
private const val CDC_CONTROL_TIMEOUT_MS = 500
private const val ACTION_USB_PERMISSION = "nl.eddydevink.sylda.USB_PERMISSION"
private const val STYLE_SPINNER = 0
private const val STYLE_HORIZONTAL = 1
private const val USB_READ_BUF_SIZE = 512
private const val HMAC_HEX_LEN = 64
private const val UUID_HEX_LEN = 32
private const val UUID_BYTE_COUNT = 16
private const val HMAC_BYTE_COUNT = 32
private const val VERSION_TIMEOUT_MS = 3000L
private const val REBOOT_WAIT_MS = 5000L
private const val REBOOT_POLL_MS = 500L
private const val REBOOT_MAX_WAIT_MS = 15000L
private const val EXPECTED_FIRMWARE_VERSION = "1"
}
private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
private var currentAction = UsbAction.NONE
private var expectingReboot = false
private var progressDialog: AlertDialog? = null
private var dialog: AlertDialog? = null
private var flasherJob: Job? = null
private var lastTheme: String? = null
private val usbAttachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && currentAction != UsbAction.NONE) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
if (usbManager.hasPermission(device)) {
onDeviceReady(usbManager, device)
} else {
requestUsbPermission(usbManager, device)
}
}
}
}
}
private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_USB_PERMISSION && currentAction != UsbAction.NONE) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null) {
onDeviceReady(usbManager, device)
}
} else {
Toast.makeText(this@SettingsActivity, getString(R.string.usb_access_denied), Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
private val usbDetachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID) {
if (expectingReboot) return
if (dialog != null && currentAction == UsbAction.NONE) {
dialog?.dismiss()
dialog = null
} else if (currentAction != UsbAction.NONE) {
cleanupAction()
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
PrefsHelper.applyTheme(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = getString(R.string.title_settings)
findViewById<Button>(R.id.flashFirmwareButton).setOnClickListener {
startFlashing()
}
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED)
registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_EXPORTED)
registerReceiver(usbDetachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED), Context.RECEIVER_EXPORTED)
setupThemeButtons()
setupLabelEditTexts()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && currentAction == UsbAction.NONE) {
finish()
}
}
override fun onResume() {
super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
if (current != lastTheme) {
lastTheme = current
recreate()
}
}
private fun setupThemeButtons() {
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
val group = findViewById<RadioGroup>(R.id.themeGroup)
group.check(when (current) {
"light" -> R.id.themeLight
"dark" -> R.id.themeDark
else -> R.id.themeSystem
})
data class ThemeOpt(val id: Int, val key: String, val label: String)
val options = listOf(
ThemeOpt(R.id.themeSystem, "system", getString(R.string.theme_system)),
ThemeOpt(R.id.themeLight, "light", getString(R.string.theme_light)),
ThemeOpt(R.id.themeDark, "dark", getString(R.string.theme_dark))
)
options.forEach { (id, key, label) ->
val rb = findViewById<android.widget.RadioButton>(id)
val isActive = key == current
rb.contentDescription = if (isActive)
getString(R.string.theme_applied, label)
else
label
rb.setOnClickListener {
selectTheme(key)
}
}
}
private fun selectTheme(theme: String) {
PrefsHelper.getPrefs(this).edit().putString(PrefsHelper.PREF_THEME, theme).apply()
Handler(Looper.getMainLooper()).post {
PrefsHelper.applyTheme(this)
}
}
override fun onDestroy() {
super.onDestroy()
currentAction = UsbAction.NONE
flasherJob?.cancel()
flasherJob = null
try { unregisterReceiver(usbAttachedReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbPermissionReceiver) } catch (_: Exception) {}
try { unregisterReceiver(usbDetachedReceiver) } catch (_: Exception) {}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
private fun setupLabelEditTexts() {
val prefs = PrefsHelper.getPrefs(this)
val label1 = findViewById<EditText>(R.id.labelRelay1)
val label2 = findViewById<EditText>(R.id.labelRelay2)
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
label1.setText(prefs.getString(PrefsHelper.PREF_LABEL1, ""))
label2.setText(prefs.getString(PrefsHelper.PREF_LABEL2, ""))
label1.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
prefs.edit().putString(PrefsHelper.PREF_LABEL1, label1.text.toString().trim()).apply()
imm.hideSoftInputFromWindow(label1.windowToken, 0)
label1.clearFocus()
}
false
}
label2.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
prefs.edit().putString(PrefsHelper.PREF_LABEL2, label2.text.toString().trim()).apply()
imm.hideSoftInputFromWindow(label2.windowToken, 0)
label2.clearFocus()
}
false
}
}
// === Key Exchange ===
private fun startKeyExchange() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.EXCHANGE_KEY
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doKeyExchange(usbManager, device)
} else {
showWaitingDialog(getString(R.string.title_key_exchange), getString(R.string.msg_connect_usb))
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER)
val prefs = PrefsHelper.getPrefs(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null)
val appKey = prefs.getString(PrefsHelper.PREF_KEY, null)
lifecycleScope.launch(Dispatchers.IO) {
val result = readKeyFromEsp(usbManager, device, appUuid, appKey)
withContext(Dispatchers.Main) {
if (isFinishing || isDestroyed) return@withContext
progressDialog?.dismiss()
progressDialog = null
when (result) {
is KeyResult.Success -> {
prefs.edit()
.putString(PrefsHelper.PREF_UUID, result.deviceUuid)
.putString(PrefsHelper.PREF_KEY, result.hexKey)
.apply()
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Conflict -> {
showConflictDialog(usbManager, device, result.espUuid, result.espKey, appKey ?: "")
}
is KeyResult.Error -> {
val msg = if (result.arg != null) getString(result.resId, result.arg!!) else getString(result.resId)
Toast.makeText(this@SettingsActivity, msg, Toast.LENGTH_LONG).show()
cleanupAction()
}
null -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
}
// === Firmware Flashing ===
private fun startFlashing() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.FLASH
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doFlashing(usbManager, device)
} else {
showWaitingDialog(
getString(R.string.title_firmware_flash),
getString(R.string.msg_flash_download_mode)
)
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doFlashing(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog(getString(R.string.title_firmware_flash), getString(R.string.msg_checking_version), STYLE_SPINNER)
lifecycleScope.launch {
val version = readEspVersion(usbManager, device)
if (version == EXPECTED_FIRMWARE_VERSION && !expectingReboot) {
Log.d(TAG, "Firmware version $version matches, skipping flash")
progressDialog?.dismiss()
progressDialog = null
currentAction = UsbAction.EXCHANGE_KEY
doKeyExchange(usbManager, device)
return@launch
}
Log.d(TAG, "Firmware version ${version ?: "none"}, flashing")
progressDialog?.dismiss()
progressDialog = null
doFlashInner(usbManager, device)
}
}
private fun doFlashInner(usbManager: UsbManager, device: UsbDevice) {
dialog?.dismiss()
dialog = null
showProgressDialog(getString(R.string.title_firmware_flash), getString(R.string.msg_initializing), STYLE_HORIZONTAL)
val flasher = EspFlasher(this)
val progressCallback = object : EspFlasher.FlashCallback {
override fun onProgress(percent: Int, message: String) {
lifecycleScope.launch(Dispatchers.Main) {
val d = progressDialog ?: return@launch
d.findViewById<ProgressBar>(R.id.progressBar)?.progress = percent
d.findViewById<TextView>(R.id.message)?.text = message
}
}
}
flasherJob = lifecycleScope.launch {
try {
flasher.flashDevice(usbManager, device, progressCallback)
if (isFinishing || isDestroyed) return@launch
expectingReboot = true
currentAction = UsbAction.EXCHANGE_KEY
progressDialog?.dismiss()
progressDialog = null
showWaitingDialog(
getString(R.string.title_firmware_flash),
getString(R.string.msg_env_waiting_reboot)
)
} catch (e: kotlinx.coroutines.CancellationException) {
cleanupAction()
throw e
} catch (e: Exception) {
progressDialog?.dismiss()
progressDialog = null
Toast.makeText(this@SettingsActivity, getString(R.string.flash_failed, e.message), Toast.LENGTH_LONG).show()
cleanupAction()
}
}
}
// === USB dispatch ===
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
when (currentAction) {
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
UsbAction.FLASH -> doFlashing(usbManager, device)
UsbAction.NONE -> {}
}
}
// === Dialog helpers ===
private fun showWaitingDialog(title: String, message: String) {
dialog?.dismiss()
val view = layoutInflater.inflate(R.layout.dialog_waiting, null)
view.findViewById<TextView>(R.id.message).text = message
dialog = AlertDialog.Builder(this)
.setTitle(title)
.setView(view)
.setCancelable(true)
.setNegativeButton(getString(R.string.button_cancel)) { _, _ ->
cleanupAction()
}
.setOnCancelListener { cleanupAction() }
.show()
}
private fun showProgressDialog(title: String, message: String, style: Int) {
progressDialog?.dismiss()
val view = layoutInflater.inflate(R.layout.dialog_progress, null)
view.findViewById<TextView>(R.id.message).text = message
val progressBar = view.findViewById<ProgressBar>(R.id.progressBar)
if (style == STYLE_HORIZONTAL) {
progressBar.isIndeterminate = false
} else {
progressBar.isIndeterminate = true
}
progressDialog = AlertDialog.Builder(this)
.setTitle(title)
.setView(view)
.setNegativeButton(getString(R.string.button_cancel)) { _, _ ->
flasherJob?.cancel()
cleanupAction()
}
.setCancelable(false)
.show()
}
private fun cleanupAction() {
currentAction = UsbAction.NONE
expectingReboot = false
dialog?.dismiss()
dialog = null
progressDialog?.dismiss()
progressDialog = null
}
private fun requestUsbPermission(usbManager: UsbManager, device: UsbDevice) {
val flags = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val permIntent = PendingIntent.getBroadcast(
this, 0,
Intent(ACTION_USB_PERMISSION).setPackage(packageName),
flags
)
usbManager.requestPermission(device, permIntent)
}
// === ESP Key Exchange via USB ===
sealed class KeyResult {
data class Success(val deviceUuid: String, val hexKey: String) : KeyResult()
data class Conflict(val espUuid: String, val espKey: String) : KeyResult()
data class Error(val resId: Int, val arg: String? = null) : KeyResult()
}
private fun readEspVersion(usbManager: UsbManager, device: UsbDevice): String? {
val intf = UsbUtils.findCdcDataInterface(device) ?: return null
val connection = usbManager.openDevice(device) ?: return null
if (!connection.claimInterface(intf, true)) { connection.close(); return null }
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return null }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return null }
return try {
setupCdcControl(connection, device)
Thread.sleep(KEY_SETTLE_MS)
val cmd = "VERSION?\n".toByteArray(StandardCharsets.US_ASCII)
connection.bulkTransfer(epOut, cmd, cmd.size, USB_BULK_TIMEOUT_MS)
Thread.sleep(RESPONSE_WAIT_MS)
val response = readResponse(connection, epIn, VERSION_TIMEOUT_MS)
val versionLine = response.lines().firstOrNull { it.startsWith("BUILD_NR:") }
versionLine?.removePrefix("BUILD_NR:")?.trim()
} finally {
connection.close()
}
}
private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice, appUuid: String?, appKey: String?): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error(R.string.err_no_cdc)
val connection = usbManager.openDevice(device)
?: return KeyResult.Error(R.string.err_usb_open)
if (!connection.claimInterface(intf, true)) {
connection.close()
return KeyResult.Error(R.string.err_usb_claim)
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_out) }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_in) }
try {
setupCdcControl(connection, device)
for (attempt in 1..MAX_KEY_RETRIES) {
Thread.sleep(KEY_SETTLE_MS)
flushReadBuffer(connection, epIn)
val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
val written = connection.bulkTransfer(epOut, cmd, cmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "Attempt $attempt: Sent KEY? ($written bytes)")
if (written < 0) {
Log.w(TAG, "Write failed on attempt $attempt")
continue
}
Thread.sleep(RESPONSE_WAIT_MS)
val response = readResponse(connection, epIn, KEY_TIMEOUT_MS)
Log.d(TAG, "Attempt $attempt response: ${response.take(300)}")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
val uuidLine = response.lines().firstOrNull { it.startsWith("DEVICE_ID:") }
val noKey = response.lines().any { it.trim() == "NO_KEY" }
if (keyLine != null && uuidLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim()
if (hexKey.length == HMAC_HEX_LEN && hexKey.all { it in "0123456789ABCDEFabcdef" } &&
hexUuid.length == UUID_HEX_LEN && hexUuid.all { it in "0123456789ABCDEFabcdef" }) {
val espKeyUpper = hexKey.uppercase()
val espUuidUpper = hexUuid.uppercase()
if (appUuid != null && appKey != null &&
(appUuid != espUuidUpper || appKey != espKeyUpper)) {
return KeyResult.Conflict(espUuidUpper, espKeyUpper)
}
return KeyResult.Success(espUuidUpper, espKeyUpper)
} else {
return KeyResult.Error(R.string.err_invalid_key)
}
} else if (noKey) {
val newKey: String
val newUuid: String
if (appUuid != null && appKey != null) {
Log.d(TAG, "ESP has no keys, using existing app keys")
newKey = appKey
newUuid = appUuid
} else {
Log.d(TAG, "Both have no keys, generating new pair")
newKey = generateRandomHexKey()
newUuid = generateRandomHex(UUID_BYTE_COUNT)
}
val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "Sent SET_KEY ($w bytes)")
if (w < 0) {
return KeyResult.Error(R.string.err_set_key_write)
}
Thread.sleep(RESPONSE_WAIT_MS)
val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_KEY response: ${okKeyResponse.take(200)}")
if (!okKeyResponse.contains("OK")) {
return KeyResult.Error(R.string.err_key_not_ack)
}
val setIdCmd = "SET_ID:$newUuid\n".toByteArray(StandardCharsets.US_ASCII)
val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS)
Log.d(TAG, "Sent SET_ID ($w2 bytes)")
if (w2 < 0) {
return KeyResult.Error(R.string.err_set_id_write)
}
Thread.sleep(RESPONSE_WAIT_MS)
val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_ID response: ${okUuidResponse.take(200)}")
if (okUuidResponse.contains("OK")) {
return KeyResult.Success(newUuid, newKey)
} else {
return KeyResult.Error(R.string.err_uuid_not_ack)
}
}
Log.w(TAG, "Attempt $attempt: no KEY response, retrying")
}
return KeyResult.Error(R.string.err_key_retries)
} catch (e: Exception) {
Log.e(TAG, "Key exchange error", e)
return KeyResult.Error(R.string.err_generic, e.message)
} finally {
try { connection.close() } catch (_: Exception) {}
}
}
private fun flushReadBuffer(connection: UsbDeviceConnection, epIn: UsbEndpoint) {
val buf = ByteArray(USB_READ_BUF_SIZE)
while (true) {
val len = connection.bulkTransfer(epIn, buf, buf.size, FLUSH_TIMEOUT_MS)
if (len <= 0) break
Log.d(TAG, "Flushed $len bytes from read buffer")
}
}
private fun readResponse(connection: UsbDeviceConnection, epIn: UsbEndpoint, timeoutMs: Long): String {
val buf = ByteArray(USB_READ_BUF_SIZE)
val sb = StringBuilder()
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeoutMs) {
val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS)
if (len > 0) {
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) {
break
}
}
}
return sb.toString()
}
private fun setupCdcControl(connection: UsbDeviceConnection, device: UsbDevice) {
val ctrlIntf = (0 until device.interfaceCount)
.map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == 0x02 }
if (ctrlIntf != null && ctrlIntf.endpointCount > 0) {
val lineCoding = byteArrayOf(
0x00.toByte(), 0xC2.toByte(), 0x01.toByte(), 0x00.toByte(),
0x00.toByte(), 0x00.toByte(), 0x08.toByte()
)
connection.controlTransfer(
UsbUtils.CDC_REQUEST_TYPE,
UsbUtils.CDC_SET_LINE_CODING,
0,
ctrlIntf.id,
lineCoding,
lineCoding.size,
CDC_CONTROL_TIMEOUT_MS
)
connection.controlTransfer(
UsbUtils.CDC_REQUEST_TYPE,
UsbUtils.CDC_SET_CONTROL_LINE_STATE,
UsbUtils.CDC_DTR or UsbUtils.CDC_RTS,
ctrlIntf.id,
null,
0,
CDC_CONTROL_TIMEOUT_MS
)
Log.d(TAG, "CDC control interface configured")
} else {
Log.w(TAG, "No CDC control interface found, trying without")
}
}
private fun generateRandomHexKey(): String {
val random = SecureRandom()
val bytes = ByteArray(HMAC_BYTE_COUNT)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
private fun generateRandomHex(byteCount: Int): String {
val random = SecureRandom()
val bytes = ByteArray(byteCount)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice,
espUuid: String, espKey: String, appKey: String) {
val prefs = PrefsHelper.getPrefs(this)
val appUuid = prefs.getString(PrefsHelper.PREF_UUID, "") ?: ""
AlertDialog.Builder(this)
.setTitle(getString(R.string.title_key_conflict))
.setMessage(getString(R.string.msg_key_conflict))
.setPositiveButton(getString(R.string.button_keep_esp_keys)) { _, _ ->
prefs.edit()
.putString(PrefsHelper.PREF_UUID, espUuid)
.putString(PrefsHelper.PREF_KEY, espKey)
.apply()
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
.setNegativeButton(getString(R.string.button_overwrite_esp)) { _, _ ->
showOverwriteConfirmDialog(usbManager, device, espUuid, espKey, appKey, appUuid)
}
.setNeutralButton(getString(R.string.button_cancel)) { _, _ ->
cleanupAction()
}
.setCancelable(false)
.show()
}
private fun showOverwriteConfirmDialog(usbManager: UsbManager, device: UsbDevice,
espUuid: String, espKey: String,
appKey: String, appUuid: String) {
val view = layoutInflater.inflate(R.layout.dialog_overwrite_confirm, null)
val checkBox = view.findViewById<CheckBox>(R.id.confirmCheckBox)
val dialog = AlertDialog.Builder(this)
.setTitle(getString(R.string.title_overwrite_confirm))
.setView(view)
.setPositiveButton(getString(R.string.button_confirm)) { _, _ ->
if (checkBox.isChecked) {
doOverwriteEspKeys(usbManager, device, appKey, appUuid)
}
}
.setNegativeButton(getString(R.string.button_cancel)) { _, _ -> }
.setCancelable(true)
.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
checkBox.setOnCheckedChangeListener { _, isChecked ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = isChecked
}
}
private fun doOverwriteEspKeys(usbManager: UsbManager, device: UsbDevice,
appKey: String, appUuid: String) {
showProgressDialog(getString(R.string.title_overwriting_keys), getString(R.string.msg_writing_keys), STYLE_SPINNER)
lifecycleScope.launch(Dispatchers.IO) {
val result = writeKeysToEsp(usbManager, device, appKey, appUuid)
withContext(Dispatchers.Main) {
if (isFinishing || isDestroyed) return@withContext
progressDialog?.dismiss()
progressDialog = null
when (result) {
is KeyResult.Success -> {
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Error -> {
val msg = if (result.arg != null) getString(result.resId, result.arg!!) else getString(result.resId)
Toast.makeText(this@SettingsActivity, msg, Toast.LENGTH_LONG).show()
cleanupAction()
}
else -> {
Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
}
private fun writeKeysToEsp(usbManager: UsbManager, device: UsbDevice,
appKey: String, appUuid: String): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error(R.string.err_no_cdc)
val connection = usbManager.openDevice(device)
?: return KeyResult.Error(R.string.err_usb_open)
if (!connection.claimInterface(intf, true)) {
connection.close()
return KeyResult.Error(R.string.err_usb_claim)
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_out) }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return KeyResult.Error(R.string.err_no_in) }
try {
setupCdcControl(connection, device)
Thread.sleep(KEY_SETTLE_MS)
flushReadBuffer(connection, epIn)
val setKeyCmd = "SET_KEY:$appKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS)
if (w < 0) return KeyResult.Error(R.string.err_set_key_write)
Log.d(TAG, "Sent SET_KEY ($w bytes)")
Thread.sleep(RESPONSE_WAIT_MS)
val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
if (!okKeyResponse.contains("OK")) return KeyResult.Error(R.string.err_key_not_ack)
val setIdCmd = "SET_ID:$appUuid\n".toByteArray(StandardCharsets.US_ASCII)
val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS)
if (w2 < 0) return KeyResult.Error(R.string.err_set_id_write)
Thread.sleep(RESPONSE_WAIT_MS)
val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
if (!okUuidResponse.contains("OK")) return KeyResult.Error(R.string.err_uuid_not_ack)
val prefs = PrefsHelper.getPrefs(this)
prefs.edit()
.putString(PrefsHelper.PREF_UUID, appUuid)
.putString(PrefsHelper.PREF_KEY, appKey)
.apply()
return KeyResult.Success(appUuid, appKey)
} catch (e: Exception) {
Log.e(TAG, "Write keys error", e)
return KeyResult.Error(R.string.err_generic, e.message)
} finally {
try { connection.close() } catch (_: Exception) {}
}
}
}

View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import org.dkaukov.esp32.io.SerialTransport
import java.io.IOException
class UsbSerialTransport(
private val connection: UsbDeviceConnection,
private val epOut: UsbEndpoint,
private val epIn: UsbEndpoint
) : SerialTransport {
companion object {
private const val USB_TIMEOUT_MS = 5000
private const val CONTROL_LINE_TIMEOUT_MS = 1000
private const val MIN_USB_PACKET = 64
}
override fun read(buffer: ByteArray, length: Int): Int {
val n = connection.bulkTransfer(epIn, buffer, length, USB_TIMEOUT_MS)
if (n < 0) return 0
return n
}
override fun write(buffer: ByteArray, length: Int) {
val n = connection.bulkTransfer(epOut, buffer, length, USB_TIMEOUT_MS)
if (n < 0) throw IOException("USB write failed")
}
override fun setControlLines(dtr: Boolean, rts: Boolean) {
connection.controlTransfer(
UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or 0x01,
UsbUtils.CDC_SET_CONTROL_LINE_STATE,
(if (dtr) UsbUtils.CDC_DTR else 0x00) or (if (rts) UsbUtils.CDC_RTS else 0x00),
0,
null,
0,
CONTROL_LINE_TIMEOUT_MS
)
}
override fun readBufferSize(): Int {
return epIn.maxPacketSize.coerceAtLeast(MIN_USB_PACKET)
}
}

View file

@ -0,0 +1,38 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package nl.eddydevink.sylda
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
object UsbUtils {
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
// CDC-ACM class-specific control request constants (USB CDC 1.20 spec)
const val CDC_REQUEST_TYPE = 0x21
const val CDC_SET_LINE_CODING = 0x20
const val CDC_SET_CONTROL_LINE_STATE = 0x22
const val CDC_DTR = 0x01
const val CDC_RTS = 0x02
fun findCdcDataInterface(device: UsbDevice): UsbInterface? {
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)
if (intf.interfaceClass == CDC_DATA_INTERFACE_CLASS) {
return intf
}
}
return null
}
fun findBulkEndpoint(intf: UsbInterface, direction: Int): UsbEndpoint? {
for (i in 0 until intf.endpointCount) {
val ep = intf.getEndpoint(i)
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK && ep.direction == direction) {
return ep
}
}
return null
}
}

View file

@ -4,14 +4,28 @@
android:layout_height="match_parent"
android:padding="16dp">
<ImageButton
android:id="@+id/settingsButton"
android:layout_width="48dp"
android:layout_height="48dp"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:src="@drawable/ic_settings"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cd_settings" />
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_label"
android:textSize="14sp"
android:layout_marginEnd="4dp" />
<ImageButton
android:id="@+id/settingsButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_settings"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cd_settings" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"

View file

@ -12,19 +12,6 @@
android:textSize="16sp"
android:layout_marginBottom="24dp" />
<Button
android:id="@+id/exchangeKeyButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/button_key_exchange" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
android:layout_marginTop="24dp"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -139,12 +126,4 @@
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/buildNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center" />
</LinearLayout>

View file

@ -1,36 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Atom Relay</string>
<string name="app_name">Sylda</string>
<string name="settings">Instellingen</string>
<!-- MainActivity status -->
<string name="status_default">Disconnected</string>
<string name="status_default">Niet verbonden</string>
<string name="status_discovering_services">Verbonden, services zoeken…</string>
<string name="status_connecting">Verbinden…</string>
<string name="status_connected">Connected</string>
<string name="status_connected">Verbonden</string>
<string name="status_service_error">Servicefout</string>
<string name="status_service_not_found">Service not found</string>
<string name="status_service_not_found">Service niet gevonden</string>
<string name="status_comm_error">Communicatiefout</string>
<string name="status_no_key">Geen HMAC sleutel - open Instellingen</string>
<string name="status_no_key">Geen sleutel — open Instellingen</string>
<string name="status_permissions_denied">Tik hier om Bluetooth-toegang in te stellen</string>
<string name="status_bluetooth_disabled">Bluetooth not enabled</string>
<string name="status_scanning">Scanning…</string>
<string name="status_device_not_found">Device not found</string>
<string name="status_wrong_device">Verkeerd apparaat — opnieuw scannen…</string>
<string name="button_retry_scan">Opnieuw scannen</string>
<string name="status_bluetooth_disabled">Bluetooth uitgeschakeld</string>
<string name="status_wrong_device">Verkeerd apparaat — opnieuw zoeken…</string>
<string name="status_scanning">Zoeken…</string>
<string name="status_device_not_found">Apparaat niet gevonden</string>
<string name="button_retry_scan">Opnieuw zoeken</string>
<!-- MainActivity UI -->
<string name="cd_settings">Instellingen</string>
<string name="button_on">ON</string>
<string name="button_hold">HOLD</string>
<string name="settings_label">instellingen</string>
<string name="button_on">AAN</string>
<string name="button_hold">VAST</string>
<!-- SettingsActivity -->
<string name="title_settings">Instellingen</string>
<string name="msg_settings_intro">Sluit de ESP aan op USB en kies een actie.</string>
<string name="button_key_exchange">Sleutel Uitwisselen</string>
<string name="msg_flash_mode_help">Sluit de ESP aan via USB. De app reset de ESP automatisch naar download-modus.</string>
<string name="button_firmware_flash">Firmware Flashen</string>
<string name="build_number">Buildnummer: %s</string>
<string name="msg_flash_mode_help">Sluit de module aan op de USB-poort.</string>
<string name="button_firmware_flash">Firmware bijwerken</string>
<string name="usb_access_denied">USB-toegang geweigerd</string>
<string name="key_read_success">Beveiligingssleutel gelezen</string>
<string name="key_exchange_failed">Sleutel uitwisselen mislukt - probeer opnieuw</string>