refactor: migrate from Hilt to Koin and expand KMP common modules (#4746)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-09 20:19:46 -05:00 committed by GitHub
parent a5390a80e7
commit 875cf1cff2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
440 changed files with 3738 additions and 3508 deletions

View file

@ -0,0 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application>
<service android:name=".FirmwareDfuService"
android:exported="false"
android:foregroundServiceType="connectedDevice">
<intent-filter>
<action android:name="no.nordicsemi.android.dfu.broadcast.BROADCAST_ACTION" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,249 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import android.content.Context
import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.head
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
private const val DOWNLOAD_BUFFER_SIZE = 8192
/**
* Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and
* extracting specific files from Zip archives.
*/
@Single
class AndroidFirmwareFileHandler(private val context: Context, private val client: HttpClient) : FirmwareFileHandler {
private val tempDir = File(context.cacheDir, "firmware_update")
override fun cleanupAllTemporaryFiles() {
runCatching {
if (tempDir.exists()) {
tempDir.deleteRecursively()
}
tempDir.mkdirs()
}
.onFailure { e -> Logger.w(e) { "Failed to cleanup temp directory" } }
}
override suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
try {
client.head(url).status.isSuccess()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Failed to check URL existence: $url" }
false
}
}
override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): String? =
withContext(Dispatchers.IO) {
val response =
try {
client.get(url)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Download failed for $url" }
return@withContext null
}
if (!response.status.isSuccess()) {
Logger.w { "Download failed: ${response.status.value} for $url" }
return@withContext null
}
val body = response.bodyAsChannel()
val contentLength = response.contentLength() ?: -1L
if (!tempDir.exists()) tempDir.mkdirs()
val targetFile = java.io.File(tempDir, fileName)
body.toInputStream().use { input ->
java.io.FileOutputStream(targetFile).use { output ->
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead = 0L
while (input.read(buffer).also { bytesRead = it } != -1) {
if (!isActive) throw CancellationException("Download cancelled")
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
onProgress(totalBytesRead.toFloat() / contentLength)
}
}
if (contentLength != -1L && totalBytesRead != contentLength) {
throw IOException("Incomplete download: expected $contentLength bytes, got $totalBytesRead")
}
}
}
targetFile.absolutePath
}
override suspend fun extractFirmwareFromZip(
zipFilePath: String,
hardware: DeviceHardware,
fileExtension: String,
preferredFilename: String?,
): String? = withContext(Dispatchers.IO) {
val zipFile = java.io.File(zipFilePath)
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
if (target.isEmpty() && preferredFilename == null) return@withContext null
val targetLowerCase = target.lowercase()
val preferredFilenameLower = preferredFilename?.lowercase()
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
if (!tempDir.exists()) tempDir.mkdirs()
ZipInputStream(zipFile.inputStream()).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val name = entry.name.lowercase()
val entryFileName = File(name).name
val isMatch =
if (preferredFilenameLower != null) {
entryFileName == preferredFilenameLower
} else {
!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)
}
if (isMatch) {
val outFile = File(tempDir, entryFileName)
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
matchingEntries.add(entry to outFile)
if (preferredFilenameLower != null) {
return@withContext outFile.absolutePath
}
}
entry = zipInput.nextEntry
}
}
matchingEntries.minByOrNull { it.first.name.length }?.second?.absolutePath
}
override suspend fun extractFirmware(
uri: CommonUri,
hardware: DeviceHardware,
fileExtension: String,
preferredFilename: String?,
): String? = withContext(Dispatchers.IO) {
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
if (target.isEmpty() && preferredFilename == null) return@withContext null
val targetLowerCase = target.lowercase()
val preferredFilenameLower = preferredFilename?.lowercase()
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
if (!tempDir.exists()) tempDir.mkdirs()
try {
val platformUri = uri.toPlatformUri() as android.net.Uri
val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null
ZipInputStream(inputStream).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val name = entry.name.lowercase()
val entryFileName = File(name).name
val isMatch =
if (preferredFilenameLower != null) {
entryFileName == preferredFilenameLower
} else {
!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)
}
if (isMatch) {
val outFile = File(tempDir, entryFileName)
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
matchingEntries.add(entry to outFile)
if (preferredFilenameLower != null) {
return@withContext outFile.absolutePath
}
}
entry = zipInput.nextEntry
}
}
} catch (e: IOException) {
Logger.w(e) { "Failed to extract firmware from URI" }
return@withContext null
}
matchingEntries.minByOrNull { it.first.name.length }?.second?.absolutePath
}
override suspend fun getFileSize(path: String): Long = withContext(Dispatchers.IO) {
val file = File(path)
if (file.exists()) file.length() else 0L
}
override suspend fun deleteFile(path: String) = withContext(Dispatchers.IO) {
val file = File(path)
if (file.exists()) file.delete()
}
private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_\\.].*")
return filename.endsWith(fileExtension) &&
filename.contains(target) &&
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
}
override suspend fun copyFileToUri(sourcePath: String, destinationUri: CommonUri): Long =
withContext(Dispatchers.IO) {
val inputStream = java.io.FileInputStream(java.io.File(sourcePath))
val outputStream =
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
?: throw IOException("Cannot open content URI for writing")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
override suspend fun copyUriToUri(sourceUri: CommonUri, destinationUri: CommonUri): Long =
withContext(Dispatchers.IO) {
val inputStream =
context.contentResolver.openInputStream(sourceUri.toPlatformUri() as android.net.Uri)
?: throw IOException("Cannot open source URI")
val outputStream =
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
?: throw IOException("Cannot open destination URI")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import kotlinx.coroutines.flow.Flow
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler
/** Orchestrates the firmware update process by choosing the correct handler. */
@Single
class AndroidFirmwareUpdateManager(
private val radioPrefs: RadioPrefs,
private val nordicDfuHandler: NordicDfuHandler,
private val usbUpdateHandler: UsbUpdateHandler,
private val esp32OtaUpdateHandler: Esp32OtaUpdateHandler,
) : FirmwareUpdateManager {
/** Start the update process based on the current connection and hardware. */
override suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri?,
): String? {
val handler = getHandler(hardware)
val target = getTarget(address)
return handler.startUpdate(
release = release,
hardware = hardware,
target = target,
updateState = updateState,
firmwareUri = firmwareUri,
)
}
override fun dfuProgressFlow(): Flow<DfuInternalState> = nordicDfuHandler.progressFlow()
private fun getHandler(hardware: DeviceHardware): FirmwareUpdateHandler = when {
radioPrefs.isSerial() -> {
if (isEsp32Architecture(hardware.architecture)) {
error("Serial/USB firmware update not supported for ESP32 devices from the app")
}
usbUpdateHandler
}
radioPrefs.isBle() -> {
if (isEsp32Architecture(hardware.architecture)) {
esp32OtaUpdateHandler
} else {
nordicDfuHandler
}
}
radioPrefs.isTcp() -> {
if (isEsp32Architecture(hardware.architecture)) {
esp32OtaUpdateHandler
} else {
// Should be handled/validated before calling startUpdate
error("WiFi OTA only supported for ESP32 devices")
}
}
else -> error("Unknown connection type for firmware update")
}
private fun getTarget(address: String): String = when {
radioPrefs.isSerial() -> ""
radioPrefs.isBle() -> address
radioPrefs.isTcp() -> extractIpFromAddress(radioPrefs.devAddr.value) ?: ""
else -> ""
}
private fun isEsp32Architecture(architecture: String): Boolean = architecture.startsWith("esp32", ignoreCase = true)
private fun extractIpFromAddress(address: String?): String? =
if (address != null && address.startsWith("t") && address.length > 1) {
address.substring(1)
} else {
null
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbManager
import android.os.Build
import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.koin.core.annotation.Single
/** Manages USB-related interactions for firmware updates. */
@Single
class AndroidFirmwareUsbManager(private val context: Context) : FirmwareUsbManager {
/** Observe when a USB device is detached. */
override fun deviceDetachFlow(): Flow<Unit> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
trySend(Unit).isSuccess
close()
}
}
}
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
awaitClose {
runCatching { context.unregisterReceiver(receiver) }
.onFailure { Logger.w(it) { "Failed to unregister USB receiver" } }
}
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import kotlinx.coroutines.runBlocking
import no.nordicsemi.android.dfu.DfuBaseService
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_channel_description
import org.meshtastic.core.resources.firmware_update_channel_name
import org.meshtastic.core.model.util.isDebug as isDebugFlag
class FirmwareDfuService : DfuBaseService() {
override fun onCreate() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Using runBlocking here is acceptable as onCreate is a lifecycle method
// and we need localized strings for the notification channel.
val (channelName, channelDesc) =
runBlocking {
getString(Res.string.firmware_update_channel_name) to
getString(Res.string.firmware_update_channel_description)
}
val channel =
NotificationChannel(NOTIFICATION_CHANNEL_DFU, channelName, NotificationManager.IMPORTANCE_LOW).apply {
description = channelDesc
setShowBadge(false)
}
manager.createNotificationChannel(channel)
super.onCreate()
}
override fun getNotificationTarget(): Class<out Activity>? = try {
// Best effort to find the main activity dynamically
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: "org.meshtastic.app.MainActivity"
@Suppress("UNCHECKED_CAST")
Class.forName(className) as Class<out Activity>
} catch (_: Exception) {
Activity::class.java
}
override fun isDebug(): Boolean = isDebugFlag
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
/** Retrieves firmware files, either by direct download or by extracting from a release asset. */
@Single
class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
suspend fun retrieveOtaFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): String? = retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
fileSuffix = "-ota.zip",
internalFileExtension = ".zip",
)
suspend fun retrieveUsbFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): String? = retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
fileSuffix = ".uf2",
internalFileExtension = ".uf2",
)
suspend fun retrieveEsp32Firmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): String? {
val mcu = hardware.architecture.replace("-", "")
val otaFilename = "mt-$mcu-ota.bin"
retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
fileSuffix = ".bin",
internalFileExtension = ".bin",
preferredFilename = otaFilename,
)
?.let {
return it
}
// Fallback to board-specific binary using the now-accurate platformioTarget.
return retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
fileSuffix = ".bin",
internalFileExtension = ".bin",
)
}
private suspend fun retrieve(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
fileSuffix: String,
internalFileExtension: String,
preferredFilename: String? = null,
): String? {
val version = release.id.removePrefix("v")
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
val filename = preferredFilename ?: "firmware-$target-$version$fileSuffix"
val directUrl =
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-$version/$filename"
if (fileHandler.checkUrlExists(directUrl)) {
try {
fileHandler.downloadFile(directUrl, filename, onProgress)?.let {
return it
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Direct download for $filename failed, falling back to release zip" }
}
}
// Fallback to downloading the full release zip and extracting
val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture)
val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress)
return downloadedZip?.let {
fileHandler.extractFirmwareFromZip(it, hardware, internalFileExtension, preferredFilename)
}
}
private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32")
for (arch in knownArchs) {
if (url.contains(arch, ignoreCase = true)) {
return url.replace(arch, targetArch.lowercase(), ignoreCase = true)
}
}
return url
}
}

View file

@ -0,0 +1,894 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.meshtastic.feature.firmware
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.mikepenz.markdown.m3.Markdown
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.chirpy
import org.meshtastic.core.resources.dont_show_again_for_device
import org.meshtastic.core.resources.firmware_update_almost_there
import org.meshtastic.core.resources.firmware_update_alpha
import org.meshtastic.core.resources.firmware_update_checking
import org.meshtastic.core.resources.firmware_update_currently_installed
import org.meshtastic.core.resources.firmware_update_device
import org.meshtastic.core.resources.firmware_update_disclaimer_chirpy_says
import org.meshtastic.core.resources.firmware_update_disclaimer_text
import org.meshtastic.core.resources.firmware_update_disclaimer_title
import org.meshtastic.core.resources.firmware_update_disconnect_warning
import org.meshtastic.core.resources.firmware_update_do_not_close
import org.meshtastic.core.resources.firmware_update_done
import org.meshtastic.core.resources.firmware_update_error
import org.meshtastic.core.resources.firmware_update_hang_tight
import org.meshtastic.core.resources.firmware_update_keep_device_close
import org.meshtastic.core.resources.firmware_update_latest
import org.meshtastic.core.resources.firmware_update_local_file
import org.meshtastic.core.resources.firmware_update_method_detail
import org.meshtastic.core.resources.firmware_update_rak4631_bootloader_hint
import org.meshtastic.core.resources.firmware_update_release_notes
import org.meshtastic.core.resources.firmware_update_retry
import org.meshtastic.core.resources.firmware_update_save_dfu_file
import org.meshtastic.core.resources.firmware_update_select_file
import org.meshtastic.core.resources.firmware_update_source_local
import org.meshtastic.core.resources.firmware_update_stable
import org.meshtastic.core.resources.firmware_update_success
import org.meshtastic.core.resources.firmware_update_taking_a_while
import org.meshtastic.core.resources.firmware_update_target
import org.meshtastic.core.resources.firmware_update_title
import org.meshtastic.core.resources.firmware_update_unknown_release
import org.meshtastic.core.resources.firmware_update_usb_bootloader_warning
import org.meshtastic.core.resources.firmware_update_usb_instruction_text
import org.meshtastic.core.resources.firmware_update_usb_instruction_title
import org.meshtastic.core.resources.firmware_update_verification_failed
import org.meshtastic.core.resources.firmware_update_verifying
import org.meshtastic.core.resources.firmware_update_waiting_reconnect
import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.img_chirpy
import org.meshtastic.core.resources.learn_more
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.CheckCircle
import org.meshtastic.core.ui.icon.CloudDownload
import org.meshtastic.core.ui.icon.Dangerous
import org.meshtastic.core.ui.icon.Folder
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.SystemUpdate
import org.meshtastic.core.ui.icon.Usb
import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.icon.Wifi
private const val CYCLE_DELAY_MS = 4500L
@Composable
@Suppress("LongMethod")
fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateViewModel, modifier: Modifier = Modifier) {
val state by viewModel.state.collectAsStateWithLifecycle()
val selectedReleaseType by viewModel.selectedReleaseType.collectAsStateWithLifecycle()
val deviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle()
val currentVersion by viewModel.currentFirmwareVersion.collectAsStateWithLifecycle()
val selectedRelease by viewModel.selectedRelease.collectAsStateWithLifecycle()
var showExitConfirmation by remember { mutableStateOf(false) }
val filePickerLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let { viewModel.startUpdateFromFile(CommonUri(it)) }
}
val createDocumentLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri: Uri? ->
uri?.let { viewModel.saveDfuFile(CommonUri(it)) }
}
val actions =
remember(viewModel, onNavigateUp, state) {
FirmwareUpdateActions(
onReleaseTypeSelect = viewModel::setReleaseType,
onStartUpdate = viewModel::startUpdate,
onPickFile = {
if (state is FirmwareUpdateState.Ready) {
val readyState = state as FirmwareUpdateState.Ready
if (
readyState.updateMethod is FirmwareUpdateMethod.Ble ||
readyState.updateMethod is FirmwareUpdateMethod.Wifi
) {
filePickerLauncher.launch("*/*")
} else if (readyState.updateMethod is FirmwareUpdateMethod.Usb) {
filePickerLauncher.launch("*/*")
}
}
},
onSaveFile = { fileName -> createDocumentLauncher.launch(fileName) },
onRetry = viewModel::checkForUpdates,
onCancel = { showExitConfirmation = true },
onDone = { onNavigateUp() },
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
)
}
KeepScreenOn(shouldKeepFirmwareScreenOn(state))
androidx.activity.compose.BackHandler(enabled = shouldKeepFirmwareScreenOn(state)) { showExitConfirmation = true }
if (showExitConfirmation) {
MeshtasticDialog(
onDismiss = { showExitConfirmation = false },
title = stringResource(Res.string.firmware_update_disclaimer_title),
message = stringResource(Res.string.firmware_update_disconnect_warning),
confirmText = stringResource(Res.string.firmware_update_retry),
onConfirm = {
showExitConfirmation = false
viewModel.cancelUpdate()
onNavigateUp()
},
dismissText = stringResource(Res.string.back),
)
}
FirmwareUpdateScaffold(
modifier = modifier,
onNavigateUp = onNavigateUp,
state = state,
selectedReleaseType = selectedReleaseType,
actions = actions,
deviceHardware = deviceHardware,
currentVersion = currentVersion,
selectedRelease = selectedRelease,
)
}
@Composable
private fun FirmwareUpdateScaffold(
onNavigateUp: () -> Unit,
state: FirmwareUpdateState,
selectedReleaseType: FirmwareReleaseType,
actions: FirmwareUpdateActions,
deviceHardware: DeviceHardware?,
currentVersion: String?,
selectedRelease: FirmwareRelease?,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
CenterAlignedTopAppBar(
title = { Text(stringResource(Res.string.firmware_update_title)) },
navigationIcon = {
IconButton(onClick = { onNavigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},
)
},
) { padding ->
Column(
modifier =
Modifier.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (deviceHardware != null) {
Spacer(Modifier.height(16.dp))
AnimatedVisibility(
visible =
state is FirmwareUpdateState.Ready ||
state is FirmwareUpdateState.Idle ||
state is FirmwareUpdateState.Checking,
) {
Column {
ReleaseTypeSelector(selectedReleaseType, actions.onReleaseTypeSelect)
Spacer(Modifier.height(16.dp))
}
}
DeviceInfoCard(
deviceHardware = deviceHardware,
release = selectedRelease,
currentFirmwareVersion = currentVersion,
selectedReleaseType = selectedReleaseType,
)
Spacer(Modifier.height(16.dp))
}
Box(contentAlignment = Alignment.TopCenter) {
FirmwareUpdateContent(state = state, selectedReleaseType = selectedReleaseType, actions = actions)
}
}
}
}
private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = when (state) {
is FirmwareUpdateState.Downloading,
is FirmwareUpdateState.Processing,
is FirmwareUpdateState.Updating,
is FirmwareUpdateState.Verifying,
-> true
else -> false
}
@Composable
private fun FirmwareUpdateContent(
state: FirmwareUpdateState,
selectedReleaseType: FirmwareReleaseType,
actions: FirmwareUpdateActions,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
content = {
when (state) {
is FirmwareUpdateState.Idle,
FirmwareUpdateState.Checking,
-> CheckingState()
is FirmwareUpdateState.Ready ->
ReadyState(state = state, selectedReleaseType = selectedReleaseType, actions = actions)
is FirmwareUpdateState.Downloading ->
ProgressContent(state.progressState, onCancel = actions.onCancel, isDownloading = true)
is FirmwareUpdateState.Processing -> ProgressContent(state.progressState, onCancel = actions.onCancel)
is FirmwareUpdateState.Updating ->
ProgressContent(state.progressState, onCancel = actions.onCancel, isUpdating = true)
is FirmwareUpdateState.Verifying -> VerifyingState()
is FirmwareUpdateState.VerificationFailed ->
VerificationFailedState(onRetry = actions.onStartUpdate, onIgnore = actions.onDone)
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = actions.onRetry)
is FirmwareUpdateState.Success -> SuccessState(onDone = actions.onDone)
is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile)
}
},
)
}
@Composable
private fun VerifyingState() {
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.firmware_update_verifying), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
stringResource(Res.string.firmware_update_waiting_reconnect),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
CyclingMessages()
}
@Composable
private fun CheckingState() {
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.firmware_update_checking), style = MaterialTheme.typography.bodyLarge)
}
@Composable
@Suppress("LongMethod")
private fun ReadyState(
state: FirmwareUpdateState.Ready,
selectedReleaseType: FirmwareReleaseType,
actions: FirmwareUpdateActions,
) {
var showDisclaimer by remember { mutableStateOf(false) }
val device = state.deviceHardware
val haptic = LocalHapticFeedback.current
if (showDisclaimer) {
DisclaimerDialog(
updateMethod = state.updateMethod,
onDismiss = { showDisclaimer = false },
onConfirm = {
showDisclaimer = false
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
actions.onPickFile()
} else {
actions.onStartUpdate()
}
},
)
}
if (state.showBootloaderWarning) {
BootloaderWarningCard(deviceHardware = device, onDismissForDevice = actions.onDismissBootloaderWarning)
Spacer(Modifier.height(16.dp))
}
Spacer(Modifier.height(16.dp))
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
Button(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showDisclaimer = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Icon(MeshtasticIcons.Folder, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.firmware_update_select_file))
}
} else if (state.release != null) {
Button(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showDisclaimer = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Icon(
imageVector =
when (state.updateMethod) {
FirmwareUpdateMethod.Ble -> MeshtasticIcons.Bluetooth
FirmwareUpdateMethod.Usb -> MeshtasticIcons.Usb
FirmwareUpdateMethod.Wifi -> MeshtasticIcons.Wifi
else -> MeshtasticIcons.SystemUpdate
},
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(
resource = Res.string.firmware_update_method_detail,
stringResource(state.updateMethod.description),
),
)
}
Spacer(Modifier.height(24.dp))
ReleaseNotesCard(state.release.releaseNotes)
}
}
@Composable
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: () -> Unit, onConfirm: () -> Unit) {
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.firmware_update_disclaimer_title),
confirmText = stringResource(Res.string.i_know_what_i_m_doing),
onConfirm = onConfirm,
dismissText = stringResource(Res.string.cancel),
text = {
Column(modifier = Modifier.animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.firmware_update_disclaimer_text))
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
MeshtasticIcons.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(Res.string.firmware_update_disconnect_warning),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
)
}
if (updateMethod is FirmwareUpdateMethod.Ble) {
Spacer(modifier = Modifier.height(12.dp))
ChirpyCard()
}
}
},
)
}
@Composable
private fun ChirpyCard() {
Card(
modifier = Modifier.fillMaxWidth().padding(4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = spacedBy(4.dp),
) {
BasicText(text = "🪜", modifier = Modifier.size(48.dp), autoSize = TextAutoSize.StepBased())
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
.data(Res.drawable.img_chirpy)
.crossfade(true)
.build(),
contentScale = ContentScale.Fit,
contentDescription = stringResource(Res.string.chirpy),
modifier = Modifier.size(48.dp),
)
}
Text(
text = stringResource(Res.string.firmware_update_disclaimer_chirpy_says),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
contentScale = ContentScale.Fit,
contentDescription = deviceHardware.displayName,
modifier = modifier,
)
}
@Composable
private fun ReleaseNotesCard(releaseNotes: String) {
ElevatedCard(
modifier = Modifier.fillMaxWidth().animateContentSize(),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(Res.string.firmware_update_release_notes),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Markdown(content = releaseNotes, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
private fun DeviceInfoCard(
deviceHardware: DeviceHardware,
release: FirmwareRelease?,
currentFirmwareVersion: String? = null,
selectedReleaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
) {
val target = deviceHardware.hwModelSlug.ifEmpty { deviceHardware.platformioTarget }
ElevatedCard(
modifier = Modifier.fillMaxWidth().animateContentSize(),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
DeviceHardwareImage(deviceHardware, Modifier.size(80.dp))
Text(
stringResource(Res.string.firmware_update_device, deviceHardware.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(Res.string.firmware_update_target, target),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
val currentVersionString =
stringResource(
Res.string.firmware_update_currently_installed,
currentFirmwareVersion?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.firmware_update_unknown_release),
)
Text(modifier = Modifier.fillMaxWidth(), text = currentVersionString)
Spacer(Modifier.height(4.dp))
val (label, version) =
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
stringResource(Res.string.firmware_update_source_local) to ""
} else {
val releaseVersion = release?.title ?: stringResource(Res.string.firmware_update_unknown_release)
stringResource(Res.string.firmware_update_latest, "") to releaseVersion
}
Text(
modifier = Modifier.fillMaxWidth(),
text = "$label$version",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) {
ElevatedCard(
modifier = Modifier.fillMaxWidth().animateContentSize(),
colors =
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.width(8.dp))
Text(
text =
stringResource(Res.string.firmware_update_usb_bootloader_warning, deviceHardware.displayName),
style = MaterialTheme.typography.bodyMedium,
)
}
val slug = deviceHardware.hwModelSlug
if (slug.equals("RAK4631", ignoreCase = true)) {
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(Res.string.firmware_update_rak4631_bootloader_hint),
style = MaterialTheme.typography.bodySmall,
)
}
val infoUrl = deviceHardware.bootloaderInfoUrl
if (!infoUrl.isNullOrEmpty()) {
Spacer(Modifier.height(8.dp))
val context = LocalContext.current
TextButton(
onClick = {
runCatching {
val intent =
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
data = infoUrl.toUri()
}
context.startActivity(intent)
}
},
) {
Text(text = stringResource(Res.string.learn_more))
}
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = onDismissForDevice) {
Text(text = stringResource(Res.string.dont_show_again_for_device))
}
}
}
}
@Composable
private fun ReleaseTypeSelector(
selectedReleaseType: FirmwareReleaseType,
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(
selected = selectedReleaseType == FirmwareReleaseType.STABLE,
onClick = { onReleaseTypeSelect(FirmwareReleaseType.STABLE) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3),
) {
Text(stringResource(Res.string.firmware_update_stable))
}
SegmentedButton(
selected = selectedReleaseType == FirmwareReleaseType.ALPHA,
onClick = { onReleaseTypeSelect(FirmwareReleaseType.ALPHA) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3),
) {
Text(stringResource(Res.string.firmware_update_alpha))
}
SegmentedButton(
selected = selectedReleaseType == FirmwareReleaseType.LOCAL,
onClick = { onReleaseTypeSelect(FirmwareReleaseType.LOCAL) },
shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3),
) {
Text(stringResource(Res.string.firmware_update_local_file))
}
}
}
@Composable
private fun ProgressContent(
progressState: ProgressState,
onCancel: () -> Unit,
isDownloading: Boolean = false,
isUpdating: Boolean = false,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
if (isDownloading) {
Icon(
MeshtasticIcons.CloudDownload,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else {
CircularWavyProgressIndicator(
progress = { if (isUpdating) progressState.progress else 1f },
modifier = Modifier.size(64.dp),
)
}
Spacer(Modifier.height(24.dp))
Text(progressState.message, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
val details = progressState.details
if (details != null) {
Spacer(Modifier.height(4.dp))
Text(
text = details,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(12.dp))
if (isDownloading || isUpdating) {
LinearWavyProgressIndicator(
progress = { progressState.progress },
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
}
Spacer(Modifier.height(16.dp))
CyclingMessages()
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) }
}
}
@Composable
private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, onSaveFile: (String) -> Unit) {
var showDialog by remember { mutableStateOf(true) }
if (showDialog) {
MeshtasticDialog(
onDismiss = { /* No-op to force user to acknowledge */ },
title = stringResource(Res.string.firmware_update_usb_instruction_title),
confirmText = stringResource(Res.string.okay),
onConfirm = {
showDialog = false
onSaveFile(state.fileName)
},
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
dismissable = false,
)
}
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.firmware_update_save_dfu_file),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
if (!showDialog) {
Spacer(Modifier.height(16.dp))
Button(onClick = { onSaveFile(state.fileName) }) { Text(stringResource(Res.string.save)) }
}
}
@Composable
private fun CyclingMessages() {
val messages =
listOf(
stringResource(Res.string.firmware_update_hang_tight),
stringResource(Res.string.firmware_update_keep_device_close),
stringResource(Res.string.firmware_update_do_not_close),
stringResource(Res.string.firmware_update_almost_there),
stringResource(Res.string.firmware_update_taking_a_while),
)
var currentMessageIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(CYCLE_DELAY_MS)
currentMessageIndex = (currentMessageIndex + 1) % messages.size
}
}
Text(
messages[currentMessageIndex],
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
@Composable
private fun VerificationFailedState(onRetry: () -> Unit, onIgnore: () -> Unit) {
Icon(
MeshtasticIcons.Warning,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.firmware_update_verification_failed),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Row(horizontalArrangement = spacedBy(16.dp)) {
OutlinedButton(onClick = onRetry) {
Icon(MeshtasticIcons.Refresh, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.firmware_update_retry))
}
Button(onClick = onIgnore) { Text(stringResource(Res.string.firmware_update_done)) }
}
}
@Composable
private fun ErrorState(error: String, onRetry: () -> Unit) {
Icon(
MeshtasticIcons.Dangerous,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.firmware_update_error, error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
OutlinedButton(onClick = onRetry) {
Icon(MeshtasticIcons.Refresh, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.firmware_update_retry))
}
}
@Composable
private fun SuccessState(onDone: () -> Unit) {
val haptic = LocalHapticFeedback.current
LaunchedEffect(Unit) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
MeshtasticIcons.CheckCircle,
contentDescription = null,
modifier = Modifier.size(100.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.firmware_update_success),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text(stringResource(Res.string.firmware_update_done))
}
}
}
@Composable
private fun KeepScreenOn(enabled: Boolean) {
val view = LocalView.current
DisposableEffect(enabled) {
if (enabled) {
view.keepScreenOn = true
}
onDispose {
if (enabled) {
view.keepScreenOn = false
}
}
}
}

View file

@ -0,0 +1,215 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import android.content.Context
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import no.nordicsemi.android.dfu.DfuBaseService
import no.nordicsemi.android.dfu.DfuLogListener
import no.nordicsemi.android.dfu.DfuProgressListenerAdapter
import no.nordicsemi.android.dfu.DfuServiceInitiator
import no.nordicsemi.android.dfu.DfuServiceListenerHelper
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_nordic_failed
import org.meshtastic.core.resources.firmware_update_not_found_in_release
import org.meshtastic.core.resources.firmware_update_starting_service
private const val SCAN_TIMEOUT = 5000L
private const val PACKETS_BEFORE_PRN = 8
private const val PERCENT_MAX = 100
private const val PREPARE_DATA_DELAY = 400L
/** Handles Over-the-Air (OTA) firmware updates for nRF52-based devices using the Nordic DFU library. */
@Single
class NordicDfuHandler(
private val firmwareRetriever: FirmwareRetriever,
private val context: Context,
private val radioController: RadioController,
) : FirmwareUpdateHandler {
override suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
target: String, // Bluetooth address
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri?,
): String? =
try {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0)
.replace(Regex(":?\\s*%1\\\$d%?"), "")
.trim()
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
if (firmwareUri != null) {
initiateDfu(target, hardware, firmwareUri, updateState)
null
} else {
val firmwareFile =
firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
),
)
}
if (firmwareFile == null) {
val errorMsg = getString(Res.string.firmware_update_not_found_in_release, hardware.displayName)
updateState(FirmwareUpdateState.Error(errorMsg))
null
} else {
initiateDfu(target, hardware, CommonUri.parse("file://$firmwareFile"), updateState)
firmwareFile
}
}
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Nordic DFU Update failed" }
val errorMsg = getString(Res.string.firmware_update_nordic_failed)
updateState(FirmwareUpdateState.Error(e.message ?: errorMsg))
null
}
private suspend fun initiateDfu(
address: String,
deviceHardware: DeviceHardware,
firmwareUri: CommonUri,
updateState: (FirmwareUpdateState) -> Unit,
) {
val startingMsg = getString(Res.string.firmware_update_starting_service)
updateState(FirmwareUpdateState.Processing(ProgressState(startingMsg)))
// n = Nordic (Legacy prefix handling in mesh service)
radioController.setDeviceAddress("n")
DfuServiceInitiator(address)
.setDeviceName(deviceHardware.displayName)
.setPrepareDataObjectDelay(PREPARE_DATA_DELAY)
.setForceScanningForNewAddressInLegacyDfu(true)
.setRestoreBond(true)
.setForeground(true)
.setKeepBond(true)
.setForceDfu(false)
.setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN)
.setPacketsReceiptNotificationsEnabled(true)
.setScanTimeout(SCAN_TIMEOUT)
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
.setZip(firmwareUri.toPlatformUri() as android.net.Uri)
.start(context, FirmwareDfuService::class.java)
}
/** Observe DFU progress and events. */
fun progressFlow(): Flow<DfuInternalState> = callbackFlow {
val listener =
object : DfuProgressListenerAdapter() {
override fun onDeviceConnecting(deviceAddress: String) {
trySend(DfuInternalState.Connecting(deviceAddress))
}
override fun onDeviceConnected(deviceAddress: String) {
trySend(DfuInternalState.Connected(deviceAddress))
}
override fun onDfuProcessStarting(deviceAddress: String) {
trySend(DfuInternalState.Starting(deviceAddress))
}
override fun onEnablingDfuMode(deviceAddress: String) {
trySend(DfuInternalState.EnablingDfuMode(deviceAddress))
}
override fun onProgressChanged(
deviceAddress: String,
percent: Int,
speed: Float,
avgSpeed: Float,
currentPart: Int,
partsTotal: Int,
) {
trySend(DfuInternalState.Progress(deviceAddress, percent, speed, avgSpeed, currentPart, partsTotal))
}
override fun onFirmwareValidating(deviceAddress: String) {
trySend(DfuInternalState.Validating(deviceAddress))
}
override fun onDeviceDisconnecting(deviceAddress: String) {
trySend(DfuInternalState.Disconnecting(deviceAddress))
}
override fun onDeviceDisconnected(deviceAddress: String) {
trySend(DfuInternalState.Disconnected(deviceAddress))
}
override fun onDfuCompleted(deviceAddress: String) {
trySend(DfuInternalState.Completed(deviceAddress))
}
override fun onDfuAborted(deviceAddress: String) {
trySend(DfuInternalState.Aborted(deviceAddress))
}
override fun onError(deviceAddress: String, error: Int, errorType: Int, message: String?) {
trySend(DfuInternalState.Error(deviceAddress, message))
}
}
val logListener =
object : DfuLogListener {
override fun onLogEvent(deviceAddress: String, level: Int, message: String) {
val severity =
when (level) {
DfuBaseService.LOG_LEVEL_DEBUG -> Severity.Debug
DfuBaseService.LOG_LEVEL_INFO -> Severity.Info
DfuBaseService.LOG_LEVEL_APPLICATION -> Severity.Info
DfuBaseService.LOG_LEVEL_WARNING -> Severity.Warn
DfuBaseService.LOG_LEVEL_ERROR -> Severity.Error
else -> Severity.Verbose
}
Logger.log(severity, tag = "NordicDFU", null, "[$deviceAddress] $message")
}
}
DfuServiceListenerHelper.registerProgressListener(context, listener)
DfuServiceListenerHelper.registerLogListener(context, logListener)
awaitClose {
runCatching {
DfuServiceListenerHelper.unregisterProgressListener(context, listener)
DfuServiceListenerHelper.unregisterLogListener(context, logListener)
}
.onFailure { Logger.w(it) { "Failed to unregister DFU listeners" } }
}
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_rebooting
import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_usb_failed
private const val REBOOT_DELAY = 5000L
private const val PERCENT_MAX = 100
/** Handles firmware updates via USB Mass Storage (UF2). */
@Single
class UsbUpdateHandler(
private val firmwareRetriever: FirmwareRetriever,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
) : FirmwareUpdateHandler {
override suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
target: String, // Unused for USB
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri?,
): String? =
try {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0)
.replace(Regex(":?\\s*%1\\\$d%?"), "")
.trim()
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
val rebootingMsg = getString(Res.string.firmware_update_rebooting)
if (firmwareUri != null) {
updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg)))
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0
radioController.rebootToDfu(myNodeNum)
delay(REBOOT_DELAY)
updateState(FirmwareUpdateState.AwaitingFileSave(null, "firmware.uf2", firmwareUri))
null
} else {
val firmwareFile =
firmwareRetriever.retrieveUsbFirmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
),
)
}
if (firmwareFile == null) {
val retrievalFailedMsg = getString(Res.string.firmware_update_retrieval_failed)
updateState(FirmwareUpdateState.Error(retrievalFailedMsg))
null
} else {
updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg)))
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0
radioController.rebootToDfu(myNodeNum)
delay(REBOOT_DELAY)
updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, java.io.File(firmwareFile).name))
firmwareFile
}
}
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "USB Update failed" }
val usbFailedMsg = getString(Res.string.firmware_update_usb_failed)
updateState(FirmwareUpdateState.Error(e.message ?: usbFailedMsg))
null
}
}

View file

@ -0,0 +1,366 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.ota
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import org.meshtastic.core.ble.AndroidBleService
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
import kotlin.time.Duration.Companion.seconds
/**
* BLE transport implementation for ESP32 Unified OTA protocol.
*
* Service UUID: 4FAFC201-1FB5-459E-8FCC-C5C9C331914B
* - OTA Characteristic (Write): 62ec0272-3ec5-11eb-b378-0242ac130005
* - TX Characteristic (Notify): 62ec0272-3ec5-11eb-b378-0242ac130003
*/
class BleOtaTransport(
private val scanner: BleScanner,
connectionFactory: BleConnectionFactory,
private val address: String,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : UnifiedOtaProtocol {
private val transportScope = CoroutineScope(SupervisorJob() + dispatcher)
private val bleConnection = connectionFactory.create(transportScope, "BLE OTA")
private var otaCharacteristic: RemoteCharacteristic? = null
private val responseChannel = Channel<String>(Channel.UNLIMITED)
private var isConnected = false
/** Scan for the device by MAC address with retries. After reboot, the device needs time to come up in OTA mode. */
private suspend fun scanForOtaDevice(): BleDevice? {
// ESP32 OTA bootloader may use MAC address with last byte incremented by 1
val otaAddress = calculateOtaAddress(macAddress = address)
val targetAddresses = setOf(address, otaAddress)
Logger.i { "BLE OTA: Will match addresses: $targetAddresses" }
repeat(SCAN_RETRY_COUNT) { attempt ->
Logger.i { "BLE OTA: Scanning for device (attempt ${attempt + 1}/$SCAN_RETRY_COUNT)..." }
val foundDevices = mutableSetOf<String>()
val device =
scanner
.scan(SCAN_TIMEOUT)
.onEach { d ->
if (foundDevices.add(d.address)) {
Logger.d { "BLE OTA: Scan found device: ${d.address} (name=${d.name})" }
}
}
.firstOrNull { it.address in targetAddresses }
if (device != null) {
Logger.i { "BLE OTA: Found target device at ${device.address}" }
return device
}
Logger.w { "BLE OTA: Target addresses $targetAddresses not in ${foundDevices.size} devices found" }
if (attempt < SCAN_RETRY_COUNT - 1) {
Logger.i { "BLE OTA: Device not found, waiting ${SCAN_RETRY_DELAY_MS}ms before retry..." }
delay(SCAN_RETRY_DELAY_MS)
}
}
return null
}
/**
* Calculate the potential OTA MAC address by incrementing the last byte. Some ESP32 bootloaders use MAC+1 for OTA
* mode to distinguish from normal operation.
*/
@Suppress("MagicNumber", "ReturnCount")
private fun calculateOtaAddress(macAddress: String): String {
val parts = macAddress.split(":")
if (parts.size != 6) return macAddress
val lastByte = parts[5].toIntOrNull(16) ?: return macAddress
val incrementedByte = ((lastByte + 1) and 0xFF).toString(16).uppercase().padStart(2, '0')
return parts.take(5).joinToString(":") + ":" + incrementedByte
}
/** Connect to the device and discover OTA service. */
@Suppress("LongMethod")
override suspend fun connect(): Result<Unit> = runCatching {
Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." }
delay(REBOOT_DELAY_MS)
Logger.i { "BLE OTA: Connecting to $address using Nordic BLE Library..." }
val device =
scanForOtaDevice()
?: throw OtaProtocolException.ConnectionFailed(
"Device not found at address $address. " +
"Ensure the device has rebooted into OTA mode and is advertising.",
)
bleConnection.connectionState
.onEach { state ->
Logger.d { "BLE OTA: Connection state changed to $state" }
isConnected = state is BleConnectionState.Connected
}
.launchIn(transportScope)
try {
val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (finalState is BleConnectionState.Disconnected) {
Logger.w { "BLE OTA: Failed to connect to ${device.address} (state=$finalState)" }
throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${device.address}")
}
} catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) {
Logger.w { "BLE OTA: Timed out waiting to connect to ${device.address}. Error: ${e.message}" }
throw OtaProtocolException.Timeout("Timed out connecting to device at address ${device.address}")
}
Logger.i { "BLE OTA: Connected to ${device.address}, discovering services..." }
// Discover services using our unified profile helper
bleConnection.profile(OTA_SERVICE_UUID) { service ->
val androidService = (service as AndroidBleService).service
val ota =
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_WRITE_CHARACTERISTIC }) {
"OTA characteristic not found"
}
val txChar =
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_NOTIFY_CHARACTERISTIC }) {
"TX characteristic not found"
}
otaCharacteristic = ota
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "BLE OTA: Service ready. Max write value length: $maxLen bytes" }
// Enable notifications and collect responses
val subscribed = CompletableDeferred<Unit>()
txChar
.subscribe {
Logger.d { "BLE OTA: TX characteristic subscribed" }
subscribed.complete(Unit)
}
.onEach { notifyBytes ->
try {
val response = notifyBytes.decodeToString()
Logger.d { "BLE OTA: Received response: $response" }
responseChannel.trySend(response)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "BLE OTA: Failed to decode response bytes" }
}
}
.catch { e ->
if (!subscribed.isCompleted) subscribed.completeExceptionally(e)
Logger.e(e) { "BLE OTA: Error in TX characteristic subscription" }
}
.launchIn(this)
subscribed.await()
Logger.i { "BLE OTA: Service discovered and ready" }
}
}
/** Initiates the OTA update by sending the size and hash. */
override suspend fun startOta(
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
): Result<Unit> = runCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
val packetsSent = sendCommand(command)
var handshakeComplete = false
var responsesReceived = 0
while (!handshakeComplete) {
val response = waitForResponse(ERASING_TIMEOUT_MS)
responsesReceived++
when (val parsed = OtaResponse.parse(response)) {
is OtaResponse.Ok -> {
if (responsesReceived >= packetsSent) {
handshakeComplete = true
}
}
is OtaResponse.Erasing -> {
Logger.i { "BLE OTA: Device erasing flash..." }
onHandshakeStatus(OtaHandshakeStatus.Erasing)
}
is OtaResponse.Error -> {
if (parsed.message.contains("Hash Rejected", ignoreCase = true)) {
throw OtaProtocolException.HashRejected(sha256Hash)
}
throw OtaProtocolException.CommandFailed(command, parsed)
}
else -> {
Logger.w { "BLE OTA: Unexpected handshake response: $response" }
}
}
}
}
/** Streams the firmware data in chunks. */
override suspend fun streamFirmware(
data: ByteArray,
chunkSize: Int,
onProgress: suspend (Float) -> Unit,
): Result<Unit> = runCatching {
val totalBytes = data.size
var sentBytes = 0
while (sentBytes < totalBytes) {
if (!isConnected) {
throw OtaProtocolException.TransferFailed("Connection lost during transfer")
}
val remainingBytes = totalBytes - sentBytes
val currentChunkSize = minOf(chunkSize, remainingBytes)
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
// Write chunk
val packetsSentForChunk = writeData(chunk, BleWriteType.WITHOUT_RESPONSE)
// Wait for responses
val nextSentBytes = sentBytes + currentChunkSize
repeat(packetsSentForChunk) { i ->
val response = waitForResponse(ACK_TIMEOUT_MS)
val isLastPacketOfChunk = i == packetsSentForChunk - 1
when (val parsed = OtaResponse.parse(response)) {
is OtaResponse.Ack -> {
// Normal packet success
}
is OtaResponse.Ok -> {
if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
sentBytes = nextSentBytes
onProgress(1.0f)
return@runCatching Unit
}
}
is OtaResponse.Error -> {
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
}
throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
}
else -> throw OtaProtocolException.TransferFailed("Unexpected response: $response")
}
}
sentBytes = nextSentBytes
onProgress(sentBytes.toFloat() / totalBytes)
}
val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS)
when (val parsed = OtaResponse.parse(finalResponse)) {
is OtaResponse.Ok -> Unit
is OtaResponse.Error -> {
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
}
throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}")
}
else -> throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $parsed")
}
}
override suspend fun close() {
bleConnection.disconnect()
isConnected = false
transportScope.cancel()
}
private suspend fun sendCommand(command: OtaCommand): Int {
val data = command.toString().toByteArray()
return writeData(data, BleWriteType.WITH_RESPONSE)
}
private suspend fun writeData(data: ByteArray, writeType: BleWriteType): Int {
val characteristic =
otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available")
val maxLen = bleConnection.maximumWriteValueLength(writeType) ?: data.size
var offset = 0
var packetsSent = 0
try {
while (offset < data.size) {
val chunkSize = minOf(data.size - offset, maxLen)
val packet = data.copyOfRange(offset, offset + chunkSize)
val nordicWriteType =
when (writeType) {
BleWriteType.WITH_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITH_RESPONSE
BleWriteType.WITHOUT_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITHOUT_RESPONSE
}
characteristic.write(packet, writeType = nordicWriteType)
offset += chunkSize
packetsSent++
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
throw OtaProtocolException.TransferFailed("Failed to write data at offset $offset", e)
}
return packetsSent
}
private suspend fun waitForResponse(timeoutMs: Long): String = try {
withTimeout(timeoutMs) { responseChannel.receive() }
} catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) {
throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms")
}
companion object {
// Timeouts and retries
private val SCAN_TIMEOUT = 10.seconds
private const val CONNECTION_TIMEOUT_MS = 15_000L
private const val ERASING_TIMEOUT_MS = 60_000L
private const val ACK_TIMEOUT_MS = 10_000L
private const val VERIFICATION_TIMEOUT_MS = 10_000L
private const val REBOOT_DELAY_MS = 5_000L
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 2_000L
const val RECOMMENDED_CHUNK_SIZE = 512
}
}

View file

@ -0,0 +1,359 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.ota
import android.content.Context
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_connecting_attempt
import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_erasing
import org.meshtastic.core.resources.firmware_update_extracting
import org.meshtastic.core.resources.firmware_update_hash_rejected
import org.meshtastic.core.resources.firmware_update_not_found_in_release
import org.meshtastic.core.resources.firmware_update_ota_failed
import org.meshtastic.core.resources.firmware_update_starting_ota
import org.meshtastic.core.resources.firmware_update_uploading
import org.meshtastic.core.resources.firmware_update_waiting_reboot
import org.meshtastic.feature.firmware.FirmwareRetriever
import org.meshtastic.feature.firmware.FirmwareUpdateHandler
import org.meshtastic.feature.firmware.FirmwareUpdateState
import org.meshtastic.feature.firmware.ProgressState
private const val RETRY_DELAY = 2000L
private const val PERCENT_MAX = 100
private const val KIB_DIVISOR = 1024f
private const val MILLIS_PER_SECOND = 1000f
// Time to wait for OTA reboot packet to be sent before disconnecting mesh service
private const val PACKET_SEND_DELAY_MS = 2000L
// Time to wait for Android BLE GATT to fully release after disconnecting mesh service
private const val GATT_RELEASE_DELAY_MS = 1000L
/**
* Handler for ESP32 firmware updates using the Unified OTA protocol. Supports both BLE and WiFi/TCP transports via
* UnifiedOtaProtocol.
*/
@Suppress("TooManyFunctions")
@Single
class Esp32OtaUpdateHandler(
private val firmwareRetriever: FirmwareRetriever,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
private val bleScanner: BleScanner,
private val bleConnectionFactory: BleConnectionFactory,
private val context: Context,
) : FirmwareUpdateHandler {
/** Entry point for FirmwareUpdateHandler interface. Decides between BLE and WiFi based on target format. */
override suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
target: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri?,
): String? = if (target.contains(":")) {
startBleUpdate(release, hardware, target, updateState, firmwareUri)
} else {
startWifiUpdate(release, hardware, target, updateState, firmwareUri)
}
private suspend fun startBleUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri? = null,
): String? = performUpdate(
release = release,
hardware = hardware,
updateState = updateState,
firmwareUri = firmwareUri,
transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) },
rebootMode = 1,
connectionAttempts = 5,
)
private suspend fun startWifiUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
deviceIp: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri? = null,
): String? = performUpdate(
release = release,
hardware = hardware,
updateState = updateState,
firmwareUri = firmwareUri,
transportFactory = { WifiOtaTransport(deviceIp, WifiOtaTransport.DEFAULT_PORT) },
rebootMode = 2,
connectionAttempts = 10,
)
private suspend fun performUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri?,
transportFactory: () -> UnifiedOtaProtocol,
rebootMode: Int,
connectionAttempts: Int,
): String? = try {
withContext(Dispatchers.IO) {
// Step 1: Get firmware file
val firmwareFile =
obtainFirmwareFile(release, hardware, firmwareUri, updateState) ?: return@withContext null
// Step 2: Calculate Hash and Trigger Reboot
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(java.io.File(firmwareFile))
val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes)
Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash" }
triggerRebootOta(rebootMode, sha256Bytes)
// Step 3: Wait for packet to be sent, then disconnect mesh service
// The packet needs ~1-2 seconds to be written and acknowledged over BLE
delay(PACKET_SEND_DELAY_MS)
disconnectMeshService()
// Give BLE stack time to fully release the GATT connection
delay(GATT_RELEASE_DELAY_MS)
val transport = transportFactory()
if (!connectToDevice(transport, connectionAttempts, updateState)) return@withContext null
try {
executeOtaSequence(transport, firmwareFile, sha256Hash, rebootMode, updateState)
firmwareFile
} finally {
transport.close()
}
}
} catch (e: CancellationException) {
throw e
} catch (e: OtaProtocolException.HashRejected) {
Logger.e(e) { "ESP32 OTA: Hash rejected by device" }
val msg = getString(Res.string.firmware_update_hash_rejected)
updateState(FirmwareUpdateState.Error(msg))
null
} catch (e: OtaProtocolException) {
Logger.e(e) { "ESP32 OTA: Protocol error" }
val msg = getString(Res.string.firmware_update_ota_failed, e.message ?: "")
updateState(FirmwareUpdateState.Error(msg))
null
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "ESP32 OTA: Unexpected error" }
val msg = getString(Res.string.firmware_update_ota_failed, e.message ?: "")
updateState(FirmwareUpdateState.Error(msg))
null
}
@Suppress("UnusedPrivateMember")
private suspend fun downloadFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
updateState: (FirmwareUpdateState) -> Unit,
): String? {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0).replace(Regex(":?\\s*%1\\\$d%?"), "").trim()
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
return firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
),
)
}
}
private suspend fun getFirmwareFromUri(uri: CommonUri): String? = withContext(Dispatchers.IO) {
val inputStream =
context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri)
?: return@withContext null
val tempFile = java.io.File(context.cacheDir, "firmware_update/ota_firmware.bin")
tempFile.parentFile?.mkdirs()
inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } }
tempFile.absolutePath
}
private fun triggerRebootOta(mode: Int, hash: ByteArray?) {
val myInfo = nodeRepository.myNodeInfo.value ?: return
val myNodeNum = myInfo.myNodeNum
Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" }
CoroutineScope(Dispatchers.IO).launch {
radioController.requestRebootOta(radioController.getPacketId(), myNodeNum, mode, hash)
}
}
/**
* Disconnect the mesh service BLE connection to free up the GATT for OTA. Setting device address to "n" (NOP
* interface) cleanly disconnects without reconnection attempts.
*/
private fun disconnectMeshService() {
Logger.i { "ESP32 OTA: Disconnecting mesh service for OTA" }
radioController.setDeviceAddress("n")
}
private suspend fun obtainFirmwareFile(
release: FirmwareRelease,
hardware: DeviceHardware,
firmwareUri: CommonUri?,
updateState: (FirmwareUpdateState) -> Unit,
): String? {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0).replace(Regex(":?\\s*%1\\\$d%?"), "").trim()
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
return if (firmwareUri != null) {
val extractingMsg = getString(Res.string.firmware_update_extracting)
updateState(FirmwareUpdateState.Processing(ProgressState(message = extractingMsg)))
getFirmwareFromUri(firmwareUri)
} else {
val firmwareFile =
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
),
)
}
if (firmwareFile == null) {
val errorMsg = getString(Res.string.firmware_update_not_found_in_release, hardware.displayName)
updateState(FirmwareUpdateState.Error(errorMsg))
null
} else {
firmwareFile
}
}
}
private suspend fun connectToDevice(
transport: UnifiedOtaProtocol,
attempts: Int,
updateState: (FirmwareUpdateState) -> Unit,
): Boolean {
// Show "waiting for reboot" state before first connection attempt
val waitingMsg = getString(Res.string.firmware_update_waiting_reboot)
updateState(FirmwareUpdateState.Processing(ProgressState(waitingMsg)))
for (i in 1..attempts) {
try {
val connectingMsg = getString(Res.string.firmware_update_connecting_attempt, i, attempts)
updateState(FirmwareUpdateState.Processing(ProgressState(connectingMsg)))
transport.connect().getOrThrow()
return true
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
if (i == attempts) throw e
delay(RETRY_DELAY)
}
}
return false
}
@Suppress("LongMethod")
private suspend fun executeOtaSequence(
transport: UnifiedOtaProtocol,
firmwareFile: String,
sha256Hash: String,
rebootMode: Int,
updateState: (FirmwareUpdateState) -> Unit,
) {
val file = java.io.File(firmwareFile)
// Step 5: Start OTA
val startingOtaMsg = getString(Res.string.firmware_update_starting_ota)
updateState(FirmwareUpdateState.Processing(ProgressState(startingOtaMsg)))
transport
.startOta(sizeBytes = file.length(), sha256Hash = sha256Hash) { status ->
when (status) {
OtaHandshakeStatus.Erasing -> {
val erasingMsg = getString(Res.string.firmware_update_erasing)
updateState(FirmwareUpdateState.Processing(ProgressState(erasingMsg)))
}
}
}
.getOrThrow()
// Step 6: Stream
val uploadingMsg = getString(Res.string.firmware_update_uploading)
updateState(FirmwareUpdateState.Updating(ProgressState(uploadingMsg, 0f)))
val firmwareData = file.readBytes()
val chunkSize =
if (rebootMode == 1) {
BleOtaTransport.RECOMMENDED_CHUNK_SIZE
} else {
WifiOtaTransport.RECOMMENDED_CHUNK_SIZE
}
val startTime = nowMillis
transport
.streamFirmware(
data = firmwareData,
chunkSize = chunkSize,
onProgress = { progress ->
val currentTime = nowMillis
val elapsedSeconds = (currentTime - startTime) / MILLIS_PER_SECOND
val percent = (progress * PERCENT_MAX).toInt()
val speedText =
if (elapsedSeconds > 0) {
val bytesSent = (progress * firmwareData.size).toLong()
val kibPerSecond = (bytesSent / KIB_DIVISOR) / elapsedSeconds
val remainingBytes = firmwareData.size - bytesSent
val etaSeconds = if (kibPerSecond > 0) (remainingBytes / KIB_DIVISOR) / kibPerSecond else 0f
String.format(java.util.Locale.US, "%.1f KiB/s, ETA: %ds", kibPerSecond, etaSeconds.toInt())
} else {
""
}
updateState(
FirmwareUpdateState.Updating(
ProgressState(
message = uploadingMsg,
progress = progress,
details = "$percent% ($speedText)",
),
),
)
},
)
.getOrThrow()
Logger.i { "ESP32 OTA: Firmware stream completed" }
updateState(FirmwareUpdateState.Success)
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.ota
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest
/** Utility functions for firmware hash calculation. */
object FirmwareHashUtil {
private const val BUFFER_SIZE = 8192
/**
* Calculate SHA-256 hash of a file as a byte array.
*
* @param file Firmware file to hash
* @return 32-byte SHA-256 hash
*/
fun calculateSha256Bytes(file: File): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { fis ->
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
while (fis.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
return digest.digest()
}
/** Convert byte array to hex string. */
fun bytesToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }
}

View file

@ -0,0 +1,145 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.ota
/** Commands supported by the ESP32 Unified OTA protocol. All commands are text-based and terminated with '\n'. */
sealed class OtaCommand {
/** Start OTA update with firmware size and SHA-256 hash */
data class StartOta(val sizeBytes: Long, val sha256Hash: String) : OtaCommand() {
override fun toString() = "OTA $sizeBytes $sha256Hash\n"
}
}
/** Responses from the ESP32 Unified OTA protocol. */
sealed class OtaResponse {
/** Successful response with optional data */
data class Ok(
val hwVersion: String? = null,
val fwVersion: String? = null,
val rebootCount: Int? = null,
val gitHash: String? = null,
) : OtaResponse()
/** Device is erasing flash partition (sent before OK after OTA command) */
data object Erasing : OtaResponse()
/** Acknowledgment for received data chunk (BLE only) */
data object Ack : OtaResponse()
/** Error response with message */
data class Error(val message: String) : OtaResponse()
companion object {
private const val OK_PREFIX_LENGTH = 3
private const val ERR_PREFIX_LENGTH = 4
private const val VERSION_PARTS_COUNT = 4
/**
* Parse a response string from the device. Format examples:
* - "OK\n"
* - "OK 1 2.3.4 45 v2.3.4-abc123\n"
* - "ERASING\n"
* - "ACK\n"
* - "ERR Hash Rejected\n"
*/
fun parse(response: String): OtaResponse {
val trimmed = response.trim()
return when {
trimmed == "OK" -> Ok()
trimmed.startsWith("OK ") -> {
val parts = trimmed.substring(OK_PREFIX_LENGTH).split(" ")
when (parts.size) {
VERSION_PARTS_COUNT ->
Ok(
hwVersion = parts[0],
fwVersion = parts[1],
rebootCount = parts[2].toIntOrNull(),
gitHash = parts[3],
)
else -> Ok()
}
}
trimmed == "ERASING" -> Erasing
trimmed == "ACK" -> Ack
trimmed.startsWith("ERR ") -> Error(trimmed.substring(ERR_PREFIX_LENGTH))
trimmed == "ERR" -> Error("Unknown error")
else -> Error("Unknown response: $trimmed")
}
}
}
}
/** Status updates during the OTA handshake. */
sealed class OtaHandshakeStatus {
/** The device is erasing the flash partition. */
data object Erasing : OtaHandshakeStatus()
}
/** Interface for ESP32 Unified OTA protocol implementation. Supports both BLE and WiFi/TCP transports. */
interface UnifiedOtaProtocol {
/**
* Connect to the device and discover OTA service/establish connection.
*
* @return Success if connected and ready, error otherwise
*/
suspend fun connect(): Result<Unit>
/**
* Start OTA update process.
*
* @param sizeBytes Total firmware size in bytes
* @param sha256Hash SHA-256 hash of the firmware (64 hex characters)
* @param onHandshakeStatus Optional callback to report status changes (e.g., "Erasing...")
* @return Success if device accepts and is ready, error otherwise
*/
suspend fun startOta(
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit = {},
): Result<Unit>
/**
* Stream firmware binary data to the device.
*
* @param data Complete firmware binary
* @param chunkSize Size of each chunk to send (256-512 for BLE, up to 1024 for WiFi)
* @param onProgress Progress callback (0.0 to 1.0)
* @return Success if all data transferred and verified, error otherwise
*/
suspend fun streamFirmware(data: ByteArray, chunkSize: Int, onProgress: suspend (Float) -> Unit): Result<Unit>
/** Close the connection and cleanup resources. */
suspend fun close()
}
/** Exception thrown during OTA protocol operations. */
sealed class OtaProtocolException(message: String, cause: Throwable? = null) : Exception(message, cause) {
class ConnectionFailed(message: String, cause: Throwable? = null) : OtaProtocolException(message, cause)
class CommandFailed(val command: OtaCommand, val response: OtaResponse.Error) :
OtaProtocolException("Command $command failed: ${response.message}")
class HashRejected(val providedHash: String) :
OtaProtocolException("Device rejected hash: $providedHash (NVS mismatch)")
class TransferFailed(message: String, cause: Throwable? = null) : OtaProtocolException(message, cause)
class VerificationFailed(message: String) : OtaProtocolException(message)
class Timeout(message: String) : OtaProtocolException(message)
}

View file

@ -0,0 +1,292 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.ota
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.meshtastic.core.common.util.nowMillis
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
/**
* WiFi/TCP transport implementation for ESP32 Unified OTA protocol.
*
* Uses UDP for device discovery on port 3232, then establishes TCP connection for OTA commands and firmware streaming.
*
* Unlike BLE, WiFi transport:
* - Uses synchronous TCP (no manual ACK waiting)
* - Supports larger chunk sizes (up to 1024 bytes)
* - Generally faster transfer speeds
*/
class WifiOtaTransport(private val deviceIpAddress: String, private val port: Int = DEFAULT_PORT) : UnifiedOtaProtocol {
private var socket: Socket? = null
private var writer: OutputStreamWriter? = null
private var reader: BufferedReader? = null
private var isConnected = false
/** Connect to the device via TCP. */
override suspend fun connect(): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" }
socket =
Socket().apply {
soTimeout = SOCKET_TIMEOUT_MS
connect(
InetSocketAddress(deviceIpAddress, this@WifiOtaTransport.port),
CONNECTION_TIMEOUT_MS,
)
}
writer = OutputStreamWriter(socket!!.getOutputStream(), Charsets.UTF_8)
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), Charsets.UTF_8))
isConnected = true
Logger.i { "WiFi OTA: Connected successfully" }
}
.onFailure { e ->
Logger.e(e) { "WiFi OTA: Connection failed" }
close()
}
}
override suspend fun startOta(
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
): Result<Unit> = runCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
sendCommand(command)
var handshakeComplete = false
while (!handshakeComplete) {
val response = readResponse(ERASING_TIMEOUT_MS)
when (val parsed = OtaResponse.parse(response)) {
is OtaResponse.Ok -> handshakeComplete = true
is OtaResponse.Erasing -> {
Logger.i { "WiFi OTA: Device erasing flash..." }
onHandshakeStatus(OtaHandshakeStatus.Erasing)
}
is OtaResponse.Error -> {
if (parsed.message.contains("Hash Rejected", ignoreCase = true)) {
throw OtaProtocolException.HashRejected(sha256Hash)
}
throw OtaProtocolException.CommandFailed(command, parsed)
}
else -> {
Logger.w { "WiFi OTA: Unexpected handshake response: $response" }
}
}
}
}
@Suppress("CyclomaticComplexMethod")
override suspend fun streamFirmware(
data: ByteArray,
chunkSize: Int,
onProgress: suspend (Float) -> Unit,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
if (!isConnected) {
throw OtaProtocolException.TransferFailed("Not connected")
}
val totalBytes = data.size
var sentBytes = 0
val outputStream = socket!!.getOutputStream()
while (sentBytes < totalBytes) {
val remainingBytes = totalBytes - sentBytes
val currentChunkSize = minOf(chunkSize, remainingBytes)
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
// Write chunk directly to TCP stream
outputStream.write(chunk)
outputStream.flush()
// In the updated protocol, the device may send ACKs over WiFi too.
// We check for any available responses without blocking too long.
if (reader?.ready() == true) {
val response = readResponse(ACK_TIMEOUT_MS)
val nextSentBytes = sentBytes + currentChunkSize
when (val parsed = OtaResponse.parse(response)) {
is OtaResponse.Ack -> {
// Normal chunk success
}
is OtaResponse.Ok -> {
// OK indicates completion (usually on last chunk)
if (nextSentBytes >= totalBytes) {
sentBytes = nextSentBytes
onProgress(1.0f)
return@runCatching Unit
}
}
is OtaResponse.Error -> {
throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
}
else -> {} // Ignore other responses during stream
}
}
sentBytes += currentChunkSize
onProgress(sentBytes.toFloat() / totalBytes)
// Small delay to avoid overwhelming the device
delay(WRITE_DELAY_MS)
}
Logger.i { "WiFi OTA: Firmware streaming complete ($sentBytes bytes)" }
// Wait for final verification response (loop until OK or Error)
var finalHandshakeComplete = false
while (!finalHandshakeComplete) {
val finalResponse = readResponse(VERIFICATION_TIMEOUT_MS)
when (val parsed = OtaResponse.parse(finalResponse)) {
is OtaResponse.Ok -> finalHandshakeComplete = true
is OtaResponse.Ack -> {} // Ignore late ACKs
is OtaResponse.Error -> {
if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
}
throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}")
}
else ->
throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $finalResponse")
}
}
}
}
override suspend fun close() {
withContext(Dispatchers.IO) {
runCatching {
writer?.close()
reader?.close()
socket?.close()
}
writer = null
reader = null
socket = null
isConnected = false
}
}
private suspend fun sendCommand(command: OtaCommand) = withContext(Dispatchers.IO) {
val w = writer ?: throw OtaProtocolException.ConnectionFailed("Not connected")
val commandStr = command.toString()
Logger.d { "WiFi OTA: Sending command: ${commandStr.trim()}" }
w.write(commandStr)
w.flush()
}
private suspend fun readResponse(timeoutMs: Long = COMMAND_TIMEOUT_MS): String = withContext(Dispatchers.IO) {
try {
withTimeout(timeoutMs) {
val r = reader ?: throw OtaProtocolException.ConnectionFailed("Not connected")
val response = r.readLine() ?: throw OtaProtocolException.ConnectionFailed("Connection closed")
Logger.d { "WiFi OTA: Received response: $response" }
response
}
} catch (@Suppress("SwallowedException") e: SocketTimeoutException) {
throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms")
}
}
companion object {
const val DEFAULT_PORT = 3232
const val RECOMMENDED_CHUNK_SIZE = 1024 // Larger than BLE
private const val RECEIVE_BUFFER_SIZE = 1024
private const val DISCOVERY_TIMEOUT_DEFAULT = 3000L
private const val BROADCAST_ADDRESS = "255.255.255.255"
// Timeouts
private const val CONNECTION_TIMEOUT_MS = 5_000
private const val SOCKET_TIMEOUT_MS = 15_000
private const val COMMAND_TIMEOUT_MS = 10_000L
private const val ERASING_TIMEOUT_MS = 60_000L
private const val ACK_TIMEOUT_MS = 10_000L
private const val VERIFICATION_TIMEOUT_MS = 10_000L
private const val WRITE_DELAY_MS = 10L // Shorter than BLE
/**
* Discover ESP32 devices on the local network via UDP broadcast.
*
* @return List of discovered device IP addresses
*/
suspend fun discoverDevices(timeoutMs: Long = DISCOVERY_TIMEOUT_DEFAULT): List<String> =
withContext(Dispatchers.IO) {
val devices = mutableListOf<String>()
runCatching {
DatagramSocket().use { socket ->
socket.broadcast = true
socket.soTimeout = timeoutMs.toInt()
// Send discovery broadcast
val discoveryMessage = "MESHTASTIC_OTA_DISCOVERY\n".toByteArray()
val broadcastAddress = InetAddress.getByName(BROADCAST_ADDRESS)
val packet =
DatagramPacket(discoveryMessage, discoveryMessage.size, broadcastAddress, DEFAULT_PORT)
socket.send(packet)
Logger.d { "WiFi OTA: Sent discovery broadcast" }
// Listen for responses
val receiveBuffer = ByteArray(RECEIVE_BUFFER_SIZE)
val startTime = nowMillis
while (nowMillis - startTime < timeoutMs) {
try {
val receivePacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
socket.receive(receivePacket)
val response = String(receivePacket.data, 0, receivePacket.length).trim()
if (response.startsWith("MESHTASTIC_OTA")) {
val deviceIp = receivePacket.address.hostAddress
if (deviceIp != null && !devices.contains(deviceIp)) {
devices.add(deviceIp)
Logger.i { "WiFi OTA: Discovered device at $deviceIp" }
}
}
} catch (@Suppress("SwallowedException") e: SocketTimeoutException) {
break
}
}
}
}
.onFailure { e -> Logger.e(e) { "WiFi OTA: Discovery failed" } }
devices
}
}
}