Compare commits
17 changed files with 79 additions and 135 deletions
|
|
@ -4,14 +4,14 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
namespace 'nl.eddydevink.sylda'
|
||||
namespace 'com.eddy.atomrelay'
|
||||
// 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 "nl.eddydevink.sylda"
|
||||
applicationId "com.eddy.atomrelay"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:label="Atom Relay"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
|
|
@ -30,15 +30,6 @@
|
|||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:label="Instellingen"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||
android:resource="@xml/device_filter" />
|
||||
</activity>
|
||||
android:parentActivityName=".MainActivity"/>
|
||||
</application>
|
||||
</manifest>
|
||||
Binary file not shown.
313
android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
Normal file
313
android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt
Normal file
21
android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
enum class BleStatus {
|
||||
Scanning,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnected,
|
||||
NoKey,
|
||||
WrongDevice,
|
||||
ServiceNotFound,
|
||||
ServiceError,
|
||||
CommError,
|
||||
DeviceNotFound,
|
||||
BluetoothDisabled,
|
||||
PermissionsDenied
|
||||
}
|
||||
|
||||
interface BleCallbacks {
|
||||
fun onStatusChanged(status: BleStatus)
|
||||
}
|
||||
134
android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt
Normal file
134
android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
218
android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
Normal file
218
android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
33
android/app/src/main/java/com/eddy/atomrelay/PrefsHelper.kt
Normal file
33
android/app/src/main/java/com/eddy/atomrelay/PrefsHelper.kt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
767
android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
Normal file
767
android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
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 = "com.eddy.atomrelay.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 enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
|
||||
|
||||
private var currentAction = UsbAction.NONE
|
||||
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 (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.exchangeKeyButton).setOnClickListener {
|
||||
startKeyExchange()
|
||||
}
|
||||
|
||||
findViewById<Button>(R.id.flashFirmwareButton).setOnClickListener {
|
||||
startFlashing()
|
||||
}
|
||||
|
||||
findViewById<TextView>(R.id.buildNumber).text = getString(R.string.build_number, BuildConfig.BUILD_NUMBER)
|
||||
|
||||
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 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()
|
||||
Toast.makeText(this@SettingsActivity, getString(R.string.key_read_success), Toast.LENGTH_LONG).show()
|
||||
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) {
|
||||
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
|
||||
currentAction = UsbAction.NONE
|
||||
progressDialog?.dismiss()
|
||||
progressDialog = null
|
||||
dialog = AlertDialog.Builder(this@SettingsActivity)
|
||||
.setTitle(getString(R.string.title_firmware_flash))
|
||||
.setMessage(getString(R.string.msg_flash_complete))
|
||||
.setPositiveButton(getString(R.string.button_close)) { d, _ ->
|
||||
d.dismiss()
|
||||
dialog = null
|
||||
}
|
||||
.setCancelable(true)
|
||||
.setOnCancelListener { dialog = null }
|
||||
.show()
|
||||
} 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
|
||||
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 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()
|
||||
Toast.makeText(this, getString(R.string.key_read_success), Toast.LENGTH_LONG).show()
|
||||
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 -> {
|
||||
Toast.makeText(this@SettingsActivity, getString(R.string.key_read_success), Toast.LENGTH_LONG).show()
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
38
android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt
Normal file
38
android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package com.eddy.atomrelay
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -4,28 +4,14 @@
|
|||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
<ImageButton
|
||||
android:id="@+id/settingsButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="end|top"
|
||||
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>
|
||||
android:src="@drawable/ic_settings"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/cd_settings" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,19 @@
|
|||
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"
|
||||
|
|
@ -126,4 +139,12 @@
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Sylda</string>
|
||||
<string name="app_name">Atom Relay</string>
|
||||
<string name="settings">Instellingen</string>
|
||||
|
||||
<!-- MainActivity status -->
|
||||
<string name="status_default">Niet verbonden</string>
|
||||
<string name="status_default">Disconnected</string>
|
||||
<string name="status_discovering_services">Verbonden, services zoeken…</string>
|
||||
<string name="status_connecting">Verbinden…</string>
|
||||
<string name="status_connected">Verbonden</string>
|
||||
<string name="status_connected">Connected</string>
|
||||
<string name="status_service_error">Servicefout</string>
|
||||
<string name="status_service_not_found">Service niet gevonden</string>
|
||||
<string name="status_service_not_found">Service not found</string>
|
||||
<string name="status_comm_error">Communicatiefout</string>
|
||||
<string name="status_no_key">Geen sleutel — open Instellingen</string>
|
||||
<string name="status_no_key">Geen HMAC sleutel - open Instellingen</string>
|
||||
<string name="status_permissions_denied">Tik hier om Bluetooth-toegang in te stellen</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>
|
||||
<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>
|
||||
|
||||
<!-- MainActivity UI -->
|
||||
<string name="cd_settings">Instellingen</string>
|
||||
<string name="settings_label">instellingen</string>
|
||||
<string name="button_on">AAN</string>
|
||||
<string name="button_hold">VAST</string>
|
||||
<string name="button_on">ON</string>
|
||||
<string name="button_hold">HOLD</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="msg_flash_mode_help">Sluit de module aan op de USB-poort.</string>
|
||||
<string name="button_firmware_flash">Firmware bijwerken</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="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>
|
||||
|
|
@ -56,9 +57,7 @@
|
|||
<string name="msg_reading_key">Sleutel uitlezen…</string>
|
||||
<string name="msg_flash_download_mode">Sluit de ESP aan via USB om te flashen.</string>
|
||||
<string name="msg_initializing">Initialiseren…</string>
|
||||
<string name="msg_checking_version">Firmware versie controleren…</string>
|
||||
<string name="msg_env_waiting_reboot">Wachten op ESP herstart…</string>
|
||||
<string name="msg_flash_complete">Firmware geflashed!\n\nSleutels worden nu uitgelezen…</string>
|
||||
<string name="msg_flash_complete">Firmware geflashed!\n\nDe ESP herstart automatisch. Sleutels blijven behouden in NVS.</string>
|
||||
<string name="button_close">Sluiten</string>
|
||||
<string name="button_cancel">Annuleren</string>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<usb-device vendor-id="12346" product-id="4097" />
|
||||
</resources>
|
||||
|
|
@ -13,5 +13,5 @@ build_flags =
|
|||
-DCONFIG_BT_NIMBLE_ENABLED
|
||||
lib_deps =
|
||||
h2zero/NimBLE-Arduino@^2
|
||||
upload_port = /dev/ttyACM1
|
||||
upload_port = /dev/ttyACM0
|
||||
upload_speed = 115200
|
||||
|
|
@ -22,7 +22,6 @@
|
|||
#define NVS_NAMESPACE "hmac"
|
||||
#define NVS_KEY_NAME "key"
|
||||
#define NVS_UUID_NAME "uuid"
|
||||
#define FIRMWARE_VERSION "1"
|
||||
|
||||
NimBLECharacteristic *pCharacteristic;
|
||||
bool deviceConnected = false;
|
||||
|
|
@ -251,9 +250,6 @@ void loop() {
|
|||
} else {
|
||||
Serial.println("NO_KEY");
|
||||
}
|
||||
} else if (serialBuf == "VERSION?") {
|
||||
Serial.print("BUILD_NR:");
|
||||
Serial.println(FIRMWARE_VERSION);
|
||||
} else if (serialBuf.startsWith("SET_KEY:")) {
|
||||
String hex = serialBuf.substring(8);
|
||||
hex.toUpperCase();
|
||||
|
|
|
|||
Loading…
Reference in a new issue