diff --git a/.github/workflows/android-unit-tests.yml b/.github/workflows/android-unit-tests.yml new file mode 100644 index 0000000..3397f05 --- /dev/null +++ b/.github/workflows/android-unit-tests.yml @@ -0,0 +1,32 @@ +name: Android Unit Tests + +on: + pull_request: + push: + +jobs: + android-unit-tests: + name: android-unit-tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Write local.properties + run: | + cat > android/local.properties < { + val parts = version.split('.') + if (parts.size !in 2..3) { + throw GradleException("VERSION must be MAJOR.MINOR or MAJOR.MINOR.PATCH, got '$version'") + } + val major = parts[0].toIntOrNull() + ?: throw GradleException("Invalid major version in '$version'") + val minor = parts[1].toIntOrNull() + ?: throw GradleException("Invalid minor version in '$version'") + val patch = when (parts.size) { + 3 -> parts[2].toIntOrNull() + ?: throw GradleException("Invalid patch version in '$version'") + else -> 0 + } + if (major < 0 || minor < 0 || patch < 0) { + throw GradleException("VERSION components must be non-negative, got '$version'") + } + return Triple(major, minor, patch) +} + +val (versionMajor, versionMinor, versionPatch) = parseSemVer(repoVersion) +val computedVersionCode = (versionMajor * 10000) + (versionMinor * 100) + versionPatch + android { namespace = "com.wifinder.android" compileSdk = 34 @@ -11,8 +46,8 @@ android { applicationId = "com.wifinder.android" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "1.0" + versionCode = computedVersionCode + versionName = repoVersion testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c414cb2..c74d2af 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,12 @@ + + + + + + @@ -17,6 +23,7 @@ android:allowBackup="true" android:icon="@android:drawable/sym_def_app_icon" android:label="@string/app_name" + android:usesCleartextTraffic="true" android:supportsRtl="true" android:theme="@style/Theme.WiFinder"> = Build.VERSION_CODES.TIRAMISU) { + permissions += Manifest.permission.NEARBY_WIFI_DEVICES permissions += Manifest.permission.POST_NOTIFICATIONS } return permissions @@ -300,6 +304,9 @@ private fun WiFinderScreen( onDownloadBacklog: () -> Unit, onSeedDebugBacklog: () -> Unit, onPushPhoneGpsNow: () -> Unit, + onSelectBleDevice: (String) -> Unit, + onDismissBlePicker: () -> Unit, + onForgetPreferredBleDevice: () -> Unit, onToggleSection: (String) -> Unit, onToggleDashboard: () -> Unit, ) { @@ -310,6 +317,45 @@ private fun WiFinderScreen( color = MaterialTheme.colorScheme.background, ) { Column(modifier = Modifier.fillMaxSize()) { + if (state.bleDevicePickerVisible) { + AlertDialog( + onDismissRequest = onDismissBlePicker, + title = { Text("Select WiFinder Device") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + state.bleDeviceCandidates.forEach { candidate -> + OutlinedButton( + onClick = { onSelectBleDevice(candidate.address) }, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = candidate.name, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${candidate.address} • RSSI ${candidate.rssi}", + style = MaterialTheme.typography.bodySmall, + ) + if (candidate.remembered) { + Text( + text = "Remembered target", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismissBlePicker) { Text("Cancel") } + }, + ) + } + // ── Top bar ───────────────────────────────────────────── TopAppBar( title = { @@ -393,6 +439,7 @@ private fun WiFinderScreen( onDownloadBacklog = onDownloadBacklog, onSeedDebugBacklog = onSeedDebugBacklog, onPushPhoneGpsNow = onPushPhoneGpsNow, + onForgetPreferredBleDevice = onForgetPreferredBleDevice, ) } @@ -662,6 +709,7 @@ private fun QuickActions( onDownloadBacklog: () -> Unit, onSeedDebugBacklog: () -> Unit, onPushPhoneGpsNow: () -> Unit, + onForgetPreferredBleDevice: () -> Unit, ) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), @@ -671,6 +719,29 @@ private fun QuickActions( modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { + if (state.preferredBleDeviceAddress.isNotBlank()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val label = state.preferredBleDeviceName.takeIf { it.isNotBlank() } + ?: state.preferredBleDeviceAddress + Text( + text = "Preferred: $label", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + CompactButton( + label = "Forget", + primary = false, + modifier = Modifier.weight(0.45f), + onClick = onForgetPreferredBleDevice, + ) + } + } // Row 1: connection + scanning Row( modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/java/com/wifinder/android/ble/BleTargetingPolicy.kt b/android/app/src/main/java/com/wifinder/android/ble/BleTargetingPolicy.kt new file mode 100644 index 0000000..baa5e70 --- /dev/null +++ b/android/app/src/main/java/com/wifinder/android/ble/BleTargetingPolicy.kt @@ -0,0 +1,71 @@ +package com.wifinder.android.ble + +import java.util.Locale + +data class BleDeviceCandidate( + val address: String, + val name: String, + val rssi: Int, + val remembered: Boolean = false, +) + +object BleTargetingPolicy { + private const val NAME_PREFIX = "WIFINDER" + + fun normalizeAddress(address: String?): String? { + if (address.isNullOrBlank()) { + return null + } + return address.trim().uppercase(Locale.US) + } + + fun isNameCompatible(advertisedName: String?): Boolean { + if (advertisedName.isNullOrBlank()) { + return true + } + return advertisedName.trim().uppercase(Locale.US).startsWith(NAME_PREFIX) + } + + fun isEligibleCandidate(hasServiceUuid: Boolean, advertisedName: String?): Boolean { + if (!hasServiceUuid) { + return false + } + return isNameCompatible(advertisedName) + } + + fun sortCandidates( + candidates: Collection, + preferredAddress: String?, + ): List { + val normalizedPreferred = normalizeAddress(preferredAddress) + return candidates + .mapNotNull { candidate -> + val normalizedAddress = normalizeAddress(candidate.address) ?: return@mapNotNull null + candidate.copy( + address = normalizedAddress, + remembered = normalizedPreferred != null && normalizedAddress == normalizedPreferred, + ) + } + .distinctBy { it.address } + .sortedWith( + compareByDescending { it.remembered } + .thenByDescending { it.rssi } + .thenBy { it.name } + .thenBy { it.address }, + ) + } + + fun chooseAutoConnect( + candidates: List, + preferredAddress: String?, + ): BleDeviceCandidate? { + if (candidates.isEmpty()) { + return null + } + val normalizedPreferred = normalizeAddress(preferredAddress) + if (normalizedPreferred != null) { + candidates.firstOrNull { normalizeAddress(it.address) == normalizedPreferred }?.let { return it } + } + return if (candidates.size == 1) candidates.first() else null + } +} diff --git a/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt b/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt index 211fb62..d4491dc 100644 --- a/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt +++ b/android/app/src/main/java/com/wifinder/android/ble/WiFinderBleClient.kt @@ -11,12 +11,14 @@ import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothStatusCodes 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.os.Build import android.os.Handler import android.os.Looper +import android.os.ParcelUuid import com.wifinder.android.model.GpsFix import com.wifinder.android.model.WgFrame import com.wifinder.android.model.WgProtocol @@ -33,6 +35,10 @@ class WiFinderBleClient( fun onFrame(frame: WgFrame) fun onInfo(message: String) + + fun onBleDeviceCandidates(candidates: List) {} + + fun onBleDeviceResolved(address: String, name: String?) {} } companion object { @@ -43,9 +49,9 @@ class WiFinderBleClient( private val CONFIG_UUID: UUID = UUID.fromString("6ee2e690-d7fd-4a11-94a2-89528da43134") private val DEVICE_INFO_UUID: UUID = UUID.fromString("6ee2e690-d7fd-4a11-94a2-89528da43135") private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - private const val TARGET_DEVICE_NAME = "WIFINDER-C6" - private const val TARGET_DEVICE_ADDRESS = "A0:85:E3:DB:04:92" + private const val TARGET_NAME_HINT = "WIFINDER" private const val SCAN_TIMEOUT_MS = 12_000L + private const val SCAN_SETTLE_MS = 1_200L private const val TARGET_MTU = 517 } @@ -62,12 +68,23 @@ class WiFinderBleClient( private var configChar: BluetoothGattCharacteristic? = null private var deviceInfoChar: BluetoothGattCharacteristic? = null private var scanCallback: ScanCallback? = null + private var scanTimeoutRunnable: Runnable? = null + private var scanSettleRunnable: Runnable? = null private var connecting = false private var ready = false + private var preferredAddress: String? = null + private var connectedAddress: String? = null + private var connectedName: String? = null + private val discoveredCandidates = linkedMapOf() + private val discoveredDevices = linkedMapOf() private val notificationQueue = ArrayDeque() fun isConnected(): Boolean = ready && gatt != null + fun setPreferredDeviceAddress(address: String?) { + preferredAddress = BleTargetingPolicy.normalizeAddress(address) + } + fun connect() { if (connecting || isConnected()) { return @@ -79,31 +96,59 @@ class WiFinderBleClient( return } - val known = findKnownDevice(adapter) + val remembered = preferredAddress + val known = remembered?.let { findBondedDeviceByAddress(adapter, it) } if (known != null) { connectToDevice(known) return } - startScan(adapter) + startScan(adapter, remembered) + } + + fun connectToAddress(address: String): Boolean { + if (connecting || isConnected()) { + return false + } + val normalized = BleTargetingPolicy.normalizeAddress(address) ?: return false + val adapter = bluetoothManager.adapter + if (adapter == null || !adapter.isEnabled) { + listener.onInfo("Bluetooth adapter unavailable or disabled") + return false + } + val device = discoveredDevices[normalized] ?: runCatching { + adapter.getRemoteDevice(normalized) + }.getOrNull() + if (device == null) { + listener.onInfo("Selected device unavailable: $normalized") + return false + } + connectToDevice(device) + return true } @SuppressLint("MissingPermission") fun disconnect() { stopScan() + clearScanScheduling() connecting = false ready = false + connectedAddress = null + connectedName = null controlChar = null statusChar = null sightingChar = null configChar = null deviceInfoChar = null notificationQueue.clear() + discoveredCandidates.clear() + discoveredDevices.clear() val current = gatt gatt = null current?.disconnect() current?.close() + listener.onBleDeviceCandidates(emptyList()) listener.onConnectState(false) } @@ -166,6 +211,18 @@ class WiFinderBleClient( WgProtocol.encodeBacklogBlobChunkReplyPayload(sessionId, chunkOffset, chunkLen, accepted), ) + fun setWifiDumpEnabled(enabled: Boolean): Boolean = + sendCommand( + WgProtocol.Command.SET_WIFI_DUMP, + WgProtocol.encodeWifiDumpTogglePayload(enabled), + ) + + fun commitWifiDump(runId: Long): Boolean = + sendCommand( + WgProtocol.Command.COMMIT_WIFI_DUMP, + WgProtocol.encodeWifiDumpCommitPayload(runId), + ) + fun seedDebugStorage(targetBytes: Int): Boolean = sendCommand( WgProtocol.Command.DEBUG_SEED_STORAGE, @@ -225,28 +282,66 @@ class WiFinderBleClient( } @SuppressLint("MissingPermission") - private fun startScan(adapter: BluetoothAdapter) { + private fun startScan(adapter: BluetoothAdapter, rememberedAddress: String?) { val scanner = adapter.bluetoothLeScanner if (scanner == null) { listener.onInfo("BLE scanner unavailable") return } + stopScan() + clearScanScheduling() + discoveredCandidates.clear() + discoveredDevices.clear() connecting = true - listener.onInfo("Scanning for $TARGET_DEVICE_NAME ($TARGET_DEVICE_ADDRESS)...") + + listener.onInfo( + if (rememberedAddress != null) { + "Scanning for $TARGET_NAME_HINT service (preferring $rememberedAddress)..." + } else { + "Scanning for $TARGET_NAME_HINT service..." + }, + ) val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + val filters = listOf( + ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build(), + ) val callback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device ?: return - val matchesAddress = device.address.equals(TARGET_DEVICE_ADDRESS, ignoreCase = true) - if (!matchesAddress) { + val normalizedAddress = BleTargetingPolicy.normalizeAddress(device.address) ?: return + val scanRecord = result.scanRecord + val hasServiceUuid = scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } ?: true + val advertisedName = scanRecord?.deviceName ?: device.name + if (!BleTargetingPolicy.isEligibleCandidate(hasServiceUuid, advertisedName)) { + return + } + + val previous = discoveredCandidates[normalizedAddress] + val resolvedName = when { + !advertisedName.isNullOrBlank() -> advertisedName + previous != null && previous.name.isNotBlank() -> previous.name + else -> normalizedAddress + } + val strongestRssi = maxOf(result.rssi, previous?.rssi ?: Int.MIN_VALUE) + discoveredDevices[normalizedAddress] = device + discoveredCandidates[normalizedAddress] = + BleDeviceCandidate( + address = normalizedAddress, + name = resolvedName, + rssi = strongestRssi, + ) + + if (rememberedAddress != null && normalizedAddress == rememberedAddress) { + connectToDevice(device) return } - connectToDevice(device) + + scheduleScanSettle() } override fun onScanFailed(errorCode: Int) { @@ -257,18 +352,61 @@ class WiFinderBleClient( } scanCallback = callback - scanner.startScan(emptyList(), settings, callback) + scanner.startScan(filters, settings, callback) - mainHandler.postDelayed( - { - if (scanCallback != null) { - listener.onInfo("BLE scan timeout") - stopScan() - connecting = false - } + val timeout = Runnable { + finalizeScanDecision() + } + scanTimeoutRunnable = timeout + mainHandler.postDelayed(timeout, SCAN_TIMEOUT_MS) + } + + private fun scheduleScanSettle() { + val existing = scanSettleRunnable + if (existing != null) { + mainHandler.removeCallbacks(existing) + } + val settle = Runnable { + finalizeScanDecision() + } + scanSettleRunnable = settle + mainHandler.postDelayed(settle, SCAN_SETTLE_MS) + } + + @SuppressLint("MissingPermission") + private fun finalizeScanDecision() { + if (scanCallback == null) { + return + } + stopScan() + connecting = false + + val candidates = BleTargetingPolicy.sortCandidates( + candidates = discoveredCandidates.values, + preferredAddress = preferredAddress, + ) + if (candidates.isEmpty()) { + listener.onInfo("BLE scan timeout") + return + } + + val auto = BleTargetingPolicy.chooseAutoConnect(candidates, preferredAddress) + if (auto != null) { + val device = discoveredDevices[auto.address] + if (device != null) { + connectToDevice(device) + return + } + } + + listener.onInfo( + if (preferredAddress != null) { + "Remembered device unavailable; select a WiFinder device" + } else { + "Multiple WiFinder devices found; select one" }, - SCAN_TIMEOUT_MS, ) + listener.onBleDeviceCandidates(candidates) } @SuppressLint("MissingPermission") @@ -278,22 +416,36 @@ class WiFinderBleClient( val callback = scanCallback ?: return scanner.stopScan(callback) scanCallback = null + clearScanScheduling() + } + + private fun clearScanScheduling() { + scanTimeoutRunnable?.let { mainHandler.removeCallbacks(it) } + scanSettleRunnable?.let { mainHandler.removeCallbacks(it) } + scanTimeoutRunnable = null + scanSettleRunnable = null } - private fun findKnownDevice(adapter: BluetoothAdapter): BluetoothDevice? { + private fun findBondedDeviceByAddress(adapter: BluetoothAdapter, address: String): BluetoothDevice? { val bonded = adapter.bondedDevices ?: return null return bonded.firstOrNull { device -> - device.address.equals(TARGET_DEVICE_ADDRESS, ignoreCase = true) + BleTargetingPolicy.normalizeAddress(device.address) == address && + BleTargetingPolicy.isNameCompatible(device.name) } } @SuppressLint("MissingPermission") private fun connectToDevice(device: BluetoothDevice) { - if (gatt != null || connecting && scanCallback == null) { + if (gatt != null || isConnected()) { return } stopScan() - listener.onInfo("Connecting to ${device.name ?: device.address}...") + clearScanScheduling() + val normalizedAddress = BleTargetingPolicy.normalizeAddress(device.address) ?: device.address + connectedAddress = normalizedAddress + connectedName = device.name + listener.onBleDeviceCandidates(emptyList()) + listener.onInfo("Connecting to ${device.name ?: normalizedAddress}...") connecting = true gatt?.close() @@ -307,20 +459,6 @@ class WiFinderBleClient( } } - @SuppressLint("MissingPermission") - private fun writeCharacteristicNoResponse( - targetGatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - bytes: ByteArray, - ): Boolean { - return writeCharacteristic( - targetGatt = targetGatt, - characteristic = characteristic, - bytes = bytes, - writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE, - ) - } - @SuppressLint("MissingPermission") private fun writeCharacteristic( targetGatt: BluetoothGatt, @@ -349,6 +487,10 @@ class WiFinderBleClient( ready = true connecting = false listener.onConnectState(true) + val address = connectedAddress ?: BleTargetingPolicy.normalizeAddress(targetGatt.device.address) + if (address != null) { + listener.onBleDeviceResolved(address, connectedName ?: targetGatt.device.name) + } listener.onInfo("BLE ready") return } diff --git a/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt b/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt index 8bd283c..f3419d8 100644 --- a/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt +++ b/android/app/src/main/java/com/wifinder/android/logging/WigleCsvLogger.kt @@ -103,15 +103,16 @@ class WigleCsvLogger(private val context: Context) { currentDisplayPath = displayPath } + val appVersion = appVersionName() val header = WigleCsvFormatter.header( - appRelease = "wifinder-android-1", - model = Build.MODEL ?: "unknown", - release = Build.VERSION.RELEASE ?: "unknown", - device = Build.DEVICE ?: "unknown", - display = Build.DISPLAY ?: "unknown", - board = Build.BOARD ?: "unknown", - brand = Build.BRAND ?: "unknown", + appRelease = "WiFinder-Android-$appVersion", + model = "WiFinder", + release = appVersion, + device = "WiFinder-Android", + display = "WiFinder", + board = "WiFinder", + brand = "WiFinder", ) for (line in header) { @@ -164,6 +165,23 @@ class WigleCsvLogger(private val context: Context) { currentFile = null currentDisplayPath = null } + + private fun appVersionName(): String { + return try { + val pkgInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + android.content.pm.PackageManager.PackageInfoFlags.of(0), + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0) + } + pkgInfo.versionName?.takeIf { it.isNotBlank() } ?: "unknown" + } catch (_: Exception) { + "unknown" + } + } } data class CsvSessionInfo( diff --git a/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt b/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt index 805f08a..665ab23 100644 --- a/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt +++ b/android/app/src/main/java/com/wifinder/android/model/WgProtocol.kt @@ -45,6 +45,8 @@ object WgProtocol { const val DEBUG_SEED_STORAGE = 0x0E const val SET_CHANNEL_PLAN = 0x0F const val BACKLOG_BLOB_CHUNK_REPLY = 0x10 + const val SET_WIFI_DUMP = 0x11 + const val COMMIT_WIFI_DUMP = 0x12 } const val GPS_FLAG_VALID = 1 shl 0 @@ -360,6 +362,16 @@ object WgProtocol { fun encodeBacklogBlobTogglePayload(enabled: Boolean): ByteArray = byteArrayOf(if (enabled) 1 else 0) + fun encodeWifiDumpTogglePayload(enabled: Boolean): ByteArray = + byteArrayOf(if (enabled) 1 else 0) + + fun encodeWifiDumpCommitPayload(runId: Long): ByteArray { + val out = ByteArray(8) + val bb = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN) + bb.putLong(runId) + return out + } + fun encodeBacklogBlobChunkReplyPayload( sessionId: Long, chunkOffset: Long, diff --git a/android/app/src/main/java/com/wifinder/android/service/BacklogTransferCoordinator.kt b/android/app/src/main/java/com/wifinder/android/service/BacklogTransferCoordinator.kt new file mode 100644 index 0000000..0b2e3ca --- /dev/null +++ b/android/app/src/main/java/com/wifinder/android/service/BacklogTransferCoordinator.kt @@ -0,0 +1,209 @@ +package com.wifinder.android.service + +enum class BacklogTransferStage { + IDLE, + WIFI_ACTIVE, + BLE_FALLBACK_ACTIVE, + COMPLETE, + FAILED, +} + +data class BacklogTransferSnapshot( + val stage: BacklogTransferStage, + val downloadActive: Boolean, + val blobActive: Boolean, + val blobSessionId: Long, + val blobBytesSent: Long, + val blobBytesTotal: Long, + val failureReason: String?, +) + +class BacklogTransferCoordinator( + private val onSnapshot: (BacklogTransferSnapshot) -> Unit, + private val onLog: (String) -> Unit, +) { + private var stage: BacklogTransferStage = BacklogTransferStage.IDLE + private var fallbackAttempted: Boolean = false + private var blobSessionId: Long = 0L + private var blobBytesSent: Long = 0L + private var blobBytesTotal: Long = 0L + private var failureReason: String? = null + + init { + emitSnapshot() + } + + @Synchronized + fun currentStage(): BacklogTransferStage = stage + + @Synchronized + fun isActive(): Boolean = + stage == BacklogTransferStage.WIFI_ACTIVE || stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE + + @Synchronized + fun isWifiActive(): Boolean = stage == BacklogTransferStage.WIFI_ACTIVE + + @Synchronized + fun isBleFallbackActive(): Boolean = stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE + + @Synchronized + fun startWifi(): Boolean { + if (isActive()) { + return false + } + stage = BacklogTransferStage.WIFI_ACTIVE + fallbackAttempted = false + failureReason = null + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + onLog("Backlog download started (Wi-Fi primary)") + emitSnapshot() + return true + } + + @Synchronized + fun failWifiAndRequestBleFallback(reason: String): Boolean { + if (stage != BacklogTransferStage.WIFI_ACTIVE) { + return false + } + onLog("Backlog Wi-Fi failed: $reason") + if (fallbackAttempted) { + stage = BacklogTransferStage.FAILED + failureReason = reason + emitSnapshot() + onLog("Backlog download failed") + return false + } + fallbackAttempted = true + stage = BacklogTransferStage.BLE_FALLBACK_ACTIVE + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + failureReason = null + emitSnapshot() + onLog("Switching to BLE fallback") + return true + } + + @Synchronized + fun noteBleSessionMeta(sessionId: Long, totalBytes: Long) { + if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE && + stage != BacklogTransferStage.WIFI_ACTIVE) { + return + } + blobSessionId = sessionId + blobBytesSent = 0L + blobBytesTotal = totalBytes.coerceAtLeast(0L) + emitSnapshot() + } + + @Synchronized + fun noteBleSessionProgress(sessionId: Long, bytesReceived: Long, totalBytes: Long) { + if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE && + stage != BacklogTransferStage.WIFI_ACTIVE) { + return + } + blobSessionId = sessionId + blobBytesSent = bytesReceived.coerceAtLeast(0L) + blobBytesTotal = totalBytes.coerceAtLeast(0L) + emitSnapshot() + } + + @Synchronized + fun completeWifi(message: String = "Backlog Wi-Fi download complete") { + if (stage != BacklogTransferStage.WIFI_ACTIVE) { + return + } + stage = BacklogTransferStage.COMPLETE + failureReason = null + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + emitSnapshot() + onLog(message) + } + + @Synchronized + fun completeBleFallback(message: String = "Backlog BLE fallback complete") { + if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE) { + return + } + stage = BacklogTransferStage.COMPLETE + failureReason = null + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + emitSnapshot() + onLog(message) + } + + @Synchronized + fun failBleFallback(reason: String) { + if (stage != BacklogTransferStage.BLE_FALLBACK_ACTIVE) { + return + } + stage = BacklogTransferStage.FAILED + failureReason = reason + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + emitSnapshot() + onLog("Backlog fallback failed: $reason") + } + + @Synchronized + fun failTransfer(reason: String, logMessage: String = "Backlog download failed: $reason") { + if (stage == BacklogTransferStage.IDLE) { + return + } + stage = BacklogTransferStage.FAILED + failureReason = reason + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + emitSnapshot() + onLog(logMessage) + } + + @Synchronized + fun cancel(message: String) { + if (stage == BacklogTransferStage.IDLE) { + return + } + stage = BacklogTransferStage.IDLE + failureReason = null + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + emitSnapshot() + onLog(message) + } + + @Synchronized + fun resetIdle() { + stage = BacklogTransferStage.IDLE + fallbackAttempted = false + failureReason = null + blobSessionId = 0L + blobBytesSent = 0L + blobBytesTotal = 0L + emitSnapshot() + } + + private fun emitSnapshot() { + onSnapshot( + BacklogTransferSnapshot( + stage = stage, + downloadActive = stage == BacklogTransferStage.WIFI_ACTIVE || + stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE, + blobActive = stage == BacklogTransferStage.WIFI_ACTIVE || + stage == BacklogTransferStage.BLE_FALLBACK_ACTIVE, + blobSessionId = blobSessionId, + blobBytesSent = blobBytesSent, + blobBytesTotal = blobBytesTotal, + failureReason = failureReason, + ), + ) + } +} diff --git a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt index bb2dc68..a592627 100644 --- a/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt +++ b/android/app/src/main/java/com/wifinder/android/service/ScannerService.kt @@ -1,15 +1,23 @@ package com.wifinder.android.service +import android.Manifest import android.annotation.SuppressLint import android.app.NotificationManager import android.app.Service import android.content.Intent import android.content.SharedPreferences +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.location.Location +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiNetworkSpecifier import android.os.Binder import android.os.Build import android.os.IBinder +import com.wifinder.android.ble.BleDeviceCandidate import com.wifinder.android.ble.WiFinderBleClient import com.wifinder.android.logging.GpxTrackLogger import com.wifinder.android.gps.GpsTracker @@ -32,6 +40,8 @@ import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL import java.nio.ByteBuffer import java.nio.ByteOrder import java.text.SimpleDateFormat @@ -41,19 +51,27 @@ import java.util.TreeSet import java.util.zip.CRC32 import kotlin.math.abs import kotlin.math.roundToInt +import kotlin.coroutines.resume +import androidx.core.content.ContextCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import org.json.JSONObject /** * Shared state exposed by the service to the UI layer via binding. @@ -122,10 +140,21 @@ data class ServiceState( val gpxPointCount: Long = 0L, val loggingEnabled: Boolean = false, val csvPath: String = "", + val preferredBleDeviceAddress: String = "", + val preferredBleDeviceName: String = "", + val bleDevicePickerVisible: Boolean = false, + val bleDeviceCandidates: List = emptyList(), val sightings: List = emptyList(), val logs: List = emptyList(), ) +data class ServiceBleDeviceCandidate( + val address: String, + val name: String, + val rssi: Int, + val remembered: Boolean, +) + class ScannerService : Service(), WiFinderBleClient.Listener { companion object { @@ -151,6 +180,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener { private const val PREF_AUTO_HOP_BASE_MS = "auto_hop_base_ms" private const val PREF_GPS_NAV_MODE = "gps_nav_mode" private const val PREF_GPX_ENABLED = "gpx_enabled" + private const val PREF_PREFERRED_BLE_DEVICE_ADDRESS = "preferred_ble_device_address" + private const val PREF_PREFERRED_BLE_DEVICE_NAME = "preferred_ble_device_name" private const val GPX_MIN_ELAPSED_MS = 1000L private const val GPX_MIN_DISTANCE_METERS = 5.0 private const val BLOB_BLOCK_SIZE = 1024 @@ -163,6 +194,14 @@ class ScannerService : Service(), WiFinderBleClient.Listener { private const val DEBUG_SEED_TARGET_BYTES = 768 * 1024 private const val DEBUG_SEED_MIN_BYTES = 512 * 1024 private const val DEBUG_SEED_MAX_BYTES = 1024 * 1024 + private const val WIFI_DUMP_AP_SSID = "WIFINDER-DUMP" + private const val WIFI_DUMP_AP_HOST = "192.168.4.1" + private const val WIFI_DUMP_MANIFEST_PATH = "/wifinder/dump/manifest.json" + private const val WIFI_DUMP_FILE_PATH = "/wifinder/dump/file" + private const val WIFI_DUMP_HTTP_CONNECT_TIMEOUT_MS = 8_000 + private const val WIFI_DUMP_HTTP_READ_TIMEOUT_MS = 20_000 + private const val WIFI_DUMP_NETWORK_WAIT_MS = 25_000L + private const val WIFI_DUMP_PROGRESS_UI_INTERVAL_MS = 150L internal fun normalizeGpsNavMode(mode: Int): Int = when (mode) { 0, 1, 2, 4 -> mode @@ -212,6 +251,19 @@ class ScannerService : Service(), WiFinderBleClient.Listener { val badBlocks: Int, ) + private data class WifiDumpSession( + val sessionId: Long, + val datBytes: Long, + val gpxBytes: Long, + val writtenSeq: Long, + val ackedSeq: Long, + ) + + private data class WifiDumpManifest( + val runId: Long, + val sessions: List, + ) + inner class LocalBinder : Binder() { val service: ScannerService get() = this@ScannerService } @@ -220,6 +272,10 @@ class ScannerService : Service(), WiFinderBleClient.Listener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _state = MutableStateFlow(ServiceState()) + private val backlogTransferCoordinator = BacklogTransferCoordinator( + onSnapshot = ::applyBacklogTransferSnapshot, + onLog = ::appendLog, + ) val state: StateFlow = _state.asStateFlow() private val sightings = linkedMapOf() @@ -227,7 +283,6 @@ class ScannerService : Service(), WiFinderBleClient.Listener { private val lastCsvWriteByBssid = hashMapOf() private val sightingsMutex = Mutex() private val ackLock = Any() - private val backlogRecoveryLock = Any() private val sessionAcks = hashMapOf() private val logTime = SimpleDateFormat("HH:mm:ss", Locale.US) @@ -256,10 +311,12 @@ class ScannerService : Service(), WiFinderBleClient.Listener { @Volatile private var downloadBacklogAutoStop: Boolean = false @Volatile private var downloadBacklogStartMs: Long = 0L @Volatile private var visibleTimeoutSec: Int = 25 - @Volatile private var backlogBlobRecoveryInFlight: Boolean = false @Volatile private var backlogBlobRx: BacklogBlobReceiver? = null @Volatile private var blobProgressUiLastMs: Long = 0L @Volatile private var blobProgressUiLastBytes: Long = 0L + @Volatile private var wifiDumpJob: Job? = null + @Volatile private var preferredBleDeviceAddress: String? = null + @Volatile private var preferredBleDeviceName: String = "" override fun onCreate() { super.onCreate() @@ -334,7 +391,11 @@ class ScannerService : Service(), WiFinderBleClient.Listener { override fun onDestroy() { super.onDestroy() scope.cancel() + wifiDumpJob?.cancel() + wifiDumpJob = null + backlogTransferCoordinator.resetIdle() try { bleClient.sendGpsClear() } catch (_: Exception) {} + try { bleClient.setWifiDumpEnabled(false) } catch (_: Exception) {} stopLiveGpxSession(reason = null) resetBacklogBlobReceiver(discardFile = true) bleClient.disconnect() @@ -348,6 +409,9 @@ class ScannerService : Service(), WiFinderBleClient.Listener { val autoHopEnabled = prefs.getBoolean(PREF_AUTO_HOP_ENABLED, false) gpsNavMode = normalizeGpsNavMode(prefs.getInt(PREF_GPS_NAV_MODE, 0)) gpxEnabled = prefs.getBoolean(PREF_GPX_ENABLED, true) + preferredBleDeviceAddress = prefs.getString(PREF_PREFERRED_BLE_DEVICE_ADDRESS, null) + preferredBleDeviceName = prefs.getString(PREF_PREFERRED_BLE_DEVICE_NAME, "") ?: "" + bleClient.setPreferredDeviceAddress(preferredBleDeviceAddress) _state.update { it.copy( visibleTimeoutSec = visibleTimeoutSec, @@ -356,6 +420,40 @@ class ScannerService : Service(), WiFinderBleClient.Listener { autoHopAppliedMs = autoHopBase, gpsNavMode = gpsNavMode, gpxEnabled = gpxEnabled, + preferredBleDeviceAddress = preferredBleDeviceAddress ?: "", + preferredBleDeviceName = preferredBleDeviceName, + ) + } + } + + private fun persistPreferredBleDevice(address: String, name: String?) { + preferredBleDeviceAddress = address + preferredBleDeviceName = name?.trim().orEmpty() + prefs.edit() + .putString(PREF_PREFERRED_BLE_DEVICE_ADDRESS, preferredBleDeviceAddress) + .putString(PREF_PREFERRED_BLE_DEVICE_NAME, preferredBleDeviceName) + .apply() + bleClient.setPreferredDeviceAddress(preferredBleDeviceAddress) + _state.update { + it.copy( + preferredBleDeviceAddress = preferredBleDeviceAddress ?: "", + preferredBleDeviceName = preferredBleDeviceName, + ) + } + } + + private fun clearPreferredBleDevice() { + preferredBleDeviceAddress = null + preferredBleDeviceName = "" + prefs.edit() + .remove(PREF_PREFERRED_BLE_DEVICE_ADDRESS) + .remove(PREF_PREFERRED_BLE_DEVICE_NAME) + .apply() + bleClient.setPreferredDeviceAddress(null) + _state.update { + it.copy( + preferredBleDeviceAddress = "", + preferredBleDeviceName = "", ) } } @@ -379,24 +477,38 @@ class ScannerService : Service(), WiFinderBleClient.Listener { prefs.edit().putBoolean(PREF_GPX_ENABLED, enabled).apply() } + private fun applyBacklogTransferSnapshot(snapshot: BacklogTransferSnapshot) { + _state.update { + it.copy( + downloadBacklogActive = snapshot.downloadActive, + blobActive = snapshot.blobActive, + blobSessionId = snapshot.blobSessionId, + blobBytesSent = snapshot.blobBytesSent, + blobBytesTotal = snapshot.blobBytesTotal, + ) + } + } + // ── Public API (called by ViewModel via binder) ───────────── fun doConnect() { + _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) } bleClient.connect() } fun doDisconnect() { + wifiDumpJob?.cancel() + wifiDumpJob = null + backlogTransferCoordinator.resetIdle() phoneGpsPushActive = false stopLiveGpxSession(reason = null) if (controlLinkReadySilent()) { bleClient.setBacklogBlobEnabled(false) + bleClient.setWifiDumpEnabled(false) } val shouldAutoStopCsv = downloadBacklogAutoStop downloadBacklogAutoStop = false downloadBacklogStartMs = 0L - synchronized(backlogRecoveryLock) { - backlogBlobRecoveryInFlight = false - } bleClient.sendGpsClear() resetBacklogBlobReceiver(discardFile = true) bleClient.disconnect() @@ -407,7 +519,43 @@ class ScannerService : Service(), WiFinderBleClient.Listener { if (shouldAutoStopCsv && csvLogger.isActive) { stopLogging() } - _state.update { it.copy(phoneGpsPushActive = false, downloadBacklogActive = false) } + _state.update { + it.copy( + phoneGpsPushActive = false, + downloadBacklogActive = false, + blobActive = false, + blobSessionId = 0L, + blobBytesSent = 0L, + blobBytesTotal = 0L, + bleDevicePickerVisible = false, + bleDeviceCandidates = emptyList(), + ) + } + } + + fun selectBleDevice(address: String): Boolean { + val normalized = address.trim().uppercase(Locale.US) + val selected = _state.value.bleDeviceCandidates.firstOrNull { it.address == normalized } ?: run { + appendLog("BLE picker selection failed: device missing") + return false + } + persistPreferredBleDevice(normalized, selected.name) + _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) } + val started = bleClient.connectToAddress(normalized) + if (!started) { + appendLog("BLE picker selection failed: connect attempt did not start") + } + return started + } + + fun dismissBleDevicePicker() { + _state.update { it.copy(bleDevicePickerVisible = false) } + } + + fun forgetPreferredBleDevice() { + clearPreferredBleDevice() + _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) } + appendLog("Forgot preferred BLE device") } fun disconnectAndStop() { @@ -542,23 +690,17 @@ class ScannerService : Service(), WiFinderBleClient.Listener { } } if (_state.value.downloadBacklogActive) { - val ok = bleClient.setBacklogBlobEnabled(false) - if (!ok) { + wifiDumpJob?.cancel() + wifiDumpJob = null + val okWifiDump = bleClient.setWifiDumpEnabled(false) + val okBlob = bleClient.setBacklogBlobEnabled(false) + if (!okWifiDump && !okBlob) { appendLog("Backlog stop command failed") return false } + backlogTransferCoordinator.cancel("Backlog download stopped") downloadBacklogStartMs = 0L resetBacklogBlobReceiver(discardFile = true) - _state.update { - it.copy( - downloadBacklogActive = false, - blobActive = false, - blobSessionId = 0L, - blobBytesSent = 0L, - blobBytesTotal = 0L, - ) - } - appendLog("Backlog download stopped") val shouldAutoStopCsv = downloadBacklogAutoStop downloadBacklogAutoStop = false if (shouldAutoStopCsv && csvLogger.isActive) { @@ -573,36 +715,26 @@ class ScannerService : Service(), WiFinderBleClient.Listener { synchronized(ackLock) { sessionAcks.clear() } - val okBlob = bleClient.setBacklogBlobEnabled(true) - if (!okBlob) { + if (!backlogTransferCoordinator.startWifi()) { + appendLog("Backlog download already active") + return false + } + val okWifiDump = bleClient.setWifiDumpEnabled(true) + if (!okWifiDump) { + backlogTransferCoordinator.failTransfer("wifi enable failed", "Backlog download request failed") downloadBacklogStartMs = 0L - _state.update { - it.copy( - downloadBacklogActive = false, - blobActive = false, - blobSessionId = 0L, - blobBytesSent = 0L, - blobBytesTotal = 0L, - ) - } - appendLog("Backlog download request failed") if (downloadBacklogAutoStop && csvLogger.isActive) { stopLogging() } downloadBacklogAutoStop = false return false } + downloadBacklogStartMs = System.currentTimeMillis() - _state.update { - it.copy( - downloadBacklogActive = true, - blobActive = false, - blobSessionId = 0L, - blobBytesSent = 0L, - blobBytesTotal = 0L, - ) + wifiDumpJob?.cancel() + wifiDumpJob = scope.launch { + runWifiDumpTransfer() } - appendLog("Backlog download started") bleClient.requestStatus() return true } @@ -791,7 +923,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener { downloadBacklogAutoStop = false downloadBacklogStartMs = 0L csvLogger.stopSession() - _state.update { it.copy(loggingEnabled = false, downloadBacklogActive = false) } + _state.update { it.copy(loggingEnabled = false) } appendLog("CSV logging stopped") } @@ -799,6 +931,9 @@ class ScannerService : Service(), WiFinderBleClient.Listener { override fun onConnectState(connected: Boolean) { if (!connected) { + wifiDumpJob?.cancel() + wifiDumpJob = null + backlogTransferCoordinator.resetIdle() lastEspGps = null lastEspFixMs = -1L stopLiveGpxSession(reason = null) @@ -811,9 +946,6 @@ class ScannerService : Service(), WiFinderBleClient.Listener { val shouldAutoStopCsv = downloadBacklogAutoStop downloadBacklogAutoStop = false downloadBacklogStartMs = 0L - synchronized(backlogRecoveryLock) { - backlogBlobRecoveryInFlight = false - } if (shouldAutoStopCsv && csvLogger.isActive) { stopLogging() } @@ -866,6 +998,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener { gpxLogging = if (!connected) false else current.gpxLogging, gpxPath = current.gpxPath, gpxPointCount = current.gpxPointCount, + bleDevicePickerVisible = if (!connected) false else current.bleDevicePickerVisible, + bleDeviceCandidates = if (!connected) emptyList() else current.bleDeviceCandidates, ) } appendLog(if (connected) "Connected" else "Disconnected") @@ -894,11 +1028,38 @@ class ScannerService : Service(), WiFinderBleClient.Listener { appendLog(message) } + override fun onBleDeviceCandidates(candidates: List) { + val mapped = candidates.map { + ServiceBleDeviceCandidate( + address = it.address, + name = it.name, + rssi = it.rssi, + remembered = it.remembered, + ) + } + _state.update { + it.copy( + bleDevicePickerVisible = mapped.isNotEmpty(), + bleDeviceCandidates = mapped, + ) + } + if (mapped.isNotEmpty()) { + appendLog("BLE picker: ${mapped.size} eligible devices") + } + } + + override fun onBleDeviceResolved(address: String, name: String?) { + persistPreferredBleDevice(address, name) + _state.update { it.copy(bleDevicePickerVisible = false, bleDeviceCandidates = emptyList()) } + appendLog("BLE target set: ${name?.takeIf { n -> n.isNotBlank() } ?: address}") + } + // ── Internal ─────────────────────────────────────────────── private fun handleStatusFrame(payload: ByteArray) { val wasEncrypted = _state.value.bleEncrypted val wasReplayActive = _state.value.replayActive + val transferOverlayActive = backlogTransferCoordinator.isActive() val status: StatusPayload = try { WgProtocol.decodeStatusPayload(payload) } catch (e: Exception) { @@ -945,10 +1106,10 @@ class ScannerService : Service(), WiFinderBleClient.Listener { spiffsUsedBytes = status.spiffsUsedBytes, spiffsFreeBytes = status.spiffsFreeBytes, dieTempCenti = status.dieTempCenti, - blobActive = status.blobActive, - blobSessionId = status.blobSessionId, - blobBytesSent = status.blobBytesSent, - blobBytesTotal = status.blobBytesTotal, + blobActive = if (transferOverlayActive) current.blobActive else status.blobActive, + blobSessionId = if (transferOverlayActive) current.blobSessionId else status.blobSessionId, + blobBytesSent = if (transferOverlayActive) current.blobBytesSent else status.blobBytesSent, + blobBytesTotal = if (transferOverlayActive) current.blobBytesTotal else status.blobBytesTotal, ) } @@ -958,30 +1119,18 @@ class ScannerService : Service(), WiFinderBleClient.Listener { val withinStartGrace = downloadBacklogStartMs > 0L && (nowMs - downloadBacklogStartMs) < 2500L if (_state.value.downloadBacklogActive && status.scanning && !withinStartGrace) { - downloadBacklogStartMs = 0L - _state.update { it.copy(downloadBacklogActive = false) } - appendLog("Backlog download stopped: scanner is active") - val shouldAutoStopCsv = downloadBacklogAutoStop - downloadBacklogAutoStop = false - if (shouldAutoStopCsv && csvLogger.isActive) { - stopLogging() - } + backlogTransferCoordinator.failTransfer( + reason = "scanner active", + logMessage = "Backlog download failed: scanner is active", + ) + finalizeBacklogTransferAfterTerminal() } else if (_state.value.downloadBacklogActive && + backlogTransferCoordinator.currentStage() == BacklogTransferStage.BLE_FALLBACK_ACTIVE && status.queuedRecords == 0L && !status.replayActive && !status.blobActive) { - _state.update { it.copy(downloadBacklogActive = false) } - if (controlLinkReadySilent()) { - bleClient.setBacklogBlobEnabled(false) - } - downloadBacklogStartMs = 0L - resetBacklogBlobReceiver(discardFile = true) - appendLog("Backlog download complete") - val shouldAutoStopCsv = downloadBacklogAutoStop - downloadBacklogAutoStop = false - if (shouldAutoStopCsv && csvLogger.isActive) { - stopLogging() - } + backlogTransferCoordinator.completeBleFallback("Backlog BLE fallback complete") + finalizeBacklogTransferAfterTerminal() } if (!status.bleEncrypted) return @@ -1170,14 +1319,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener { appendLog( "Backlog blob session 0x${java.lang.Long.toUnsignedString(meta.sessionId, 16)} size=${meta.totalBytes}B acked=${meta.ackedSeq} written=${meta.writtenSeq}", ) - _state.update { - it.copy( - blobActive = true, - blobSessionId = meta.sessionId, - blobBytesSent = 0L, - blobBytesTotal = meta.totalBytes, - ) - } + backlogTransferCoordinator.noteBleSessionMeta(meta.sessionId, meta.totalBytes) } private fun handleBacklogBlobChunkFrame(payload: ByteArray) { @@ -1193,10 +1335,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener { return } - var uiUpdateSessionId = 0L - var uiUpdateBytes = 0L - var uiUpdateTotal = 0L - var shouldUpdateUi = false + var progressBytes = -1L + var progressTotal = 0L var replyOffset = offset var replyLen = chunkLen var replyAccept = false @@ -1230,7 +1370,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener { appendLog("Backlog chunk write failed: ${e.message}") sendBacklogBlobChunkReply(sessionId, rx.receivedBytes, 0, accepted = false) resetBacklogBlobReceiver(discardFile = true) - recoverBacklogBlobStream("chunk write failed") + backlogTransferCoordinator.failBleFallback("chunk write failed") + finalizeBacklogTransferAfterTerminal() return } rx.receivedBytes += chunkLen.toLong() @@ -1246,10 +1387,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener { if (dueToTime || dueToBytes || done) { blobProgressUiLastMs = nowMs blobProgressUiLastBytes = rx.receivedBytes - shouldUpdateUi = true - uiUpdateSessionId = rx.sessionId - uiUpdateBytes = rx.receivedBytes - uiUpdateTotal = rx.totalBytes + progressBytes = rx.receivedBytes + progressTotal = rx.totalBytes } } } @@ -1261,15 +1400,8 @@ class ScannerService : Service(), WiFinderBleClient.Listener { accepted = replyAccept, ) - if (shouldUpdateUi) { - _state.update { - it.copy( - blobActive = true, - blobSessionId = uiUpdateSessionId, - blobBytesSent = uiUpdateBytes, - blobBytesTotal = uiUpdateTotal, - ) - } + if (progressBytes >= 0L) { + backlogTransferCoordinator.noteBleSessionProgress(sessionId, progressBytes, progressTotal) } } @@ -1357,14 +1489,7 @@ class ScannerService : Service(), WiFinderBleClient.Listener { appendLog( "Backlog blob received session 0x${java.lang.Long.toUnsignedString(done.sessionId, 16)} (${done.totalBytes}B), importing...", ) - _state.update { - it.copy( - blobActive = false, - blobSessionId = done.sessionId, - blobBytesSent = done.totalBytes, - blobBytesTotal = done.totalBytes, - ) - } + backlogTransferCoordinator.noteBleSessionProgress(done.sessionId, done.totalBytes, done.totalBytes) importBacklogBlobSessionAsync( file = completed.file, sessionId = done.sessionId, @@ -1390,11 +1515,13 @@ class ScannerService : Service(), WiFinderBleClient.Listener { ) if (importComplete) { queueBlobSessionAck(sessionId, targetSeq, force = true) - } else { - appendLog( - "Backlog import incomplete sid=0x${java.lang.Long.toUnsignedString(sessionId, 16)}; withholding ACK and retrying", + backlogTransferCoordinator.completeBleFallback( + "Backlog BLE fallback complete sid=0x${java.lang.Long.toUnsignedString(sessionId, 16)}", ) - recoverBacklogBlobStream("import integrity check failed") + finalizeBacklogTransferAfterTerminal() + } else { + backlogTransferCoordinator.failBleFallback("import integrity check failed") + finalizeBacklogTransferAfterTerminal() } try { file.delete() @@ -1418,41 +1545,435 @@ class ScannerService : Service(), WiFinderBleClient.Listener { } } - private fun recoverBacklogBlobStream(reason: String) { - if (!_state.value.downloadBacklogActive) { + private suspend fun runWifiDumpTransfer() { + var wifiCommitted = false + var cancelled = false + var failureReason: String? = null + var runId: Long = 0L + try { + val transferOk = withWifiDumpNetwork { network -> + val manifest = fetchWifiDumpManifest(network) + if (manifest == null) { + failureReason = "manifest unavailable" + return@withWifiDumpNetwork false + } + runId = manifest.runId + if (manifest.sessions.isEmpty()) { + appendLog("Backlog download: no pending sessions") + return@withWifiDumpNetwork true + } + + val dumpDirName = "wifi-dump-${SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())}" + val dumpDir = File(getExternalFilesDir(null), dumpDirName) + dumpDir.mkdirs() + + appendLog( + "Backlog Wi-Fi dump manifest run=0x${toHex64(runId)} sessions=${manifest.sessions.size}", + ) + + for ((index, session) in manifest.sessions.withIndex()) { + if (!currentCoroutineContext().isActive) { + cancelled = true + return@withWifiDumpNetwork false + } + val sidHex = toHex64(session.sessionId) + appendLog( + "Backlog Wi-Fi session ${index + 1}/${manifest.sessions.size} sid=0x$sidHex dat=${session.datBytes}B gpx=${session.gpxBytes}B", + ) + + val datFile = File(dumpDir, "session-$sidHex.dat") + backlogTransferCoordinator.noteBleSessionMeta(session.sessionId, session.datBytes) + + val datOk = downloadWifiDumpFile( + network = network, + sessionId = session.sessionId, + type = "dat", + expectedBytes = session.datBytes, + outFile = datFile, + ) { downloaded -> + backlogTransferCoordinator.noteBleSessionProgress( + sessionId = session.sessionId, + bytesReceived = downloaded.coerceAtLeast(0L), + totalBytes = session.datBytes, + ) + } + if (!datOk) { + failureReason = "session sid=0x$sidHex dat download failed" + return@withWifiDumpNetwork false + } + + val result = parseBacklogBlobFile(datFile, session.sessionId) + val targetSeq = if (session.writtenSeq > 0L) session.writtenSeq else result.highestSeq + val importComplete = + targetSeq > 0L && + result.badBlocks == 0 && + !result.gapDetected && + result.highestSeq >= targetSeq + appendLog( + "Backlog Wi-Fi import sid=0x$sidHex records=${result.records} highest_seq=${result.highestSeq} bad_blocks=${result.badBlocks} gap=${result.gapDetected}", + ) + if (!importComplete) { + failureReason = "session sid=0x$sidHex integrity check failed" + return@withWifiDumpNetwork false + } + try { + datFile.delete() + } catch (_: Exception) {} + + if (session.gpxBytes > 0L) { + val gpxFile = File(dumpDir, "session-$sidHex.gpx") + val gpxOk = downloadWifiDumpFile( + network = network, + sessionId = session.sessionId, + type = "gpx", + expectedBytes = session.gpxBytes, + outFile = gpxFile, + ) + if (!gpxOk) { + failureReason = "session sid=0x$sidHex gpx download failed" + return@withWifiDumpNetwork false + } + try { + gpxFile.delete() + } catch (_: Exception) {} + } + } + true + } ?: false + if (!transferOk) { + if (failureReason == null) { + failureReason = if (runId != 0L) { + "run=0x${toHex64(runId)} transfer failed" + } else { + "Wi-Fi transfer failed" + } + } + return + } + + if (!bleClient.commitWifiDump(runId)) { + failureReason = "commit failed for run=0x${toHex64(runId)}" + return + } + bleClient.requestStatus() + backlogTransferCoordinator.completeWifi() + finalizeBacklogTransferAfterTerminal() + wifiCommitted = true + } catch (e: kotlinx.coroutines.CancellationException) { + cancelled = true + } catch (e: Exception) { + failureReason = "transfer error: ${e.message ?: e.javaClass.simpleName}" + } finally { + wifiDumpJob = null + downloadBacklogStartMs = 0L + if (cancelled) { + backlogTransferCoordinator.cancel("Backlog download stopped") + finalizeBacklogTransferAfterTerminal() + return + } + if (!wifiCommitted && backlogTransferCoordinator.currentStage() == BacklogTransferStage.WIFI_ACTIVE) { + startBleFallbackAfterWifiFailure( + reason = failureReason ?: if (runId != 0L) { + "run=0x${toHex64(runId)} failed" + } else { + "Wi-Fi transfer failed" + }, + ) + } + } + } + + private fun startBleFallbackAfterWifiFailure(reason: String) { + val fallbackStarted = backlogTransferCoordinator.failWifiAndRequestBleFallback(reason) + if (!fallbackStarted) { + finalizeBacklogTransferAfterTerminal() return } if (!controlLinkReadySilent()) { - appendLog("Backlog recovery skipped: control link unavailable") + backlogTransferCoordinator.failBleFallback("control link unavailable") + finalizeBacklogTransferAfterTerminal() return } - synchronized(backlogRecoveryLock) { - if (backlogBlobRecoveryInFlight) { - return + val disableWifiOk = bleClient.setWifiDumpEnabled(false) + val enableBlobOk = bleClient.setBacklogBlobEnabled(true) + if (!disableWifiOk || !enableBlobOk) { + backlogTransferCoordinator.failBleFallback("BLE fallback toggle failed") + finalizeBacklogTransferAfterTerminal() + return + } + resetBacklogBlobReceiver(discardFile = true) + bleClient.requestStatus() + } + + private fun finalizeBacklogTransferAfterTerminal() { + downloadBacklogStartMs = 0L + resetBacklogBlobReceiver(discardFile = true) + if (controlLinkReadySilent()) { + bleClient.setWifiDumpEnabled(false) + bleClient.setBacklogBlobEnabled(false) + bleClient.requestStatus() + } + val shouldAutoStopCsv = downloadBacklogAutoStop + downloadBacklogAutoStop = false + if (shouldAutoStopCsv && csvLogger.isActive) { + stopLogging() + } + } + + private fun toHex64(value: Long): String = + java.lang.Long.toUnsignedString(value, 16).uppercase(Locale.US).padStart(16, '0') + + @SuppressLint("MissingPermission") + private suspend fun withWifiDumpNetwork(block: suspend (Network) -> T?): T? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + appendLog("Backlog Wi-Fi dump requires Android 10+") + return null + } + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION, + ) != PackageManager.PERMISSION_GRANTED) { + appendLog("Backlog Wi-Fi dump blocked: ACCESS_FINE_LOCATION permission missing") + return null + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) != PackageManager.PERMISSION_GRANTED) { + appendLog("Backlog Wi-Fi dump blocked: NEARBY_WIFI_DEVICES permission missing") + return null + } + val cm = getSystemService(ConnectivityManager::class.java) + if (cm == null) { + appendLog("Backlog Wi-Fi dump failed: ConnectivityManager unavailable") + return null + } + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .setNetworkSpecifier( + WifiNetworkSpecifier.Builder() + .setSsid(WIFI_DUMP_AP_SSID) + .build(), + ) + .build() + + val acquired = withTimeoutOrNull(WIFI_DUMP_NETWORK_WAIT_MS) { + suspendCancellableCoroutine?> { cont -> + var completed = false + lateinit var callback: ConnectivityManager.NetworkCallback + fun finish(value: Pair?) { + if (completed) return + completed = true + cont.resume(value) + } + callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + appendLog("Backlog Wi-Fi network acquired: $WIFI_DUMP_AP_SSID") + finish(network to this) + } + + override fun onUnavailable() { + appendLog("Backlog Wi-Fi network unavailable: $WIFI_DUMP_AP_SSID") + try { + cm.unregisterNetworkCallback(this) + } catch (_: Exception) {} + finish(null) + } + + override fun onLost(network: Network) { + if (!completed) { + appendLog("Backlog Wi-Fi network lost before transfer") + try { + cm.unregisterNetworkCallback(this) + } catch (_: Exception) {} + finish(null) + } + } + } + try { + cm.requestNetwork(request, callback) + } catch (e: Exception) { + appendLog("Backlog Wi-Fi request failed: ${e.javaClass.simpleName}${if (e.message.isNullOrBlank()) "" else " (${e.message})"}") + finish(null) + return@suspendCancellableCoroutine + } + cont.invokeOnCancellation { + try { + cm.unregisterNetworkCallback(callback) + } catch (_: Exception) {} + } } - backlogBlobRecoveryInFlight = true } - appendLog("Backlog blob desync ($reason); restarting stream") - scope.launch { + if (acquired == null) { + appendLog("Backlog Wi-Fi request timed out (${WIFI_DUMP_NETWORK_WAIT_MS}ms)") + return null + } + + val network = acquired.first + val callback = acquired.second + val previous = cm.boundNetworkForProcess + val bound = try { + cm.bindProcessToNetwork(network) + } catch (_: Exception) { + false + } + if (!bound) { + appendLog("Backlog Wi-Fi bindProcessToNetwork failed") + try { + cm.unregisterNetworkCallback(callback) + } catch (_: Exception) {} + return null + } + + return try { + block(network) + } finally { + try { + cm.bindProcessToNetwork(previous) + } catch (_: Exception) {} + try { + cm.unregisterNetworkCallback(callback) + } catch (_: Exception) {} + } + } + + private suspend fun fetchWifiDumpManifest(network: Network): WifiDumpManifest? = + withContext(Dispatchers.IO) { + val url = URL("http://$WIFI_DUMP_AP_HOST$WIFI_DUMP_MANIFEST_PATH") + val conn = (network.openConnection(url) as? HttpURLConnection) ?: return@withContext null try { - val disableOk = bleClient.setBacklogBlobEnabled(false) - if (!disableOk) { - appendLog("Backlog recovery failed: disable command failed") - return@launch + conn.requestMethod = "GET" + conn.connectTimeout = WIFI_DUMP_HTTP_CONNECT_TIMEOUT_MS + conn.readTimeout = WIFI_DUMP_HTTP_READ_TIMEOUT_MS + conn.setRequestProperty("Connection", "close") + val code = conn.responseCode + if (code != HttpURLConnection.HTTP_OK) { + return@withContext null } - delay(120) - val enableOk = bleClient.setBacklogBlobEnabled(true) - if (!enableOk) { - appendLog("Backlog recovery failed: enable command failed") - return@launch + val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val root = JSONObject(body) + val runId = root.optString("run_id").trim() + if (runId.isEmpty()) { + return@withContext null } - delay(80) - bleClient.requestStatus() + val runValue = try { + runId.toULong(16).toLong() + } catch (_: Exception) { + return@withContext null + } + val sessionsJson = root.optJSONArray("sessions") ?: return@withContext WifiDumpManifest( + runId = runValue, + sessions = emptyList(), + ) + val sessions = mutableListOf() + for (i in 0 until sessionsJson.length()) { + val item = sessionsJson.optJSONObject(i) ?: continue + val sidHex = item.optString("sid").trim() + if (sidHex.isEmpty()) { + continue + } + val sid = try { + sidHex.toULong(16).toLong() + } catch (_: Exception) { + continue + } + val datBytes = item.optLong("dat_bytes", 0L).coerceAtLeast(0L) + if (datBytes <= 0L) { + continue + } + sessions += WifiDumpSession( + sessionId = sid, + datBytes = datBytes, + gpxBytes = item.optLong("gpx_bytes", 0L).coerceAtLeast(0L), + writtenSeq = item.optLong("written_seq", 0L).coerceAtLeast(0L), + ackedSeq = item.optLong("acked_seq", 0L).coerceAtLeast(0L), + ) + } + WifiDumpManifest(runId = runValue, sessions = sessions) } finally { - synchronized(backlogRecoveryLock) { - backlogBlobRecoveryInFlight = false + conn.disconnect() + } + } + + private suspend fun downloadWifiDumpFile( + network: Network, + sessionId: Long, + type: String, + expectedBytes: Long, + outFile: File, + onProgress: ((Long) -> Unit)? = null, + ): Boolean = withContext(Dispatchers.IO) { + if (expectedBytes <= 0L) { + return@withContext false + } + outFile.parentFile?.mkdirs() + val tmpFile = File(outFile.parentFile, "${outFile.name}.part") + if (tmpFile.exists()) { + tmpFile.delete() + } + + val sidHex = toHex64(sessionId) + val url = URL("http://$WIFI_DUMP_AP_HOST$WIFI_DUMP_FILE_PATH?sid=$sidHex&type=$type") + val conn = (network.openConnection(url) as? HttpURLConnection) ?: return@withContext false + try { + conn.requestMethod = "GET" + conn.connectTimeout = WIFI_DUMP_HTTP_CONNECT_TIMEOUT_MS + conn.readTimeout = WIFI_DUMP_HTTP_READ_TIMEOUT_MS + conn.setRequestProperty("Connection", "close") + val code = conn.responseCode + if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) { + return@withContext false + } + val input = conn.inputStream ?: return@withContext false + var written = 0L + var lastUiMs = 0L + input.use { stream -> + BufferedInputStream(stream, 64 * 1024).use { bin -> + FileOutputStream(tmpFile, false).use { fout -> + BufferedOutputStream(fout, 64 * 1024).use { bout -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = bin.read(buffer) + if (read < 0) { + break + } + if (read == 0) { + continue + } + bout.write(buffer, 0, read) + written += read.toLong() + if (onProgress != null) { + val now = System.currentTimeMillis() + if (written == expectedBytes || + lastUiMs == 0L || + now - lastUiMs >= WIFI_DUMP_PROGRESS_UI_INTERVAL_MS) { + lastUiMs = now + onProgress(written) + } + } + } + bout.flush() + } + } } } + if (written != expectedBytes) { + tmpFile.delete() + return@withContext false + } + if (outFile.exists()) { + outFile.delete() + } + if (!tmpFile.renameTo(outFile)) { + return@withContext false + } + true + } catch (_: Exception) { + tmpFile.delete() + false + } finally { + conn.disconnect() } } diff --git a/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt b/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt index d152bda..7f1738e 100644 --- a/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt +++ b/android/app/src/main/java/com/wifinder/android/ui/AppUiState.kt @@ -10,6 +10,13 @@ data class SightingUi( val seenCount: Int, ) +data class BleDeviceCandidateUi( + val address: String, + val name: String, + val rssi: Int, + val remembered: Boolean, +) + data class AppUiState( val permissionsGranted: Boolean = false, val serviceRunning: Boolean = false, @@ -83,6 +90,10 @@ data class AppUiState( val dashboardMode: Boolean = false, val loggingEnabled: Boolean = false, val csvPath: String = "", + val preferredBleDeviceAddress: String = "", + val preferredBleDeviceName: String = "", + val bleDevicePickerVisible: Boolean = false, + val bleDeviceCandidates: List = emptyList(), val sightings: List = emptyList(), val logs: List = emptyList(), val expandedSections: Set = emptySet(), diff --git a/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt b/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt index 3ee8161..6e62522 100644 --- a/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt +++ b/android/app/src/main/java/com/wifinder/android/ui/DashboardScreen.kt @@ -1,5 +1,8 @@ package com.wifinder.android.ui +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,6 +25,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,6 +38,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText @@ -40,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wifinder.android.model.WifiBand +import kotlinx.coroutines.delay // --------------------------------------------------------------------------- // Colours @@ -51,7 +62,7 @@ private val TealDim = Color(0xFF00897B) private val Amber = Color(0xFFFFC107) private val Red = Color(0xFFEF5350) private val Green = Color(0xFF4CAF50) -private val DimLabel = Color(0xFF757575) +private val DimLabel = Color(0xFF9E9E9E) private val BrightLabel = Color(0xFFE0E0E0) // --------------------------------------------------------------------------- @@ -85,40 +96,38 @@ private fun DashboardPortrait(state: AppUiState, onExit: () -> Unit) { Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp), ) { - DashTopBar(onExit = onExit, state = state) - - Spacer(Modifier.weight(1f)) - - HeroApCount(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth()) - - Spacer(Modifier.weight(1f)) - - RateStrip(state = state, modifier = Modifier.fillMaxWidth()) - - Spacer(Modifier.weight(1f)) - - HealthIndicators(state = state, modifier = Modifier.fillMaxWidth()) + // 5% top margin + Spacer(Modifier.weight(0.05f)) - Spacer(Modifier.weight(1f)) - - GpsDetailRow(state = state, modifier = Modifier.fillMaxWidth()) - - Spacer(Modifier.weight(1f)) + // 55% info section + Column( + modifier = Modifier + .fillMaxWidth() + .weight(0.55f), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + DashTopBar(onExit = onExit, state = state) + HeroRow(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth()) + RateStrip(state = state, modifier = Modifier.fillMaxWidth()) + HealthIndicators(state = state, modifier = Modifier.fillMaxWidth()) + GpsDopRow(state = state, modifier = Modifier.fillMaxWidth()) + GpsStatusRow(state = state, modifier = Modifier.fillMaxWidth()) + StorageBar(state = state, modifier = Modifier.fillMaxWidth()) + BacklogRow(state = state, modifier = Modifier.fillMaxWidth()) + } + // 35% channel chart ChannelChart( sightings = state.sightings, modifier = Modifier .fillMaxWidth() - .height(150.dp), + .weight(0.35f), ) - Spacer(Modifier.weight(1f)) - - StorageBar(state = state, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(4.dp)) - BacklogRow(state = state, modifier = Modifier.fillMaxWidth()) + // 5% bottom margin + Spacer(Modifier.weight(0.05f)) } } @@ -149,10 +158,11 @@ private fun DashboardLandscape(state: AppUiState, onExit: () -> Unit) { .fillMaxHeight(), verticalArrangement = Arrangement.SpaceEvenly, ) { - HeroApCount(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth()) + HeroRow(count = state.uniqueBssids, modifier = Modifier.fillMaxWidth()) RateStrip(state = state, modifier = Modifier.fillMaxWidth()) HealthIndicators(state = state, modifier = Modifier.fillMaxWidth()) - GpsDetailRow(state = state, modifier = Modifier.fillMaxWidth()) + GpsDopRow(state = state, modifier = Modifier.fillMaxWidth()) + GpsStatusRow(state = state, modifier = Modifier.fillMaxWidth()) } // Right column @@ -238,26 +248,86 @@ private fun MiniDot(color: Color) { } // --------------------------------------------------------------------------- -// Hero AP count +// Hero row: AP count + battery side by side // --------------------------------------------------------------------------- @Composable -private fun HeroApCount(count: Int, modifier: Modifier = Modifier) { +private fun HeroRow(count: Int, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // AP count – hero + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f), + ) { + Text( + text = "%,d".format(count), + color = Teal, + fontSize = 56.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + maxLines = 1, + ) + Text( + text = "UNIQUE APs", + color = DimLabel, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 2.sp, + ) + } + + // Battery block + BatteryBlock(modifier = Modifier.weight(0.6f)) + } +} + +@Composable +private fun BatteryBlock(modifier: Modifier = Modifier) { + val context = LocalContext.current + var pct by remember { mutableIntStateOf(-1) } + var charging by remember { mutableStateOf(false) } + + // Poll every 5 seconds via the sticky broadcast (very cheap, no receiver registration) + LaunchedEffect(Unit) { + while (true) { + val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100 + pct = if (level >= 0 && scale > 0) (level * 100) / scale else -1 + charging = (intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0) != 0 + delay(5_000L) + } + } + + val battColor = when { + pct < 0 -> DimLabel + pct <= 15 -> Red + pct <= 30 -> Amber + charging -> Green + else -> BrightLabel + } + val stateText = if (charging) "⚡CHG" else "BAT" + Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "%,d".format(count), - color = Teal, - fontSize = 56.sp, + text = if (pct >= 0) "$pct%" else "—", + color = battColor, + fontSize = 36.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, textAlign = TextAlign.Center, maxLines = 1, ) Text( - text = "UNIQUE APs", - color = DimLabel, + text = stateText, + color = if (charging) Green else DimLabel, fontSize = 12.sp, fontWeight = FontWeight.Medium, letterSpacing = 2.sp, @@ -274,7 +344,7 @@ private fun RateStrip(state: AppUiState, modifier: Modifier = Modifier) { modifier = modifier .clip(RoundedCornerShape(8.dp)) .background(CardBg) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 12.dp, vertical = 10.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { @@ -285,8 +355,8 @@ private fun RateStrip(state: AppUiState, modifier: Modifier = Modifier) { ) VerticalDivider() MetricCell( - value = "%.0f".format(state.phoneSpeedKmh), - label = "km/h", + value = if (state.dieTempCenti != Int.MIN_VALUE) "%.0f°".format(state.dieTempCenti / 100.0) else "—", + label = "die temp", modifier = Modifier.weight(1f), ) VerticalDivider() @@ -315,7 +385,8 @@ private fun MetricCell(value: String, label: String, modifier: Modifier = Modifi Text( text = label, color = DimLabel, - fontSize = 10.sp, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, maxLines = 1, ) } @@ -340,7 +411,7 @@ private fun HealthIndicators(state: AppUiState, modifier: Modifier = Modifier) { modifier = modifier .clip(RoundedCornerShape(8.dp)) .background(CardBg) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 12.dp, vertical = 10.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { @@ -383,7 +454,7 @@ private fun HealthIndicators(state: AppUiState, modifier: Modifier = Modifier) { private fun HealthDot(label: String, color: Color) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Box( modifier = Modifier @@ -394,7 +465,8 @@ private fun HealthDot(label: String, color: Color) { Text( text = label, color = DimLabel, - fontSize = 9.sp, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, maxLines = 1, ) } @@ -426,7 +498,7 @@ private fun gpsNavRateCompact(mode: Int, appliedHz: Int): String { } @Composable -private fun GpsDetailRow(state: AppUiState, modifier: Modifier = Modifier) { +private fun GpsDopRow(state: AppUiState, modifier: Modifier = Modifier) { val hdopText = if (state.gpsHdopCenti > 0) "%.2f".format(state.gpsHdopCenti / 100.0) else "—" val vdopText = if (state.gpsVdopCenti > 0) "%.2f".format(state.gpsVdopCenti / 100.0) else "—" val pdopText = if (state.gpsPdopCenti > 0) "%.2f".format(state.gpsPdopCenti / 100.0) else "—" @@ -437,21 +509,34 @@ private fun GpsDetailRow(state: AppUiState, modifier: Modifier = Modifier) { else -> "—" } val accText = if (state.gpsValid) "±${"%.1f".format(state.gpsAccuracyDm / 10.0)}m" else "—" - val ageText = if (state.gpsValid) "${state.gpsAgeS}s" else "—" - val rateText = gpsNavRateCompact(state.gpsNavMode, state.gpsNavAppliedHz) - val dopText = "$hdopText/$vdopText/$pdopText" Row( modifier = modifier - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) .background(CardBg) .padding(horizontal = 10.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - GpsMini(value = dopText, label = "H/V/P", color = pdopColor(state.gpsPdopCenti)) + GpsMini(value = "$hdopText/$vdopText/$pdopText", label = "H/V/P", color = pdopColor(state.gpsPdopCenti)) GpsMini(value = satText, label = "Use/View", color = BrightLabel) GpsMini(value = accText, label = "Acc", color = BrightLabel) + } +} + +@Composable +private fun GpsStatusRow(state: AppUiState, modifier: Modifier = Modifier) { + val ageText = if (state.gpsValid) "${state.gpsAgeS}s" else "—" + val rateText = gpsNavRateCompact(state.gpsNavMode, state.gpsNavAppliedHz) + + Row( + modifier = modifier + .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) + .background(CardBg) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { GpsMini( value = gpsSourceLabel(state.gpsSource), label = "Src", @@ -468,6 +553,11 @@ private fun GpsDetailRow(state: AppUiState, modifier: Modifier = Modifier) { else -> Red }) GpsMini(value = rateText, label = "Nav", color = DimLabel) + GpsMini( + value = "%.0f".format(state.phoneSpeedKmh), + label = "km/h", + color = BrightLabel, + ) } } @@ -485,7 +575,8 @@ private fun GpsMini(value: String, label: String, color: Color) { Text( text = label, color = DimLabel, - fontSize = 9.sp, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, maxLines = 1, ) } diff --git a/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt b/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt index e4af070..bd8f513 100644 --- a/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt +++ b/android/app/src/main/java/com/wifinder/android/ui/MainViewModel.kt @@ -285,6 +285,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun downloadBacklog() { service?.downloadBacklogToCsv() } fun seedDebugBacklog() { service?.seedDebugBacklog() } fun pushPhoneGpsNow() { service?.pushPhoneGpsNow() } + fun selectBleDevice(address: String) { service?.selectBleDevice(address) } + fun dismissBleDevicePicker() { service?.dismissBleDevicePicker() } + fun forgetPreferredBleDevice() { service?.forgetPreferredBleDevice() } // ── State merging ───────────────────────────────────────── @@ -359,6 +362,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { gpxPointCount = svc.gpxPointCount, loggingEnabled = svc.loggingEnabled, csvPath = svc.csvPath, + preferredBleDeviceAddress = svc.preferredBleDeviceAddress, + preferredBleDeviceName = svc.preferredBleDeviceName, + bleDevicePickerVisible = svc.bleDevicePickerVisible, + bleDeviceCandidates = svc.bleDeviceCandidates.map { + BleDeviceCandidateUi( + address = it.address, + name = it.name, + rssi = it.rssi, + remembered = it.remembered, + ) + }, sightings = svc.sightings, logs = svc.logs, hopInputMs = if (!ui.hopInputDirty) svc.autoHopBaseMs else ui.hopInputMs, diff --git a/android/app/src/test/java/com/wifinder/android/ble/BleTargetingPolicyTest.kt b/android/app/src/test/java/com/wifinder/android/ble/BleTargetingPolicyTest.kt new file mode 100644 index 0000000..e565257 --- /dev/null +++ b/android/app/src/test/java/com/wifinder/android/ble/BleTargetingPolicyTest.kt @@ -0,0 +1,56 @@ +package com.wifinder.android.ble + +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Test + +class BleTargetingPolicyTest { + + @Test + fun `service uuid is required and name guard is secondary`() { + assertFalse(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = false, advertisedName = "WIFINDER-C6")) + assertFalse(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = true, advertisedName = "OtherDevice")) + assertTrue(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = true, advertisedName = "WIFINDER-S3")) + assertTrue(BleTargetingPolicy.isEligibleCandidate(hasServiceUuid = true, advertisedName = null)) + } + + @Test + fun `remembered address is prioritized for auto-connect`() { + val candidates = listOf( + BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -50), + BleDeviceCandidate(address = "AA:BB:CC:DD:EE:02", name = "WIFINDER-2", rssi = -40), + ) + + val chosen = BleTargetingPolicy.chooseAutoConnect(candidates, preferredAddress = "aa:bb:cc:dd:ee:01") + + assertEquals("AA:BB:CC:DD:EE:01", chosen?.address) + } + + @Test + fun `no preferred device auto-connects only when single candidate exists`() { + val one = listOf(BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -55)) + val many = listOf( + BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -55), + BleDeviceCandidate(address = "AA:BB:CC:DD:EE:02", name = "WIFINDER-2", rssi = -40), + ) + + assertEquals("AA:BB:CC:DD:EE:01", BleTargetingPolicy.chooseAutoConnect(one, preferredAddress = null)?.address) + assertNull(BleTargetingPolicy.chooseAutoConnect(many, preferredAddress = null)) + } + + @Test + fun `sorted candidates place remembered first`() { + val sorted = BleTargetingPolicy.sortCandidates( + candidates = listOf( + BleDeviceCandidate(address = "AA:BB:CC:DD:EE:01", name = "WIFINDER-1", rssi = -55), + BleDeviceCandidate(address = "AA:BB:CC:DD:EE:02", name = "WIFINDER-2", rssi = -40), + ), + preferredAddress = "AA:BB:CC:DD:EE:01", + ) + + assertEquals("AA:BB:CC:DD:EE:01", sorted.first().address) + assertTrue(sorted.first().remembered) + } +} diff --git a/android/app/src/test/java/com/wifinder/android/service/BacklogTransferCoordinatorTest.kt b/android/app/src/test/java/com/wifinder/android/service/BacklogTransferCoordinatorTest.kt new file mode 100644 index 0000000..fcdf7aa --- /dev/null +++ b/android/app/src/test/java/com/wifinder/android/service/BacklogTransferCoordinatorTest.kt @@ -0,0 +1,65 @@ +package com.wifinder.android.service + +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +class BacklogTransferCoordinatorTest { + + @Test + fun `wifi failure transitions to single ble fallback and then complete`() { + val snapshots = mutableListOf() + val logs = mutableListOf() + val coordinator = BacklogTransferCoordinator( + onSnapshot = { snapshots += it }, + onLog = { logs += it }, + ) + + assertTrue(coordinator.startWifi()) + assertEquals(BacklogTransferStage.WIFI_ACTIVE, coordinator.currentStage()) + + val startedFallback = coordinator.failWifiAndRequestBleFallback("manifest unavailable") + assertTrue(startedFallback) + assertEquals(BacklogTransferStage.BLE_FALLBACK_ACTIVE, coordinator.currentStage()) + + coordinator.noteBleSessionMeta(sessionId = 0x1234L, totalBytes = 4096L) + coordinator.noteBleSessionProgress(sessionId = 0x1234L, bytesReceived = 4096L, totalBytes = 4096L) + coordinator.completeBleFallback("fallback imported") + + assertEquals(BacklogTransferStage.COMPLETE, coordinator.currentStage()) + val last = snapshots.last() + assertFalse(last.downloadActive) + assertFalse(last.blobActive) + assertTrue(logs.any { it.contains("Switching to BLE fallback") }) + assertTrue(logs.any { it.contains("fallback imported") }) + } + + @Test + fun `only one fallback attempt is allowed`() { + val coordinator = BacklogTransferCoordinator( + onSnapshot = {}, + onLog = {}, + ) + + assertTrue(coordinator.startWifi()) + assertTrue(coordinator.failWifiAndRequestBleFallback("network timeout")) + coordinator.failBleFallback("chunk mismatch") + + assertEquals(BacklogTransferStage.FAILED, coordinator.currentStage()) + assertFalse(coordinator.failWifiAndRequestBleFallback("second wifi failure")) + assertEquals(BacklogTransferStage.FAILED, coordinator.currentStage()) + } + + @Test + fun `cancel returns transfer to idle`() { + val coordinator = BacklogTransferCoordinator( + onSnapshot = {}, + onLog = {}, + ) + + assertTrue(coordinator.startWifi()) + coordinator.cancel("user cancelled") + assertEquals(BacklogTransferStage.IDLE, coordinator.currentStage()) + } +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 0c95991..f4184b9 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -7,6 +7,9 @@ idf_component_register( "wg_payload.c" "ble_security_policy.c" "scan_policy.c" + "wg_session_transport.c" + "wg_storage_backlog.c" + "wg_gps_nmea.c" INCLUDE_DIRS "." REQUIRES @@ -16,6 +19,8 @@ idf_component_register( esp_event esp_netif esp_timer + esp_http_server + esp_app_format spiffs fatfs sdmmc diff --git a/main/main.c b/main/main.c index e114a0e..c73cc24 100644 --- a/main/main.c +++ b/main/main.c @@ -25,12 +25,14 @@ #include "driver/temperature_sensor.h" #include "driver/uart.h" #include "driver/usb_serial_jtag.h" +#include "esp_app_desc.h" #include "esp_err.h" #include "esp_event.h" #include "esp_log.h" #include "esp_netif.h" #include "esp_random.h" #include "esp_vfs_fat.h" +#include "esp_http_server.h" #include "esp_spiffs.h" #include "esp_system.h" #include "esp_timer.h" @@ -58,17 +60,18 @@ #include "ble_security_policy.h" #include "scan_policy.h" #include "sniffer_logic.h" +#include "wg_gps_nmea.h" #include "wg_payload.h" #include "wg_protocol.h" +#include "wg_session_transport.h" +#include "wg_storage_backlog.h" extern void ble_store_config_init(void); #if CONFIG_IDF_TARGET_ESP32C6 #define WG_DEVICE_NAME "WIFINDER-C6" -#define WG_DEVICE_INFO_TEXT "WIFINDER-C6 FW1" #else #define WG_DEVICE_NAME "WIFINDER-S3" -#define WG_DEVICE_INFO_TEXT "WIFINDER-S3 FW1" #endif #define WG_DEFAULT_HOP_MS 250 @@ -174,6 +177,18 @@ _Static_assert(WG_NODE_5GHZ_MASK_BIT_COUNT < 64, "WG_NODE_5GHZ_MASK_BIT_COUNT mu #define WG_SD_SPI_MOSI_GPIO GPIO_NUM_22 #define WG_SD_SPI_MISO_GPIO GPIO_NUM_23 +#define WG_WIFI_DUMP_AP_SSID "WIFINDER-DUMP" +#define WG_WIFI_DUMP_AP_CHANNEL 6 +#define WG_WIFI_DUMP_HTTP_PORT 80 +#define WG_WIFI_DUMP_MANIFEST_URI "/wifinder/dump/manifest.json" +#define WG_WIFI_DUMP_FILE_URI "/wifinder/dump/file" +#define WG_WIFI_DUMP_COMMIT_URI "/wifinder/dump/commit" +#define WG_WIFI_DUMP_ABORT_URI "/wifinder/dump/abort" +#define WG_WIFI_DUMP_JOURNAL_FILE "wq_dump_journal.bin" +#define WG_WIFI_DUMP_JOURNAL_MAGIC 0x314A4457U +#define WG_WIFI_DUMP_JOURNAL_VERSION 1 +#define WG_WIFI_DUMP_HTTP_CHUNK 2048 + #define WG_NODE_UART_NUM LP_UART_NUM_0 #define WG_NODE_UART_TX_GPIO 5 #define WG_NODE_UART_RX_GPIO 4 @@ -352,6 +367,30 @@ typedef struct __attribute__((packed)) { uint32_t crc32; } wg_session_manifest_t; +typedef struct __attribute__((packed)) { + uint32_t magic; + uint16_t version; + uint16_t header_size; + uint64_t run_id; + uint8_t committed; + uint8_t reserved[7]; + uint32_t session_count; + uint32_t crc32; +} wg_dump_journal_header_t; + +typedef struct __attribute__((packed)) { + uint64_t session_id; + uint32_t ack_seq; +} wg_dump_journal_entry_t; + +typedef struct { + uint64_t session_id; + uint32_t data_bytes; + uint32_t gpx_bytes; + uint32_t written_seq; + uint32_t acked_seq; +} wg_dump_session_t; + static const char *TAG = "wifinder"; static ap_entry_t s_seen[WG_SEEN_CAPACITY]; @@ -575,6 +614,16 @@ static int64_t s_gpx_last_point_ms = 0; static int64_t s_gpx_last_flush_ms = 0; static uint32_t s_gpx_points_written = 0; +static httpd_handle_t s_wifi_dump_httpd = NULL; +static bool s_wifi_dump_requested = false; +static bool s_wifi_dump_active = false; +static bool s_wifi_dump_ap_running = false; +static uint64_t s_wifi_dump_run_id = 0; +static int64_t s_wifi_dump_started_ms = 0; +static wg_dump_session_t *s_wifi_dump_sessions = NULL; +static size_t s_wifi_dump_session_count = 0; +static size_t s_wifi_dump_session_capacity = 0; + #if CONFIG_WG_RGB_LED_ENABLE typedef struct { uint8_t r; @@ -678,6 +727,7 @@ static bool storage_has_output_link(void); static const char *storage_base_path(void); static bool storage_get_fs_info(uint64_t *total_bytes_out, uint64_t *used_bytes_out); static bool storage_commit_file(FILE *fp, const char *kind, uint64_t session_id); +static bool storage_parse_meta_filename(const char *name, uint64_t *session_id_out); static bool storage_open_session(void); static bool storage_open_session_locked(void); static void storage_close_session(wg_session_state_t final_state); @@ -697,6 +747,14 @@ static bool storage_seed_synthetic(uint32_t target_bytes, uint32_t *records_adde uint32_t *bytes_added_out); static bool storage_clear_sessions(void); static bool storage_patch_sighting_source(uint8_t *payload, uint16_t payload_len, uint8_t source_flags); +static void storage_refresh_backlog_locked(bool reclaim); +static bool storage_read_manifest(uint64_t session_id, wg_session_manifest_t *out); +static bool storage_flush_pending_locked(bool force); +static void storage_reset_replay_locked(void); +static void storage_reset_blob_locked(void); +static bool storage_set_wifi_dump_enabled(bool enabled); +static bool storage_commit_wifi_dump_run(uint64_t run_id); +static void storage_recover_wifi_dump_journal(void); static uint16_t ble_notify_frame_payload_limit(void); static void wake_replay_task(void); static void request_fast_ble_conn_params(uint16_t conn_handle); @@ -713,6 +771,22 @@ static void wr_u32_le(uint8_t *out, uint32_t value); static void wr_u64_le(uint8_t *out, uint64_t value); static void scan_state_lock(void); static void scan_state_unlock(void); +static void storage_dump_reset_sessions_locked(void); +static bool storage_dump_ensure_capacity_locked(size_t needed); +static bool storage_dump_collect_sessions_locked(void); +static bool storage_dump_journal_write_locked(bool committed); +static bool storage_dump_journal_load_locked(wg_dump_journal_header_t *header_out, + wg_dump_journal_entry_t **entries_out); +static bool storage_dump_journal_delete_locked(void); +static bool storage_dump_start_locked(void); +static void storage_dump_stop_locked(bool keep_journal); +static bool storage_dump_commit_locked(uint64_t run_id); +static bool wifi_dump_http_start_locked(void); +static void wifi_dump_http_stop_locked(void); +static bool wifi_dump_ap_start_locked(void); +static void wifi_dump_ap_stop_locked(void); +static int wifi_dump_manifest_handler(httpd_req_t *req); +static int wifi_dump_file_handler(httpd_req_t *req); static const struct ble_gatt_svc_def gatt_services[] = { { @@ -1049,7 +1123,7 @@ static void led_sync_runtime_state(void) { s_led_state.scanning = s_scanning; s_led_state.gps_valid = s_latest_gps.valid; s_led_state.gps_source = s_latest_gps.valid ? (uint8_t)s_gps_source : 0; - s_led_state.replay_active = s_replay_active || s_blob_active; + s_led_state.replay_active = s_replay_active || s_blob_active || s_wifi_dump_active; s_led_state.backlog_present = s_queue_backlog_records > 0; } @@ -1469,6 +1543,128 @@ static void storage_gpx_paths(uint64_t session_id, char *gpx_path, size_t gpx_si } } +static void storage_dump_journal_path(char *path_out, size_t path_size) { + if (path_out == NULL || path_size == 0) { + return; + } + snprintf(path_out, path_size, "%s/%s", storage_base_path(), WG_WIFI_DUMP_JOURNAL_FILE); +} + +static void storage_dump_reset_sessions_locked(void) { + if (s_wifi_dump_sessions != NULL) { + free(s_wifi_dump_sessions); + } + s_wifi_dump_sessions = NULL; + s_wifi_dump_session_count = 0; + s_wifi_dump_session_capacity = 0; +} + +static bool storage_dump_ensure_capacity_locked(size_t needed) { + if (needed <= s_wifi_dump_session_capacity) { + return true; + } + size_t next_cap = s_wifi_dump_session_capacity > 0 ? s_wifi_dump_session_capacity : 8; + while (next_cap < needed) { + if (next_cap > (SIZE_MAX / 2U)) { + next_cap = needed; + break; + } + next_cap *= 2U; + } + wg_dump_session_t *grown = (wg_dump_session_t *)realloc( + s_wifi_dump_sessions, next_cap * sizeof(wg_dump_session_t)); + if (grown == NULL) { + return false; + } + s_wifi_dump_sessions = grown; + s_wifi_dump_session_capacity = next_cap; + return true; +} + +static int storage_dump_session_cmp(const void *a, const void *b) { + const wg_dump_session_t *lhs = (const wg_dump_session_t *)a; + const wg_dump_session_t *rhs = (const wg_dump_session_t *)b; + if (lhs->session_id < rhs->session_id) { + return -1; + } + if (lhs->session_id > rhs->session_id) { + return 1; + } + return 0; +} + +static bool storage_dump_collect_sessions_locked(void) { + storage_dump_reset_sessions_locked(); + + DIR *dir = opendir(storage_base_path()); + if (dir == NULL) { + return false; + } + + struct dirent *ent = NULL; + while ((ent = readdir(dir)) != NULL) { + uint64_t sid = 0; + if (!storage_parse_meta_filename(ent->d_name, &sid)) { + continue; + } + + wg_session_manifest_t manifest = {0}; + if (!storage_read_manifest(sid, &manifest)) { + continue; + } + if (manifest.state == WG_SESSION_STATE_OPEN || manifest.records_written == 0 || + manifest.records_acked >= manifest.records_written || + manifest.last_seq_written == 0) { + continue; + } + + if (!storage_dump_ensure_capacity_locked(s_wifi_dump_session_count + 1U)) { + closedir(dir); + return false; + } + + char data_path[96] = {0}; + char gpx_path[WG_STORAGE_PATH_BUF_SIZE] = {0}; + storage_session_paths(sid, NULL, 0, data_path, sizeof(data_path)); + storage_gpx_paths(sid, gpx_path, sizeof(gpx_path), NULL, 0); + struct stat st_data = {0}; + struct stat st_gpx = {0}; + uint32_t data_bytes = 0; + uint32_t gpx_bytes = 0; + if (stat(data_path, &st_data) == 0 && st_data.st_size > 0) { + if ((uint64_t)st_data.st_size > UINT32_MAX) { + data_bytes = UINT32_MAX; + } else { + data_bytes = (uint32_t)st_data.st_size; + } + } + if (stat(gpx_path, &st_gpx) == 0 && st_gpx.st_size > 0) { + if ((uint64_t)st_gpx.st_size > UINT32_MAX) { + gpx_bytes = UINT32_MAX; + } else { + gpx_bytes = (uint32_t)st_gpx.st_size; + } + } + if (data_bytes == 0) { + continue; + } + + wg_dump_session_t *slot = &s_wifi_dump_sessions[s_wifi_dump_session_count++]; + slot->session_id = sid; + slot->data_bytes = data_bytes; + slot->gpx_bytes = gpx_bytes; + slot->written_seq = manifest.last_seq_written; + slot->acked_seq = manifest.last_seq_acked; + } + closedir(dir); + + if (s_wifi_dump_session_count > 1U) { + qsort(s_wifi_dump_sessions, s_wifi_dump_session_count, sizeof(wg_dump_session_t), + storage_dump_session_cmp); + } + return true; +} + static double gpx_distance_meters_e7(int32_t lat1_e7, int32_t lon1_e7, int32_t lat2_e7, int32_t lon2_e7) { static const double k_earth_radius_m = 6371000.0; const double lat1 = ((double)lat1_e7 / 10000000.0) * (M_PI / 180.0); @@ -1614,56 +1810,38 @@ static bool storage_finalize_stale_gpx_session(uint64_t session_id) { } static bool storage_parse_meta_filename(const char *name, uint64_t *session_id_out) { - if (name == NULL || session_id_out == NULL) { - return false; - } - const size_t prefix_len = strlen(WG_STORAGE_FILE_PREFIX); - const size_t n = strlen(name); - if (n != (prefix_len + 16 + 5) || strncmp(name, WG_STORAGE_FILE_PREFIX, prefix_len) != 0 || - strcmp(name + prefix_len + 16, ".meta") != 0) { - return false; - } - char hex[17] = {0}; - memcpy(hex, name + prefix_len, 16); - errno = 0; - char *end = NULL; - unsigned long long parsed = strtoull(hex, &end, 16); - if (errno != 0 || end == NULL || *end != '\0') { - return false; - } - *session_id_out = (uint64_t)parsed; - return true; + return wg_storage_parse_meta_filename(name, WG_STORAGE_FILE_PREFIX, session_id_out); } static uint32_t storage_manifest_generation_get(const wg_session_manifest_t *manifest, bool *valid_out) { - bool valid = (manifest != NULL && manifest->reserved1[2] == WG_STORAGE_MANIFEST_GEN_MAGIC); - if (valid_out != NULL) { - *valid_out = valid; - } - if (!valid) { + if (manifest == NULL) { + if (valid_out != NULL) { + *valid_out = false; + } return 0; } - return (uint32_t)manifest->reserved0 | ((uint32_t)manifest->reserved1[0] << 16) | - ((uint32_t)manifest->reserved1[1] << 24); + return wg_storage_manifest_generation_get(manifest->reserved0, manifest->reserved1, + WG_STORAGE_MANIFEST_GEN_MAGIC, valid_out); } static void storage_manifest_generation_set(wg_session_manifest_t *manifest, uint32_t generation) { if (manifest == NULL) { return; } - manifest->reserved0 = (uint16_t)(generation & 0xFFFFU); - manifest->reserved1[0] = (uint8_t)((generation >> 16) & 0xFFU); - manifest->reserved1[1] = (uint8_t)((generation >> 24) & 0xFFU); - manifest->reserved1[2] = WG_STORAGE_MANIFEST_GEN_MAGIC; + uint16_t reserved0 = manifest->reserved0; + uint8_t reserved1[sizeof(manifest->reserved1)] = {0}; + memcpy(reserved1, manifest->reserved1, sizeof(reserved1)); + + wg_storage_manifest_generation_set(&reserved0, reserved1, WG_STORAGE_MANIFEST_GEN_MAGIC, + generation); + + manifest->reserved0 = reserved0; + memcpy(manifest->reserved1, reserved1, sizeof(reserved1)); } static int storage_manifest_generation_cmp(uint32_t lhs, uint32_t rhs) { - if (lhs == rhs) { - return 0; - } - uint32_t delta = lhs - rhs; - return (delta < 0x80000000U) ? 1 : -1; + return wg_storage_manifest_generation_cmp(lhs, rhs); } static int storage_manifest_compare_freshness(const wg_session_manifest_t *lhs, @@ -1875,6 +2053,649 @@ static bool storage_write_manifest(const wg_session_manifest_t *manifest_in) { return storage_write_manifest_path(fallback_path, &manifest); } +static bool storage_dump_journal_write_locked(bool committed) { + char journal_path[WG_STORAGE_PATH_BUF_SIZE] = {0}; + storage_dump_journal_path(journal_path, sizeof(journal_path)); + FILE *fp = fopen(journal_path, "wb"); + if (fp == NULL) { + ESP_LOGW(TAG, "wifi dump journal open failed path=%s errno=%d", journal_path, errno); + return false; + } + + const uint32_t session_count = + (s_wifi_dump_session_count > UINT32_MAX) ? UINT32_MAX : (uint32_t)s_wifi_dump_session_count; + const size_t entries_size = (size_t)session_count * sizeof(wg_dump_journal_entry_t); + uint8_t *entries_buf = NULL; + if (entries_size > 0) { + entries_buf = (uint8_t *)calloc(1, entries_size); + if (entries_buf == NULL) { + fclose(fp); + return false; + } + for (uint32_t i = 0; i < session_count; ++i) { + wg_dump_journal_entry_t *entry = (wg_dump_journal_entry_t *)(entries_buf + (i * sizeof(*entry))); + entry->session_id = s_wifi_dump_sessions[i].session_id; + entry->ack_seq = s_wifi_dump_sessions[i].written_seq; + } + } + + wg_dump_journal_header_t header = { + .magic = WG_WIFI_DUMP_JOURNAL_MAGIC, + .version = WG_WIFI_DUMP_JOURNAL_VERSION, + .header_size = sizeof(wg_dump_journal_header_t), + .run_id = s_wifi_dump_run_id, + .committed = committed ? 1 : 0, + .reserved = {0}, + .session_count = session_count, + .crc32 = entries_size > 0 ? crc32_ieee(entries_buf, entries_size) : 0, + }; + + bool ok = (fwrite(&header, 1, sizeof(header), fp) == sizeof(header)); + if (ok && entries_size > 0) { + ok = (fwrite(entries_buf, 1, entries_size, fp) == entries_size); + } + if (entries_buf != NULL) { + free(entries_buf); + } + if (ok) { + ok = storage_commit_file(fp, "wifi dump journal", s_wifi_dump_run_id); + } + fclose(fp); + if (!ok) { + ESP_LOGW(TAG, "wifi dump journal write failed run=%016llX errno=%d", + (unsigned long long)s_wifi_dump_run_id, errno); + } + return ok; +} + +static bool storage_dump_journal_load_locked(wg_dump_journal_header_t *header_out, + wg_dump_journal_entry_t **entries_out) { + if (header_out == NULL || entries_out == NULL) { + return false; + } + *entries_out = NULL; + memset(header_out, 0, sizeof(*header_out)); + + char journal_path[WG_STORAGE_PATH_BUF_SIZE] = {0}; + storage_dump_journal_path(journal_path, sizeof(journal_path)); + FILE *fp = fopen(journal_path, "rb"); + if (fp == NULL) { + return false; + } + + wg_dump_journal_header_t header = {0}; + bool ok = (fread(&header, 1, sizeof(header), fp) == sizeof(header)); + if (!ok || header.magic != WG_WIFI_DUMP_JOURNAL_MAGIC || + header.version != WG_WIFI_DUMP_JOURNAL_VERSION || + header.header_size != sizeof(wg_dump_journal_header_t)) { + fclose(fp); + return false; + } + + const size_t entries_size = (size_t)header.session_count * sizeof(wg_dump_journal_entry_t); + wg_dump_journal_entry_t *entries = NULL; + if (entries_size > 0) { + entries = (wg_dump_journal_entry_t *)calloc(1, entries_size); + if (entries == NULL) { + fclose(fp); + return false; + } + ok = (fread(entries, 1, entries_size, fp) == entries_size); + if (!ok) { + free(entries); + fclose(fp); + return false; + } + const uint32_t crc = crc32_ieee((const uint8_t *)entries, entries_size); + if (crc != header.crc32) { + free(entries); + fclose(fp); + return false; + } + } else if (header.crc32 != 0) { + fclose(fp); + return false; + } + + fclose(fp); + *header_out = header; + *entries_out = entries; + return true; +} + +static bool storage_dump_journal_delete_locked(void) { + char journal_path[WG_STORAGE_PATH_BUF_SIZE] = {0}; + storage_dump_journal_path(journal_path, sizeof(journal_path)); + if (unlink(journal_path) == 0 || errno == ENOENT) { + return true; + } + ESP_LOGW(TAG, "wifi dump journal delete failed path=%s errno=%d", journal_path, errno); + return false; +} + +static const wg_dump_session_t *storage_dump_find_session_locked(uint64_t sid) { + if (sid == 0 || s_wifi_dump_sessions == NULL) { + return NULL; + } + for (size_t i = 0; i < s_wifi_dump_session_count; ++i) { + if (s_wifi_dump_sessions[i].session_id == sid) { + return &s_wifi_dump_sessions[i]; + } + } + return NULL; +} + +static bool wifi_dump_parse_query_value(httpd_req_t *req, const char *key, char *value_out, + size_t value_size) { + if (req == NULL || key == NULL || value_out == NULL || value_size == 0) { + return false; + } + int query_len = httpd_req_get_url_query_len(req); + if (query_len <= 0 || query_len >= 192) { + return false; + } + char query[192] = {0}; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) { + return false; + } + return httpd_query_key_value(query, key, value_out, value_size) == ESP_OK; +} + +static int wifi_dump_manifest_handler(httpd_req_t *req) { + if (req == NULL) { + return ESP_FAIL; + } + if (!storage_lock(pdMS_TO_TICKS(200))) { + httpd_resp_set_status(req, "503 Service Unavailable"); + httpd_resp_sendstr(req, "storage lock failed"); + return ESP_FAIL; + } + if (!s_wifi_dump_active) { + storage_unlock(); + httpd_resp_set_status(req, "503 Service Unavailable"); + httpd_resp_sendstr(req, "wifi dump not active"); + return ESP_FAIL; + } + + uint64_t run_id = s_wifi_dump_run_id; + size_t count = s_wifi_dump_session_count; + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + + char line[256] = {0}; + snprintf(line, sizeof(line), + "{\"run_id\":\"%016llX\",\"ap_ssid\":\"%s\",\"session_count\":%lu,\"sessions\":[", + (unsigned long long)run_id, WG_WIFI_DUMP_AP_SSID, (unsigned long)count); + esp_err_t rc = httpd_resp_sendstr_chunk(req, line); + if (rc != ESP_OK) { + storage_unlock(); + return rc; + } + + for (size_t i = 0; i < count; ++i) { + const wg_dump_session_t *s = &s_wifi_dump_sessions[i]; + snprintf(line, sizeof(line), + "%s{\"sid\":\"%016llX\",\"dat_bytes\":%lu,\"gpx_bytes\":%lu,\"written_seq\":%lu,\"acked_seq\":%lu}", + (i == 0U) ? "" : ",", (unsigned long long)s->session_id, + (unsigned long)s->data_bytes, (unsigned long)s->gpx_bytes, + (unsigned long)s->written_seq, (unsigned long)s->acked_seq); + rc = httpd_resp_sendstr_chunk(req, line); + if (rc != ESP_OK) { + storage_unlock(); + return rc; + } + } + storage_unlock(); + + if (httpd_resp_sendstr_chunk(req, "]}") != ESP_OK) { + return ESP_FAIL; + } + return httpd_resp_send_chunk(req, NULL, 0); +} + +static int wifi_dump_file_handler(httpd_req_t *req) { + if (req == NULL) { + return ESP_FAIL; + } + char sid_hex[24] = {0}; + char type[8] = {0}; + if (!wifi_dump_parse_query_value(req, "sid", sid_hex, sizeof(sid_hex)) || + !wifi_dump_parse_query_value(req, "type", type, sizeof(type))) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "missing sid/type"); + return ESP_FAIL; + } + + errno = 0; + char *sid_end = NULL; + uint64_t sid = strtoull(sid_hex, &sid_end, 16); + if (errno != 0 || sid_end == sid_hex || (sid_end != NULL && *sid_end != '\0')) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "bad sid"); + return ESP_FAIL; + } + + bool want_dat = strcmp(type, "dat") == 0; + bool want_gpx = strcmp(type, "gpx") == 0; + if (!want_dat && !want_gpx) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "bad type"); + return ESP_FAIL; + } + + char path[WG_STORAGE_PATH_BUF_SIZE] = {0}; + uint32_t expected_bytes = 0; + if (!storage_lock(pdMS_TO_TICKS(200))) { + httpd_resp_set_status(req, "503 Service Unavailable"); + httpd_resp_sendstr(req, "storage lock failed"); + return ESP_FAIL; + } + if (!s_wifi_dump_active) { + storage_unlock(); + httpd_resp_set_status(req, "503 Service Unavailable"); + httpd_resp_sendstr(req, "wifi dump not active"); + return ESP_FAIL; + } + const wg_dump_session_t *session = storage_dump_find_session_locked(sid); + if (session == NULL) { + storage_unlock(); + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_sendstr(req, "unknown session"); + return ESP_FAIL; + } + if (want_dat) { + storage_session_paths(sid, NULL, 0, path, sizeof(path)); + expected_bytes = session->data_bytes; + } else { + storage_gpx_paths(sid, path, sizeof(path), NULL, 0); + expected_bytes = session->gpx_bytes; + } + storage_unlock(); + + if (expected_bytes == 0) { + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_sendstr(req, "file absent"); + return ESP_FAIL; + } + + struct stat st = {0}; + if (stat(path, &st) != 0 || st.st_size <= 0) { + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_sendstr(req, "file missing"); + return ESP_FAIL; + } + uint32_t file_size = ((uint64_t)st.st_size > UINT32_MAX) ? UINT32_MAX : (uint32_t)st.st_size; + if (file_size == 0) { + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_sendstr(req, "empty file"); + return ESP_FAIL; + } + + uint32_t start = 0; + bool partial = false; + int range_len = httpd_req_get_hdr_value_len(req, "Range"); + if (range_len > 0 && range_len < 64) { + char range[64] = {0}; + if (httpd_req_get_hdr_value_str(req, "Range", range, sizeof(range)) == ESP_OK && + strncmp(range, "bytes=", 6) == 0) { + errno = 0; + char *end = NULL; + unsigned long parsed = strtoul(range + 6, &end, 10); + if (errno == 0 && end != (range + 6)) { + if (parsed > UINT32_MAX) { + start = UINT32_MAX; + } else { + start = (uint32_t)parsed; + } + partial = true; + } + } + } + if (start >= file_size) { + httpd_resp_set_status(req, "416 Range Not Satisfiable"); + httpd_resp_sendstr(req, "range out of bounds"); + return ESP_FAIL; + } + + FILE *fp = fopen(path, "rb"); + if (fp == NULL) { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "open failed"); + return ESP_FAIL; + } + if (start > 0 && fseek(fp, (long)start, SEEK_SET) != 0) { + fclose(fp); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "seek failed"); + return ESP_FAIL; + } + + uint32_t remain = file_size - start; + char hdr[96] = {0}; + if (want_dat) { + httpd_resp_set_type(req, "application/octet-stream"); + } else { + httpd_resp_set_type(req, "application/gpx+xml"); + } + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + if (partial) { + httpd_resp_set_status(req, "206 Partial Content"); + snprintf(hdr, sizeof(hdr), "bytes %lu-%lu/%lu", (unsigned long)start, + (unsigned long)(file_size - 1U), (unsigned long)file_size); + httpd_resp_set_hdr(req, "Content-Range", hdr); + } + snprintf(hdr, sizeof(hdr), "%lu", (unsigned long)remain); + httpd_resp_set_hdr(req, "Content-Length", hdr); + + uint8_t chunk[WG_WIFI_DUMP_HTTP_CHUNK] = {0}; + while (remain > 0) { + size_t want = remain > sizeof(chunk) ? sizeof(chunk) : (size_t)remain; + size_t n = fread(chunk, 1, want, fp); + if (n == 0) { + fclose(fp); + httpd_resp_send_chunk(req, NULL, 0); + return ESP_FAIL; + } + if (httpd_resp_send_chunk(req, (const char *)chunk, n) != ESP_OK) { + fclose(fp); + return ESP_FAIL; + } + remain -= (uint32_t)n; + } + fclose(fp); + return httpd_resp_send_chunk(req, NULL, 0); +} + +static bool wifi_dump_http_start_locked(void) { + if (s_wifi_dump_httpd != NULL) { + return true; + } + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.server_port = WG_WIFI_DUMP_HTTP_PORT; + cfg.max_uri_handlers = 6; + cfg.stack_size = 6144; + cfg.lru_purge_enable = true; + cfg.max_open_sockets = 4; + + if (httpd_start(&s_wifi_dump_httpd, &cfg) != ESP_OK) { + s_wifi_dump_httpd = NULL; + return false; + } + + const httpd_uri_t manifest_uri = { + .uri = WG_WIFI_DUMP_MANIFEST_URI, + .method = HTTP_GET, + .handler = wifi_dump_manifest_handler, + .user_ctx = NULL, + }; + const httpd_uri_t file_uri = { + .uri = WG_WIFI_DUMP_FILE_URI, + .method = HTTP_GET, + .handler = wifi_dump_file_handler, + .user_ctx = NULL, + }; + if (httpd_register_uri_handler(s_wifi_dump_httpd, &manifest_uri) != ESP_OK || + httpd_register_uri_handler(s_wifi_dump_httpd, &file_uri) != ESP_OK) { + httpd_stop(s_wifi_dump_httpd); + s_wifi_dump_httpd = NULL; + return false; + } + return true; +} + +static void wifi_dump_http_stop_locked(void) { + if (s_wifi_dump_httpd != NULL) { + httpd_handle_t handle = s_wifi_dump_httpd; + s_wifi_dump_httpd = NULL; + httpd_stop(handle); + } +} + +static bool wifi_dump_ap_start_locked(void) { + if (s_wifi_dump_ap_running) { + return true; + } + wifi_config_t ap_cfg = {0}; + memcpy(ap_cfg.ap.ssid, WG_WIFI_DUMP_AP_SSID, strlen(WG_WIFI_DUMP_AP_SSID)); + ap_cfg.ap.ssid_len = strlen(WG_WIFI_DUMP_AP_SSID); + ap_cfg.ap.channel = WG_WIFI_DUMP_AP_CHANNEL; + ap_cfg.ap.authmode = WIFI_AUTH_OPEN; + ap_cfg.ap.max_connection = 2; + ap_cfg.ap.beacon_interval = 100; + + esp_err_t rc = esp_wifi_set_mode(WIFI_MODE_APSTA); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "wifi dump set mode APSTA failed rc=%s", esp_err_to_name(rc)); + return false; + } + rc = esp_wifi_set_config(WIFI_IF_AP, &ap_cfg); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "wifi dump AP config failed rc=%s", esp_err_to_name(rc)); + (void)esp_wifi_set_mode(WIFI_MODE_STA); + return false; + } + s_wifi_dump_ap_running = true; + ESP_LOGI(TAG, "wifi dump AP ready ssid=%s channel=%u", WG_WIFI_DUMP_AP_SSID, + (unsigned)WG_WIFI_DUMP_AP_CHANNEL); + return true; +} + +static void wifi_dump_ap_stop_locked(void) { + if (!s_wifi_dump_ap_running) { + return; + } + (void)esp_wifi_set_mode(WIFI_MODE_STA); + s_wifi_dump_ap_running = false; +} + +static bool storage_dump_start_locked(void) { + if (!s_storage_ready || s_scanning) { + return false; + } + + s_replay_requested = false; + storage_reset_replay_locked(); + s_blob_requested = false; + storage_reset_blob_locked(); + (void)storage_flush_pending_locked(true); + + if (s_session_open) { + storage_close_session_locked(WG_SESSION_STATE_CLOSED); + } + if (!storage_dump_collect_sessions_locked()) { + return false; + } + s_wifi_dump_run_id = ((uint64_t)(uint32_t)now_ms() << 32) ^ (uint64_t)esp_random(); + if (s_wifi_dump_run_id == 0) { + s_wifi_dump_run_id = 1; + } + if (!storage_dump_journal_write_locked(false)) { + storage_dump_reset_sessions_locked(); + s_wifi_dump_run_id = 0; + return false; + } + s_wifi_dump_requested = true; + s_wifi_dump_active = true; + s_wifi_dump_started_ms = now_ms(); + ESP_LOGI(TAG, "wifi dump start run=%016llX sessions=%lu", (unsigned long long)s_wifi_dump_run_id, + (unsigned long)s_wifi_dump_session_count); + return true; +} + +static void storage_dump_stop_locked(bool keep_journal) { + s_wifi_dump_requested = false; + s_wifi_dump_active = false; + s_wifi_dump_run_id = 0; + s_wifi_dump_started_ms = 0; + storage_dump_reset_sessions_locked(); + if (!keep_journal) { + (void)storage_dump_journal_delete_locked(); + } +} + +static bool storage_dump_commit_locked(uint64_t run_id) { + if (!s_wifi_dump_active || run_id == 0 || run_id != s_wifi_dump_run_id) { + return false; + } + return storage_dump_journal_write_locked(true); +} + +static bool storage_set_wifi_dump_enabled(bool enabled) { + bool ok = true; + if (!storage_lock(pdMS_TO_TICKS(500))) { + return false; + } + + if (enabled) { + if (s_wifi_dump_active || s_wifi_dump_requested) { + storage_unlock(); + notify_status_frame(); + return true; + } + if (!storage_dump_start_locked()) { + storage_unlock(); + return false; + } + if (!wifi_dump_ap_start_locked() || !wifi_dump_http_start_locked()) { + wifi_dump_http_stop_locked(); + wifi_dump_ap_stop_locked(); + storage_dump_stop_locked(false); + ok = false; + } + } else { + if (!s_wifi_dump_active && !s_wifi_dump_requested) { + storage_unlock(); + notify_status_frame(); + return true; + } + wifi_dump_http_stop_locked(); + wifi_dump_ap_stop_locked(); + storage_dump_stop_locked(false); + } + + storage_unlock(); + notify_status_frame(); + return ok; +} + +static bool storage_commit_wifi_dump_run(uint64_t run_id) { + if (run_id == 0) { + return false; + } + + wg_dump_journal_entry_t *entries = NULL; + uint32_t session_count = 0; + bool prepared = false; + if (!storage_lock(pdMS_TO_TICKS(500))) { + return false; + } + prepared = storage_dump_commit_locked(run_id); + if (prepared) { + session_count = (s_wifi_dump_session_count > UINT32_MAX) ? UINT32_MAX : (uint32_t)s_wifi_dump_session_count; + if (session_count > 0) { + entries = (wg_dump_journal_entry_t *)calloc(session_count, sizeof(wg_dump_journal_entry_t)); + if (entries == NULL) { + prepared = false; + } else { + for (uint32_t i = 0; i < session_count; ++i) { + entries[i].session_id = s_wifi_dump_sessions[i].session_id; + entries[i].ack_seq = s_wifi_dump_sessions[i].written_seq; + } + } + } + } + storage_unlock(); + if (!prepared) { + if (entries != NULL) { + free(entries); + } + return false; + } + + bool ack_ok = true; + for (uint32_t i = 0; i < session_count; ++i) { + if (entries[i].session_id == 0 || entries[i].ack_seq == 0) { + continue; + } + if (!storage_apply_replay_ack(entries[i].session_id, entries[i].ack_seq)) { + ack_ok = false; + ESP_LOGW(TAG, "wifi dump commit ack failed sid=%016llX seq=%lu", + (unsigned long long)entries[i].session_id, (unsigned long)entries[i].ack_seq); + } + } + if (entries != NULL) { + free(entries); + } + + if (!storage_lock(pdMS_TO_TICKS(500))) { + return false; + } + if (ack_ok) { + storage_refresh_backlog_locked(true); + (void)storage_dump_journal_delete_locked(); + } + wifi_dump_http_stop_locked(); + wifi_dump_ap_stop_locked(); + storage_dump_stop_locked(true); + storage_unlock(); + notify_status_frame(); + + if (ack_ok) { + ESP_LOGI(TAG, "wifi dump commit complete run=%016llX", (unsigned long long)run_id); + } + return ack_ok; +} + +static void storage_recover_wifi_dump_journal(void) { + wg_dump_journal_header_t header = {0}; + wg_dump_journal_entry_t *entries = NULL; + bool loaded = false; + if (!storage_lock(pdMS_TO_TICKS(300))) { + return; + } + loaded = storage_dump_journal_load_locked(&header, &entries); + if (!loaded) { + storage_unlock(); + return; + } + if (header.committed == 0) { + ESP_LOGW(TAG, "wifi dump recovery: uncommitted journal retained run=%016llX sessions=%lu", + (unsigned long long)header.run_id, (unsigned long)header.session_count); + storage_unlock(); + if (entries != NULL) { + free(entries); + } + return; + } + storage_unlock(); + + bool ok = true; + for (uint32_t i = 0; i < header.session_count; ++i) { + if (entries[i].session_id == 0 || entries[i].ack_seq == 0) { + continue; + } + if (!storage_apply_replay_ack(entries[i].session_id, entries[i].ack_seq)) { + ok = false; + } + } + if (entries != NULL) { + free(entries); + } + + if (!storage_lock(pdMS_TO_TICKS(300))) { + return; + } + if (ok) { + (void)storage_refresh_backlog_locked(true); + (void)storage_dump_journal_delete_locked(); + ESP_LOGI(TAG, "wifi dump recovery replayed committed journal run=%016llX", + (unsigned long long)header.run_id); + } else { + ESP_LOGW(TAG, "wifi dump recovery incomplete run=%016llX", (unsigned long long)header.run_id); + } + storage_unlock(); +} + static uint64_t storage_generate_session_id(void) { uint32_t unix_s = s_latest_gps.unix_time_s; if (unix_s == 0) { @@ -1890,8 +2711,6 @@ static void storage_reset_pending_block_locked(void) { s_store_block_started_ms = 0; } -static void storage_refresh_backlog_locked(bool reclaim); - static uint32_t storage_reclaim_acked_sessions_locked(void) { DIR *dir = opendir(storage_base_path()); if (dir == NULL) { @@ -2478,6 +3297,9 @@ static bool storage_replay_allowed_locked(void) { if (!s_storage_ready || !s_replay_requested || s_blob_requested) { return false; } + if (s_wifi_dump_requested || s_wifi_dump_active) { + return false; + } if (s_scanning) { return false; } @@ -2709,6 +3531,9 @@ static bool storage_blob_allowed_locked(void) { if (!s_storage_ready || !s_blob_requested) { return false; } + if (s_wifi_dump_requested || s_wifi_dump_active) { + return false; + } if (s_scanning) { return false; } @@ -3582,6 +4407,7 @@ static void init_storage(void) { s_storage_ready = true; storage_abort_stale_open_sessions(); + storage_recover_wifi_dump_journal(); storage_refresh_backlog(true); uint64_t total = 0; uint64_t used = 0; @@ -3914,7 +4740,7 @@ static void build_status_payload(wg_status_payload_t *payload) { .session_id = s_session_open ? s_session_id : s_last_session_id, .queued_records = s_queue_backlog_records, .queued_bytes = s_queue_backlog_bytes, - .replay_active = s_replay_active || s_blob_active, + .replay_active = s_replay_active || s_blob_active || s_wifi_dump_active, .replay_cursor = s_blob_active ? s_blob_bytes_acked : s_replay_cursor_seq, .queue_full = s_queue_full, .dropped_flash_full = s_dropped_flash_full, @@ -4015,65 +4841,23 @@ static void recompute_effective_gps_fix(void) { } static bool parse_nmea_latlon(const char *value, const char *hemi, int32_t *out_e7) { - if (value == NULL || hemi == NULL || out_e7 == NULL || value[0] == '\0' || hemi[0] == '\0') { - return false; - } - char *end = NULL; - double raw = strtod(value, &end); - if (end == value || raw <= 0.0) { - return false; - } - int degrees = (int)(raw / 100.0); - double minutes = raw - (double)degrees * 100.0; - double dec = (double)degrees + minutes / 60.0; - if (hemi[0] == 'S' || hemi[0] == 'W') { - dec = -dec; - } else if (hemi[0] != 'N' && hemi[0] != 'E') { - return false; - } - *out_e7 = (int32_t)llround(dec * 10000000.0); - return true; + return wg_gps_parse_nmea_latlon(value, hemi, out_e7); } static uint16_t parse_nmea_dop_centi(const char *token) { - if (token == NULL || token[0] == '\0') { - return 0; - } - char *end = NULL; - double dop = strtod(token, &end); - if (end == token || !isfinite(dop) || dop <= 0.0) { - return 0; - } - if (dop > 999.99) { - dop = 999.99; - } - return clamp_u16_u32((uint32_t)llround(dop * 100.0)); + return wg_gps_parse_nmea_dop_centi(token); } static bool nmea_sentence_has_type(const char *line, const char *type3) { - if (line == NULL || type3 == NULL) { - return false; - } - size_t len = strnlen(line, 16); - if (len < 6) { - return false; - } - return line[0] == '$' && strncmp(&line[3], type3, 3) == 0; + return wg_gps_nmea_sentence_has_type(line, type3); } static uint16_t nmea_sentence_talker_key(const char *line) { - if (line == NULL) { - return 0; - } - size_t len = strnlen(line, 8); - if (len < 3 || line[0] != '$') { - return 0; - } - return (((uint16_t)(uint8_t)line[1]) << 8) | (uint16_t)(uint8_t)line[2]; + return wg_gps_nmea_sentence_talker_key(line); } static bool talker_key_is_gn(uint16_t key) { - return key == ((((uint16_t)'G') << 8) | (uint16_t)'N'); + return wg_gps_talker_key_is_gn(key); } static void gps_update_talker_sat(gps_talker_sat_t *buckets, size_t bucket_count, uint16_t talker_key, @@ -5288,23 +6072,18 @@ static bool send_snapshot_end(void) { return sent; } -static uint16_t rd_u16_le(const uint8_t *in) { return (uint16_t)in[0] | ((uint16_t)in[1] << 8); } +static uint16_t rd_u16_le(const uint8_t *in) { return wg_transport_rd_u16_le(in); } static void wr_u16_le(uint8_t *out, uint16_t value) { - out[0] = (uint8_t)(value & 0xFF); - out[1] = (uint8_t)((value >> 8) & 0xFF); + wg_transport_wr_u16_le(out, value); } static void wr_u32_le(uint8_t *out, uint32_t value) { - out[0] = (uint8_t)(value & 0xFF); - out[1] = (uint8_t)((value >> 8) & 0xFF); - out[2] = (uint8_t)((value >> 16) & 0xFF); - out[3] = (uint8_t)((value >> 24) & 0xFF); + wg_transport_wr_u32_le(out, value); } static void wr_u64_le(uint8_t *out, uint64_t value) { - wr_u32_le(out, (uint32_t)(value & 0xFFFFFFFFULL)); - wr_u32_le(out + 4, (uint32_t)((value >> 32) & 0xFFFFFFFFULL)); + wg_transport_wr_u64_le(out, value); } static uint8_t rsn_cipher_flags_from_suite(const uint8_t *suite) { @@ -6142,6 +6921,11 @@ static esp_err_t start_scan(void) { rc = ESP_ERR_INVALID_STATE; goto out; } + if (s_wifi_dump_requested || s_wifi_dump_active) { + ESP_LOGW(TAG, "Scan start blocked: Wi-Fi dump mode is active"); + rc = ESP_ERR_INVALID_STATE; + goto out; + } if (s_scanning) { rc = ESP_OK; goto out; @@ -6339,6 +7123,9 @@ static uint8_t apply_command(const wg_command_t *cmd) { if (!s_latest_gps.valid) { return WG_ERR_BAD_ARG; } + if (s_wifi_dump_requested || s_wifi_dump_active) { + return WG_ERR_BUSY; + } wg_scan_policy_on_explicit_start(&s_auto_scan_paused); return start_scan() == ESP_OK ? WG_ACK_OK : WG_ERR_INTERNAL; case WG_CMD_STOP: @@ -6415,6 +7202,9 @@ static uint8_t apply_command(const wg_command_t *cmd) { if (cmd->replay_enable > 1) { return WG_ERR_BAD_ARG; } + if (cmd->replay_enable == 1 && (s_wifi_dump_requested || s_wifi_dump_active)) { + return WG_ERR_BUSY; + } if (cmd->replay_enable == 1 && s_scanning) { esp_err_t stop_rc = stop_scan(); if (stop_rc != ESP_OK) { @@ -6429,6 +7219,9 @@ static uint8_t apply_command(const wg_command_t *cmd) { if (cmd->backlog_blob_enable > 1) { return WG_ERR_BAD_ARG; } + if (cmd->backlog_blob_enable == 1 && (s_wifi_dump_requested || s_wifi_dump_active)) { + return WG_ERR_BUSY; + } if (cmd->backlog_blob_enable == 1 && s_scanning) { esp_err_t stop_rc = stop_scan(); if (stop_rc != ESP_OK) { @@ -6457,6 +7250,25 @@ static uint8_t apply_command(const wg_command_t *cmd) { cmd->backlog_blob_chunk_reply) ? WG_ACK_OK : WG_ERR_INTERNAL; + case WG_CMD_SET_WIFI_DUMP: + if (cmd->wifi_dump_enable > 1) { + return WG_ERR_BAD_ARG; + } + if (cmd->wifi_dump_enable == 1 && s_scanning) { + esp_err_t stop_rc = stop_scan(); + if (stop_rc != ESP_OK) { + ESP_LOGW(TAG, "set wifi dump failed: stop scan rc=%s", esp_err_to_name(stop_rc)); + return WG_ERR_INTERNAL; + } + } + return storage_set_wifi_dump_enabled(cmd->wifi_dump_enable == 1) ? WG_ACK_OK + : WG_ERR_INTERNAL; + case WG_CMD_COMMIT_WIFI_DUMP: + if (cmd->wifi_dump_run_id == 0) { + return WG_ERR_BAD_ARG; + } + return storage_commit_wifi_dump_run(cmd->wifi_dump_run_id) ? WG_ACK_OK + : WG_ERR_INTERNAL; case WG_CMD_DEBUG_SEED_STORAGE: { if (s_scanning) { return WG_ERR_BUSY; @@ -6472,7 +7284,7 @@ static uint8_t apply_command(const wg_command_t *cmd) { return WG_ACK_OK; } case WG_CMD_CLEAR_STORAGE: - if (s_scanning) { + if (s_scanning || s_wifi_dump_requested || s_wifi_dump_active) { return WG_ERR_BUSY; } if (!storage_clear_sessions()) { @@ -6604,7 +7416,11 @@ static int handle_gatt_read(uint16_t attr_handle, struct ble_gatt_access_ctxt *c } if (attr_handle == s_device_info_handle) { - return os_mbuf_append(ctxt->om, WG_DEVICE_INFO_TEXT, strlen(WG_DEVICE_INFO_TEXT)) == 0 + const esp_app_desc_t *app_desc = esp_app_get_description(); + const char *fw_ver = (app_desc != NULL && app_desc->version[0] != '\0') ? app_desc->version : "unknown"; + char info[96] = {0}; + (void)snprintf(info, sizeof(info), "%s FW %s", WG_DEVICE_NAME, fw_ver); + return os_mbuf_append(ctxt->om, info, strlen(info)) == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; } @@ -6708,48 +7524,13 @@ static void refresh_link_security_state(uint16_t conn_handle) { } static const char *ble_phy_name(uint8_t phy) { - switch (phy) { - case BLE_GAP_LE_PHY_1M: - return "1M"; - case BLE_GAP_LE_PHY_2M: - return "2M"; - case BLE_GAP_LE_PHY_CODED: - return "CODED"; - default: - return "unknown"; - } + return wg_transport_ble_phy_name(phy); } static uint16_t ble_notify_frame_payload_limit(void) { - uint16_t mtu = s_ble_att_mtu; - if (mtu < 23) { - mtu = 23; - } - if (mtu <= (WG_FRAME_HEADER_SIZE + 3)) { - return 0; - } - uint16_t limit = (uint16_t)(mtu - WG_FRAME_HEADER_SIZE - 3); - if (limit > WG_BLE_NOTIFY_FRAME_MAX_PAYLOAD_MAX) { - limit = WG_BLE_NOTIFY_FRAME_MAX_PAYLOAD_MAX; - } - // Cap payload so the entire BLE notification fits within 2 LL PDU fragments. - // Some Android BLE stacks silently fail to reassemble L2CAP PDUs spanning 3+ - // LL fragments. With DLE=251 and encryption (4-byte MIC), each fragment - // carries at most 247 bytes of L2CAP data. Two fragments = 494 bytes L2CAP - // → 490 ATT PDU → 487 notification value → 475 WG frame payload. - const uint16_t dle = WG_BLE_DATA_LEN_OCTETS; - const uint16_t mic = s_ble_encrypted ? 4 : 0; - const uint16_t l2cap_per_frag = (dle > mic) ? (uint16_t)(dle - mic) : 1; - const uint16_t safe_l2cap = (uint16_t)(l2cap_per_frag * 2U); - const uint16_t safe_att = (safe_l2cap > 4) ? (uint16_t)(safe_l2cap - 4) : 0; - const uint16_t safe_notify = (safe_att > 3) ? (uint16_t)(safe_att - 3) : 0; - const uint16_t safe_payload = (safe_notify > WG_FRAME_HEADER_SIZE) - ? (uint16_t)(safe_notify - WG_FRAME_HEADER_SIZE) - : 0; - if (limit > safe_payload) { - limit = safe_payload; - } - return limit; + return wg_transport_ble_notify_payload_limit(s_ble_att_mtu, WG_FRAME_HEADER_SIZE, + WG_BLE_NOTIFY_FRAME_MAX_PAYLOAD_MAX, + WG_BLE_DATA_LEN_OCTETS, s_ble_encrypted); } static void refresh_ble_link_metrics(uint16_t conn_handle, const char *reason_tag) { @@ -7155,6 +7936,7 @@ static void init_wifi(void) { ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_create_default_wifi_sta(); + esp_netif_create_default_wifi_ap(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); diff --git a/main/wg_gps_nmea.c b/main/wg_gps_nmea.c new file mode 100644 index 0000000..f3052c2 --- /dev/null +++ b/main/wg_gps_nmea.c @@ -0,0 +1,86 @@ +#include "wg_gps_nmea.h" + +#include +#include +#include +#include + +static uint16_t clamp_u16(uint32_t value) { + if (value > UINT16_MAX) { + return UINT16_MAX; + } + return (uint16_t)value; +} + +static size_t bounded_strlen(const char *s, size_t max_len) { + if (s == NULL) { + return 0; + } + size_t n = 0; + while (n < max_len && s[n] != '\0') { + ++n; + } + return n; +} + +bool wg_gps_parse_nmea_latlon(const char *value, const char *hemi, int32_t *out_e7) { + if (value == NULL || hemi == NULL || out_e7 == NULL || value[0] == '\0' || hemi[0] == '\0') { + return false; + } + char *end = NULL; + double raw = strtod(value, &end); + if (end == value || raw <= 0.0) { + return false; + } + int degrees = (int)(raw / 100.0); + double minutes = raw - (double)degrees * 100.0; + double dec = (double)degrees + minutes / 60.0; + if (hemi[0] == 'S' || hemi[0] == 'W') { + dec = -dec; + } else if (hemi[0] != 'N' && hemi[0] != 'E') { + return false; + } + *out_e7 = (int32_t)llround(dec * 10000000.0); + return true; +} + +uint16_t wg_gps_parse_nmea_dop_centi(const char *token) { + if (token == NULL || token[0] == '\0') { + return 0; + } + char *end = NULL; + double dop = strtod(token, &end); + if (end == token || !isfinite(dop) || dop <= 0.0) { + return 0; + } + if (dop > 999.99) { + dop = 999.99; + } + return clamp_u16((uint32_t)llround(dop * 100.0)); +} + +bool wg_gps_nmea_sentence_has_type(const char *line, const char *type3) { + if (line == NULL || type3 == NULL) { + return false; + } + size_t len = bounded_strlen(line, 16); + if (len < 6) { + return false; + } + return line[0] == '$' && strncmp(&line[3], type3, 3) == 0; +} + +uint16_t wg_gps_nmea_sentence_talker_key(const char *line) { + if (line == NULL) { + return 0; + } + size_t len = bounded_strlen(line, 8); + if (len < 3 || line[0] != '$') { + return 0; + } + return (((uint16_t)(uint8_t)line[1]) << 8) | (uint16_t)(uint8_t)line[2]; +} + +bool wg_gps_talker_key_is_gn(uint16_t key) { + return key == ((((uint16_t)'G') << 8) | (uint16_t)'N'); +} diff --git a/main/wg_gps_nmea.h b/main/wg_gps_nmea.h new file mode 100644 index 0000000..f6618aa --- /dev/null +++ b/main/wg_gps_nmea.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +bool wg_gps_parse_nmea_latlon(const char *value, const char *hemi, int32_t *out_e7); +uint16_t wg_gps_parse_nmea_dop_centi(const char *token); +bool wg_gps_nmea_sentence_has_type(const char *line, const char *type3); +uint16_t wg_gps_nmea_sentence_talker_key(const char *line); +bool wg_gps_talker_key_is_gn(uint16_t key); diff --git a/main/wg_protocol.c b/main/wg_protocol.c index 994b33d..6f521a9 100644 --- a/main/wg_protocol.c +++ b/main/wg_protocol.c @@ -113,6 +113,8 @@ wg_result_t wg_command_decode(const uint8_t *payload, size_t payload_len, wg_com out->backlog_blob_chunk_offset = 0; out->backlog_blob_chunk_len = 0; out->backlog_blob_chunk_reply = WG_BACKLOG_BLOB_CHUNK_REPLY_NAK; + out->wifi_dump_enable = 0; + out->wifi_dump_run_id = 0; switch (out->id) { case WG_CMD_START: @@ -203,6 +205,18 @@ wg_result_t wg_command_decode(const uint8_t *payload, size_t payload_len, wg_com out->backlog_blob_chunk_len = rd_u16(&payload[13]); out->backlog_blob_chunk_reply = payload[15]; return WG_OK; + case WG_CMD_SET_WIFI_DUMP: + if (payload_len != (size_t)(1 + WG_WIFI_DUMP_TOGGLE_PAYLOAD_SIZE)) { + return WG_ERR_INVALID_FRAME; + } + out->wifi_dump_enable = payload[1]; + return WG_OK; + case WG_CMD_COMMIT_WIFI_DUMP: + if (payload_len != (size_t)(1 + WG_WIFI_DUMP_COMMIT_PAYLOAD_SIZE)) { + return WG_ERR_INVALID_FRAME; + } + out->wifi_dump_run_id = rd_u64(&payload[1]); + return WG_OK; default: return WG_ERR_UNSUPPORTED; } diff --git a/main/wg_protocol.h b/main/wg_protocol.h index cef3e38..86ad367 100644 --- a/main/wg_protocol.h +++ b/main/wg_protocol.h @@ -49,6 +49,8 @@ typedef enum { WG_CMD_DEBUG_SEED_STORAGE = 0x0E, WG_CMD_SET_CHANNEL_PLAN = 0x0F, WG_CMD_BACKLOG_BLOB_CHUNK_REPLY = 0x10, + WG_CMD_SET_WIFI_DUMP = 0x11, + WG_CMD_COMMIT_WIFI_DUMP = 0x12, } wg_command_id_t; typedef enum { @@ -66,6 +68,8 @@ typedef enum { #define WG_DEBUG_SEED_STORAGE_PAYLOAD_SIZE 4 #define WG_CHANNEL_PLAN_PAYLOAD_SIZE 12 #define WG_BACKLOG_BLOB_CHUNK_REPLY_PAYLOAD_SIZE 15 +#define WG_WIFI_DUMP_TOGGLE_PAYLOAD_SIZE 1 +#define WG_WIFI_DUMP_COMMIT_PAYLOAD_SIZE 8 #define WG_BACKLOG_BLOB_CHUNK_REPLY_NAK 0 #define WG_BACKLOG_BLOB_CHUNK_REPLY_ACK 1 @@ -104,6 +108,8 @@ typedef struct { uint32_t backlog_blob_chunk_offset; uint16_t backlog_blob_chunk_len; uint8_t backlog_blob_chunk_reply; + uint8_t wifi_dump_enable; + uint64_t wifi_dump_run_id; } wg_command_t; wg_result_t wg_frame_encode(const wg_frame_t *frame, uint8_t *out, size_t out_capacity, diff --git a/main/wg_session_transport.c b/main/wg_session_transport.c new file mode 100644 index 0000000..b198cd9 --- /dev/null +++ b/main/wg_session_transport.c @@ -0,0 +1,79 @@ +#include "wg_session_transport.h" + +#include + +uint16_t wg_transport_rd_u16_le(const uint8_t *in) { + if (in == NULL) { + return 0; + } + return (uint16_t)in[0] | ((uint16_t)in[1] << 8); +} + +void wg_transport_wr_u16_le(uint8_t *out, uint16_t value) { + if (out == NULL) { + return; + } + out[0] = (uint8_t)(value & 0xFFU); + out[1] = (uint8_t)((value >> 8) & 0xFFU); +} + +void wg_transport_wr_u32_le(uint8_t *out, uint32_t value) { + if (out == NULL) { + return; + } + out[0] = (uint8_t)(value & 0xFFU); + out[1] = (uint8_t)((value >> 8) & 0xFFU); + out[2] = (uint8_t)((value >> 16) & 0xFFU); + out[3] = (uint8_t)((value >> 24) & 0xFFU); +} + +void wg_transport_wr_u64_le(uint8_t *out, uint64_t value) { + if (out == NULL) { + return; + } + wg_transport_wr_u32_le(out, (uint32_t)(value & 0xFFFFFFFFULL)); + wg_transport_wr_u32_le(out + 4, (uint32_t)((value >> 32) & 0xFFFFFFFFULL)); +} + +const char *wg_transport_ble_phy_name(uint8_t phy) { + switch (phy) { + case 1: + return "1M"; + case 2: + return "2M"; + case 3: + return "CODED"; + default: + return "unknown"; + } +} + +uint16_t wg_transport_ble_notify_payload_limit(uint16_t att_mtu, uint16_t frame_header_size, + uint16_t payload_max, uint16_t data_len_octets, + bool encrypted) { + uint16_t mtu = att_mtu; + if (mtu < 23U) { + mtu = 23U; + } + if (mtu <= (uint16_t)(frame_header_size + 3U)) { + return 0U; + } + + uint16_t limit = (uint16_t)(mtu - frame_header_size - 3U); + if (limit > payload_max) { + limit = payload_max; + } + + const uint16_t mic = encrypted ? 4U : 0U; + const uint16_t l2cap_per_frag = (data_len_octets > mic) ? (uint16_t)(data_len_octets - mic) : 1U; + const uint16_t safe_l2cap = (uint16_t)(l2cap_per_frag * 2U); + const uint16_t safe_att = (safe_l2cap > 4U) ? (uint16_t)(safe_l2cap - 4U) : 0U; + const uint16_t safe_notify = (safe_att > 3U) ? (uint16_t)(safe_att - 3U) : 0U; + const uint16_t safe_payload = + (safe_notify > frame_header_size) ? (uint16_t)(safe_notify - frame_header_size) : 0U; + if (limit > safe_payload) { + limit = safe_payload; + } + + return limit; +} diff --git a/main/wg_session_transport.h b/main/wg_session_transport.h new file mode 100644 index 0000000..ba43e22 --- /dev/null +++ b/main/wg_session_transport.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +uint16_t wg_transport_rd_u16_le(const uint8_t *in); +void wg_transport_wr_u16_le(uint8_t *out, uint16_t value); +void wg_transport_wr_u32_le(uint8_t *out, uint32_t value); +void wg_transport_wr_u64_le(uint8_t *out, uint64_t value); + +const char *wg_transport_ble_phy_name(uint8_t phy); + +uint16_t wg_transport_ble_notify_payload_limit(uint16_t att_mtu, uint16_t frame_header_size, + uint16_t payload_max, uint16_t data_len_octets, + bool encrypted); diff --git a/main/wg_storage_backlog.c b/main/wg_storage_backlog.c new file mode 100644 index 0000000..544da26 --- /dev/null +++ b/main/wg_storage_backlog.c @@ -0,0 +1,63 @@ +#include "wg_storage_backlog.h" + +#include +#include +#include +#include + +bool wg_storage_parse_meta_filename(const char *name, const char *prefix, + uint64_t *session_id_out) { + if (name == NULL || prefix == NULL || session_id_out == NULL) { + return false; + } + + const size_t prefix_len = strlen(prefix); + const size_t n = strlen(name); + if (n != (prefix_len + 16U + 5U) || strncmp(name, prefix, prefix_len) != 0 || + strcmp(name + prefix_len + 16U, ".meta") != 0) { + return false; + } + + char hex[17] = {0}; + memcpy(hex, name + prefix_len, 16U); + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(hex, &end, 16); + if (errno != 0 || end == NULL || *end != '\0') { + return false; + } + *session_id_out = (uint64_t)parsed; + return true; +} + +uint32_t wg_storage_manifest_generation_get(uint16_t reserved0, const uint8_t reserved1[3], + uint8_t generation_magic, bool *valid_out) { + bool valid = (reserved1 != NULL && reserved1[2] == generation_magic); + if (valid_out != NULL) { + *valid_out = valid; + } + if (!valid) { + return 0; + } + + return (uint32_t)reserved0 | ((uint32_t)reserved1[0] << 16) | ((uint32_t)reserved1[1] << 24); +} + +void wg_storage_manifest_generation_set(uint16_t *reserved0, uint8_t reserved1[3], + uint8_t generation_magic, uint32_t generation) { + if (reserved0 == NULL || reserved1 == NULL) { + return; + } + *reserved0 = (uint16_t)(generation & 0xFFFFU); + reserved1[0] = (uint8_t)((generation >> 16) & 0xFFU); + reserved1[1] = (uint8_t)((generation >> 24) & 0xFFU); + reserved1[2] = generation_magic; +} + +int wg_storage_manifest_generation_cmp(uint32_t lhs, uint32_t rhs) { + if (lhs == rhs) { + return 0; + } + uint32_t delta = lhs - rhs; + return (delta < 0x80000000U) ? 1 : -1; +} diff --git a/main/wg_storage_backlog.h b/main/wg_storage_backlog.h new file mode 100644 index 0000000..17151a3 --- /dev/null +++ b/main/wg_storage_backlog.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +bool wg_storage_parse_meta_filename(const char *name, const char *prefix, + uint64_t *session_id_out); + +uint32_t wg_storage_manifest_generation_get(uint16_t reserved0, const uint8_t reserved1[3], + uint8_t generation_magic, bool *valid_out); + +void wg_storage_manifest_generation_set(uint16_t *reserved0, uint8_t reserved1[3], + uint8_t generation_magic, uint32_t generation); + +int wg_storage_manifest_generation_cmp(uint32_t lhs, uint32_t rhs); diff --git a/tests/test_wg_protocol.c b/tests/test_wg_protocol.c index b408075..255721e 100644 --- a/tests/test_wg_protocol.c +++ b/tests/test_wg_protocol.c @@ -233,6 +233,34 @@ static void test_backlog_blob_chunk_reply_command_decode(void) { "backlog blob chunk reply code should decode"); } +static void test_set_wifi_dump_command_decode(void) { + uint8_t payload[] = { + WG_CMD_SET_WIFI_DUMP, + 0x01, + }; + + wg_command_t cmd = {0}; + assert_true(wg_command_decode(payload, sizeof(payload), &cmd) == WG_OK, + "set wifi dump command decode should succeed"); + assert_true(cmd.id == WG_CMD_SET_WIFI_DUMP, "set wifi dump command id should match"); + assert_true(cmd.wifi_dump_enable == 1, "set wifi dump enable should decode"); +} + +static void test_commit_wifi_dump_command_decode(void) { + uint8_t payload[] = { + WG_CMD_COMMIT_WIFI_DUMP, + 0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, // run id (little-endian) + }; + + wg_command_t cmd = {0}; + assert_true(wg_command_decode(payload, sizeof(payload), &cmd) == WG_OK, + "commit wifi dump command decode should succeed"); + assert_true(cmd.id == WG_CMD_COMMIT_WIFI_DUMP, + "commit wifi dump command id should match"); + assert_true(cmd.wifi_dump_run_id == 0x0123456789ABCDEFULL, + "commit wifi dump run id should decode"); +} + int main(void) { test_frame_roundtrip(); test_command_decode(); @@ -246,6 +274,8 @@ int main(void) { test_set_backlog_blob_command_decode(); test_debug_seed_storage_command_decode(); test_backlog_blob_chunk_reply_command_decode(); + test_set_wifi_dump_command_decode(); + test_commit_wifi_dump_command_decode(); printf("PASS: %d tests\n", tests_run); return 0; }