mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add firmware update module for Nordic nRF devices (#3782)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
3e4e9d5f29
commit
4b93065c7e
18 changed files with 1461 additions and 4 deletions
60
feature/firmware/build.gradle.kts
Normal file
60
feature/firmware/build.gradle.kts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kover)
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.compose)
|
||||
alias(libs.plugins.meshtastic.hilt)
|
||||
}
|
||||
|
||||
android { namespace = "org.meshtastic.feature.firmware" }
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.datastore)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.prefs)
|
||||
implementation(projects.core.proto)
|
||||
implementation(projects.core.service)
|
||||
implementation(projects.core.strings)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.timber)
|
||||
|
||||
implementation(libs.nordic)
|
||||
implementation(libs.nordic.dfu)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.markdown.renderer)
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
}
|
||||
15
feature/firmware/src/main/AndroidManifest.xml
Normal file
15
feature/firmware/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 no.nordicsemi.android.dfu.DfuBaseService
|
||||
import org.meshtastic.core.model.BuildConfig
|
||||
|
||||
class FirmwareDfuService : DfuBaseService() {
|
||||
override fun onCreate() {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel =
|
||||
NotificationChannel(NOTIFICATION_CHANNEL_DFU, "Firmware Update", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Firmware update status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun getNotificationTarget(): Class<out Activity>? = try {
|
||||
// Best effort to find the main activity
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
Class.forName("com.geeksville.mesh.MainActivity") as Class<out Activity>
|
||||
} catch (_: ClassNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun isDebug(): Boolean = BuildConfig.DEBUG
|
||||
}
|
||||
|
|
@ -0,0 +1,568 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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)
|
||||
|
||||
package org.meshtastic.feature.firmware
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.ColumnScope
|
||||
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.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Dangerous
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.chirpy
|
||||
import org.meshtastic.core.strings.firmware_update_almost_there
|
||||
import org.meshtastic.core.strings.firmware_update_alpha
|
||||
import org.meshtastic.core.strings.firmware_update_button
|
||||
import org.meshtastic.core.strings.firmware_update_checking
|
||||
import org.meshtastic.core.strings.firmware_update_device
|
||||
import org.meshtastic.core.strings.firmware_update_disclaimer_chirpy_says
|
||||
import org.meshtastic.core.strings.firmware_update_disclaimer_text
|
||||
import org.meshtastic.core.strings.firmware_update_disclaimer_title
|
||||
import org.meshtastic.core.strings.firmware_update_disconnect_warning
|
||||
import org.meshtastic.core.strings.firmware_update_do_not_close
|
||||
import org.meshtastic.core.strings.firmware_update_done
|
||||
import org.meshtastic.core.strings.firmware_update_downloading
|
||||
import org.meshtastic.core.strings.firmware_update_error
|
||||
import org.meshtastic.core.strings.firmware_update_hang_tight
|
||||
import org.meshtastic.core.strings.firmware_update_keep_device_close
|
||||
import org.meshtastic.core.strings.firmware_update_latest
|
||||
import org.meshtastic.core.strings.firmware_update_retry
|
||||
import org.meshtastic.core.strings.firmware_update_select_file
|
||||
import org.meshtastic.core.strings.firmware_update_stable
|
||||
import org.meshtastic.core.strings.firmware_update_success
|
||||
import org.meshtastic.core.strings.firmware_update_taking_a_while
|
||||
import org.meshtastic.core.strings.firmware_update_title
|
||||
import org.meshtastic.core.strings.firmware_update_unknown_release
|
||||
import org.meshtastic.core.strings.i_know_what_i_m_doing
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FirmwareUpdateScreen(
|
||||
navController: NavController,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: FirmwareUpdateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val selectedReleaseType by viewModel.selectedReleaseType.collectAsState()
|
||||
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri?.let { viewModel.startUpdateFromFile(it) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(stringResource(Res.string.firmware_update_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.padding(padding).fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
contentKey = { targetState ->
|
||||
when (targetState) {
|
||||
is FirmwareUpdateState.Idle -> "Idle"
|
||||
is FirmwareUpdateState.Checking -> "Checking"
|
||||
is FirmwareUpdateState.Ready -> "Ready"
|
||||
is FirmwareUpdateState.Downloading -> "Downloading"
|
||||
is FirmwareUpdateState.Processing -> "Processing"
|
||||
is FirmwareUpdateState.Updating -> "Updating"
|
||||
is FirmwareUpdateState.Error -> "Error"
|
||||
is FirmwareUpdateState.Success -> "Success"
|
||||
}
|
||||
},
|
||||
label = "FirmwareState",
|
||||
) { targetState ->
|
||||
FirmwareUpdateContent(
|
||||
state = targetState,
|
||||
selectedReleaseType = selectedReleaseType,
|
||||
onReleaseTypeSelect = viewModel::setReleaseType,
|
||||
onStartUpdate = viewModel::startUpdate,
|
||||
onPickFile = { launcher.launch("application/zip") },
|
||||
onRetry = viewModel::checkForUpdates,
|
||||
onDone = { navController.navigateUp() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirmwareUpdateContent(
|
||||
state: FirmwareUpdateState,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
|
||||
onStartUpdate: () -> Unit,
|
||||
onPickFile: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val modifier =
|
||||
if (state is FirmwareUpdateState.Ready) {
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp)
|
||||
} else {
|
||||
Modifier.padding(24.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
content = {
|
||||
when (state) {
|
||||
is FirmwareUpdateState.Idle,
|
||||
FirmwareUpdateState.Checking,
|
||||
-> CheckingState()
|
||||
|
||||
is FirmwareUpdateState.Ready ->
|
||||
ReadyState(state, selectedReleaseType, onReleaseTypeSelect, onStartUpdate, onPickFile)
|
||||
|
||||
is FirmwareUpdateState.Downloading -> DownloadingState(state)
|
||||
is FirmwareUpdateState.Processing -> ProcessingState(state.message)
|
||||
is FirmwareUpdateState.Updating -> UpdatingState(state)
|
||||
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = onRetry)
|
||||
|
||||
is FirmwareUpdateState.Success -> SuccessState(onDone = onDone)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.CheckingState() {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(stringResource(Res.string.firmware_update_checking), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun ColumnScope.ReadyState(
|
||||
state: FirmwareUpdateState.Ready,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
|
||||
onStartUpdate: () -> Unit,
|
||||
onPickFile: () -> Unit,
|
||||
) {
|
||||
var showDisclaimer by remember { mutableStateOf(false) }
|
||||
var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
|
||||
if (showDisclaimer) {
|
||||
DisclaimerDialog(
|
||||
onDismissRequest = { showDisclaimer = false },
|
||||
onConfirm = {
|
||||
showDisclaimer = false
|
||||
pendingAction?.invoke()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
DeviceHardwareImage(state.deviceHardware, Modifier.size(150.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
DeviceInfoCard(state.deviceHardware, state.release)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
if (state.release != null) {
|
||||
ReleaseTypeSelector(selectedReleaseType, onReleaseTypeSelect)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ReleaseNotesCard(state.release.releaseNotes)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
pendingAction = onStartUpdate
|
||||
showDisclaimer = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Icon(Icons.Default.SystemUpdate, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.firmware_update_button))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
pendingAction = onPickFile
|
||||
showDisclaimer = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.firmware_update_select_file))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisclaimerDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
|
||||
text = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(Res.string.firmware_update_disclaimer_text))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(4.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(4.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(org.meshtastic.core.ui.R.drawable.chirpy)
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(Res.string.i_know_what_i_m_doing)) } },
|
||||
dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@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).build(),
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = deviceHardware.displayName,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReleaseNotesCard(releaseNotes: String) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { expanded = !expanded },
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = "Release Notes", style = MaterialTheme.typography.titleMedium)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Markdown(content = releaseNotes, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceInfoCard(deviceHardware: DeviceHardware, release: FirmwareRelease?) {
|
||||
val target = deviceHardware.hwModelSlug.ifEmpty { deviceHardware.platformioTarget }
|
||||
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_device, deviceHardware.displayName),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Target: $target",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val releaseTitle = release?.title ?: stringResource(Res.string.firmware_update_unknown_release)
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_latest, releaseTitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 = 2),
|
||||
) {
|
||||
Text(stringResource(Res.string.firmware_update_stable))
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = selectedReleaseType == FirmwareReleaseType.ALPHA,
|
||||
onClick = { onReleaseTypeSelect(FirmwareReleaseType.ALPHA) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||
) {
|
||||
Text(stringResource(Res.string.firmware_update_alpha))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun ColumnScope.DownloadingState(state: FirmwareUpdateState.Downloading) {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_downloading, (state.progress * 100).toInt()),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LinearWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ProcessingState(message: String) {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(message, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.UpdatingState(state: FirmwareUpdateState.Updating) {
|
||||
CircularWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(state.message, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CyclingMessages()
|
||||
}
|
||||
|
||||
private const val CYCLE_DELAY = 4000L
|
||||
|
||||
@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)
|
||||
currentMessageIndex = (currentMessageIndex + 1) % messages.size
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(targetState = messages[currentMessageIndex], label = "CyclingMessage") { message ->
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ErrorState(error: String, onRetry: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Default.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(Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.firmware_update_retry))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SuccessState(onDone: () -> Unit) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_success),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text(stringResource(Res.string.firmware_update_done))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
|
||||
sealed interface FirmwareUpdateState {
|
||||
data object Idle : FirmwareUpdateState
|
||||
|
||||
data object Checking : FirmwareUpdateState
|
||||
|
||||
data class Ready(val release: FirmwareRelease?, val deviceHardware: DeviceHardware, val address: String) :
|
||||
FirmwareUpdateState
|
||||
|
||||
data class Downloading(val progress: Float) : FirmwareUpdateState
|
||||
|
||||
data class Processing(val message: String) : FirmwareUpdateState
|
||||
|
||||
data class Updating(val progress: Float, val message: String) : FirmwareUpdateState
|
||||
|
||||
data class Error(val error: String) : FirmwareUpdateState
|
||||
|
||||
data object Success : FirmwareUpdateState
|
||||
}
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import no.nordicsemi.android.dfu.DfuProgressListenerAdapter
|
||||
import no.nordicsemi.android.dfu.DfuServiceInitiator
|
||||
import no.nordicsemi.android.dfu.DfuServiceListenerHelper
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.firmware_update_extracting
|
||||
import org.meshtastic.core.strings.firmware_update_failed
|
||||
import org.meshtastic.core.strings.firmware_update_invalid_address
|
||||
import org.meshtastic.core.strings.firmware_update_no_device
|
||||
import org.meshtastic.core.strings.firmware_update_not_found_in_release
|
||||
import org.meshtastic.core.strings.firmware_update_starting_dfu
|
||||
import org.meshtastic.core.strings.firmware_update_starting_service
|
||||
import org.meshtastic.core.strings.firmware_update_unknown_hardware
|
||||
import org.meshtastic.core.strings.firmware_update_updating
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val NO_DEVICE_SELECTED = "n"
|
||||
private const val DFU_RECONNECT_PREFIX = "x"
|
||||
private const val DOWNLOAD_BUFFER_SIZE = 8192
|
||||
private const val PERCENT_MAX_VALUE = 100f
|
||||
|
||||
private const val SCAN_TIMEOUT = 2000L
|
||||
|
||||
private const val PACKETS_BEFORE_PRN = 8
|
||||
|
||||
private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||
|
||||
/**
|
||||
* ViewModel responsible for managing the firmware update process for Meshtastic devices.
|
||||
*
|
||||
* It handles checking for updates, downloading firmware artifacts, extracting compatible firmware, and initiating the
|
||||
* Device Firmware Update (DFU) process over Bluetooth.
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("LongParameterList")
|
||||
class FirmwareUpdateViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val centralManager: CentralManager,
|
||||
client: OkHttpClient,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
|
||||
val state: StateFlow<FirmwareUpdateState> = _state.asStateFlow()
|
||||
|
||||
private val _selectedReleaseType = MutableStateFlow(FirmwareReleaseType.STABLE)
|
||||
val selectedReleaseType: StateFlow<FirmwareReleaseType> = _selectedReleaseType.asStateFlow()
|
||||
|
||||
private var updateJob: Job? = null
|
||||
private val fileHandler = FirmwareFileHandler(context, client)
|
||||
private var tempFirmwareFile: File? = null
|
||||
|
||||
init {
|
||||
// Cleanup potential leftovers from previous crashes
|
||||
fileHandler.cleanupAllTemporaryFiles()
|
||||
checkForUpdates()
|
||||
|
||||
// Start listening to DFU events immediately
|
||||
viewModelScope.launch { observeDfuProgress() }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
cleanupTemporaryFiles()
|
||||
}
|
||||
|
||||
/** Sets the desired [FirmwareReleaseType] (e.g., ALPHA, STABLE) and triggers a new update check. */
|
||||
fun setReleaseType(type: FirmwareReleaseType) {
|
||||
_selectedReleaseType.value = type
|
||||
checkForUpdates()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a check for available firmware updates based on the selected release type.
|
||||
*
|
||||
* Validates the current device connection and hardware before fetching release information. Updates [state] to
|
||||
* [FirmwareUpdateState.Checking], then [FirmwareUpdateState.Ready] or [FirmwareUpdateState.Error].
|
||||
*/
|
||||
fun checkForUpdates() {
|
||||
updateJob?.cancel()
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
_state.value = FirmwareUpdateState.Checking
|
||||
|
||||
runCatching {
|
||||
val validationResult = validateDeviceAndConnection()
|
||||
|
||||
if (validationResult == null) {
|
||||
// Validation failed, state is already set to Error inside validateDeviceAndConnection
|
||||
return@launch
|
||||
}
|
||||
|
||||
val (ourNode, _, address) = validationResult
|
||||
val deviceHardware = getDeviceHardware(ourNode) ?: return@launch
|
||||
|
||||
firmwareReleaseRepository.getReleaseFlow(_selectedReleaseType.value).collectLatest { release ->
|
||||
_state.value = FirmwareUpdateState.Ready(release, deviceHardware, address)
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
if (e is CancellationException) throw e
|
||||
Timber.e(e)
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the firmware update process using the currently identified release.
|
||||
* 1. Downloads the firmware zip from the release URL.
|
||||
* 2. Extracts the correct firmware image for the connected device hardware.
|
||||
* 3. Initiates the DFU process.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun startUpdate() {
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
|
||||
val (release, hardware, address) = currentState
|
||||
|
||||
if (release == null || !isValidBluetoothAddress(address)) return
|
||||
|
||||
updateJob?.cancel()
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 1. Download
|
||||
_state.value = FirmwareUpdateState.Downloading(0f)
|
||||
|
||||
var firmwareFile: File? = null
|
||||
|
||||
// Try direct download of the specific device firmware
|
||||
val version = release.id.removePrefix("v")
|
||||
// We prefer platformioTarget because it matches the build artifact naming
|
||||
// convention (lower-case with hyphens).
|
||||
// hwModelSlug often uses underscores and uppercase
|
||||
// (e.g. TRACKER_T1000_E vs tracker-t1000-e).
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
val filename = "firmware-$target-$version-ota.zip"
|
||||
val directUrl = "https://meshtastic.github.io/firmware-$version/$filename"
|
||||
|
||||
if (fileHandler.checkUrlExists(directUrl)) {
|
||||
try {
|
||||
firmwareFile =
|
||||
fileHandler.downloadFile(directUrl, "firmware_direct.zip") { progress ->
|
||||
_state.value = FirmwareUpdateState.Downloading(progress)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Direct download failed, falling back to release zip")
|
||||
}
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture)
|
||||
|
||||
val downloadedZip =
|
||||
fileHandler.downloadFile(zipUrl, "firmware_release.zip") { progress ->
|
||||
_state.value = FirmwareUpdateState.Downloading(progress)
|
||||
}
|
||||
|
||||
// Note: Current API does not provide checksums, so we rely on content-length
|
||||
// checks during download and integrity checks during extraction.
|
||||
|
||||
// 2. Extract
|
||||
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_extracting))
|
||||
val extracted = fileHandler.extractFirmware(downloadedZip, hardware)
|
||||
|
||||
if (extracted == null) {
|
||||
val msg = getString(Res.string.firmware_update_not_found_in_release, hardware.displayName)
|
||||
_state.value = FirmwareUpdateState.Error(msg)
|
||||
return@launch
|
||||
}
|
||||
firmwareFile = extracted
|
||||
}
|
||||
|
||||
tempFirmwareFile = firmwareFile
|
||||
initiateDfu(address, hardware, firmwareFile!!)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: getString(Res.string.firmware_update_failed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a firmware update using a local file provided via [Uri].
|
||||
*
|
||||
* Copies the content to a temporary file and initiates the DFU process.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun startUpdateFromFile(uri: Uri) {
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
|
||||
val (_, hardware, address) = currentState
|
||||
|
||||
if (!isValidBluetoothAddress(address)) return
|
||||
|
||||
updateJob?.cancel()
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_extracting))
|
||||
val localFile = fileHandler.copyUriToFile(uri)
|
||||
tempFirmwareFile = localFile
|
||||
|
||||
initiateDfu(address, hardware, localFile)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
_state.value = FirmwareUpdateState.Error(e.message ?: "Local update failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the DFU service and starts the update.
|
||||
*
|
||||
* @param address The Bluetooth address of the target device.
|
||||
* @param deviceHardware The hardware definition of the target device.
|
||||
* @param firmwareFile The local file containing the firmware image.
|
||||
*/
|
||||
private fun initiateDfu(address: String, deviceHardware: DeviceHardware, firmwareFile: File) {
|
||||
viewModelScope.launch {
|
||||
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_starting_service))
|
||||
|
||||
serviceRepository.meshService?.setDeviceAddress(NO_DEVICE_SELECTED)
|
||||
|
||||
DfuServiceInitiator(address)
|
||||
.disableResume()
|
||||
.setDeviceName(deviceHardware.displayName)
|
||||
.setForceScanningForNewAddressInLegacyDfu(true)
|
||||
.setForeground(true)
|
||||
.setKeepBond(true)
|
||||
.setPacketsReceiptNotificationsEnabled(true)
|
||||
.setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN)
|
||||
.setScanTimeout(SCAN_TIMEOUT)
|
||||
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
|
||||
.setZip(Uri.fromFile(firmwareFile))
|
||||
.start(context, FirmwareDfuService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the callback-based DfuServiceListenerHelper to a Kotlin Flow. This decouples the listener implementation
|
||||
* from the ViewModel state.
|
||||
*/
|
||||
private suspend fun observeDfuProgress() {
|
||||
dfuProgressFlow(context)
|
||||
.flowOn(Dispatchers.Main) // Listener Helper typically needs main thread for registration
|
||||
.collect { dfuState ->
|
||||
when (dfuState) {
|
||||
is DfuInternalState.Progress -> {
|
||||
val msg = getString(Res.string.firmware_update_updating, "${dfuState.percent}")
|
||||
_state.value = FirmwareUpdateState.Updating(dfuState.percent / PERCENT_MAX_VALUE, msg)
|
||||
}
|
||||
is DfuInternalState.Error -> {
|
||||
_state.value = FirmwareUpdateState.Error("DFU Error: ${dfuState.message}")
|
||||
cleanupTemporaryFiles()
|
||||
}
|
||||
is DfuInternalState.Completed -> {
|
||||
_state.value = FirmwareUpdateState.Success
|
||||
serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX${dfuState.address}")
|
||||
cleanupTemporaryFiles()
|
||||
}
|
||||
is DfuInternalState.Aborted -> {
|
||||
_state.value = FirmwareUpdateState.Error("DFU Aborted")
|
||||
cleanupTemporaryFiles()
|
||||
}
|
||||
is DfuInternalState.Starting -> {
|
||||
val msg = getString(Res.string.firmware_update_starting_dfu)
|
||||
_state.value = FirmwareUpdateState.Processing(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupTemporaryFiles() {
|
||||
runCatching {
|
||||
tempFirmwareFile?.takeIf { it.exists() }?.delete()
|
||||
fileHandler.cleanupAllTemporaryFiles()
|
||||
}
|
||||
.onFailure { e -> Timber.w(e, "Failed to cleanup temp files") }
|
||||
tempFirmwareFile = null
|
||||
}
|
||||
|
||||
private data class ValidationResult(
|
||||
val node: org.meshtastic.core.database.model.Node,
|
||||
val peripheral: no.nordicsemi.kotlin.ble.client.android.Peripheral,
|
||||
val address: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Validates that a Meshtastic device is known (in Node DB), connected via Bluetooth, and has a valid Bluetooth
|
||||
* address.
|
||||
*/
|
||||
private suspend fun validateDeviceAndConnection(): ValidationResult? {
|
||||
val ourNode = nodeRepository.ourNodeInfo.value
|
||||
val connectedPeripheral =
|
||||
centralManager.getBondedPeripherals().firstOrNull { it.state.value == ConnectionState.Connected }
|
||||
val address = connectedPeripheral?.address
|
||||
|
||||
return if (ourNode != null && connectedPeripheral != null && address != null) {
|
||||
if (isValidBluetoothAddress(address)) {
|
||||
ValidationResult(ourNode, connectedPeripheral, address)
|
||||
} else {
|
||||
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_invalid_address, address))
|
||||
null
|
||||
}
|
||||
} else {
|
||||
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_no_device))
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDeviceHardware(ourNode: org.meshtastic.core.database.model.Node): DeviceHardware? {
|
||||
val hwModel = ourNode.user.hwModel?.number
|
||||
|
||||
return if (hwModel != null) {
|
||||
val deviceHardware = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
|
||||
if (deviceHardware != null) {
|
||||
deviceHardware
|
||||
} else {
|
||||
_state.value =
|
||||
FirmwareUpdateState.Error(getString(Res.string.firmware_update_unknown_hardware, hwModel))
|
||||
null
|
||||
}
|
||||
} else {
|
||||
_state.value = FirmwareUpdateState.Error("Node user information is missing.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
|
||||
// Architectures ordered by length descending to handle substrings like esp32-s3 vs esp32
|
||||
val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32")
|
||||
|
||||
for (arch in knownArchs) {
|
||||
if (url.contains(arch, ignoreCase = true)) {
|
||||
// Replace the found architecture with the target architecture
|
||||
// We use replacement to preserve the rest of the URL structure (version, server, etc.)
|
||||
return url.replace(arch, targetArch.lowercase(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/** Internal state representation for the DFU process flow. */
|
||||
private sealed interface DfuInternalState {
|
||||
data class Starting(val address: String) : DfuInternalState
|
||||
|
||||
data class Progress(val address: String, val percent: Int) : DfuInternalState
|
||||
|
||||
data class Completed(val address: String) : DfuInternalState
|
||||
|
||||
data class Aborted(val address: String) : DfuInternalState
|
||||
|
||||
data class Error(val address: String, val message: String?) : DfuInternalState
|
||||
}
|
||||
|
||||
private fun isValidBluetoothAddress(address: String?): Boolean =
|
||||
address != null && BLUETOOTH_ADDRESS_REGEX.matches(address)
|
||||
|
||||
private fun FirmwareReleaseRepository.getReleaseFlow(type: FirmwareReleaseType): Flow<FirmwareRelease?> = when (type) {
|
||||
FirmwareReleaseType.STABLE -> stableRelease
|
||||
FirmwareReleaseType.ALPHA -> alphaRelease
|
||||
}
|
||||
|
||||
/** Converts Nordic DFU callbacks to a cold Flow. Automatically registers/unregisters the listener. */
|
||||
private fun dfuProgressFlow(context: Context): Flow<DfuInternalState> = callbackFlow {
|
||||
val listener =
|
||||
object : DfuProgressListenerAdapter() {
|
||||
override fun onDfuProcessStarting(deviceAddress: String) {
|
||||
trySend(DfuInternalState.Starting(deviceAddress))
|
||||
}
|
||||
|
||||
override fun onProgressChanged(
|
||||
deviceAddress: String,
|
||||
percent: Int,
|
||||
speed: Float,
|
||||
avgSpeed: Float,
|
||||
currentPart: Int,
|
||||
partsTotal: Int,
|
||||
) {
|
||||
trySend(DfuInternalState.Progress(deviceAddress, percent))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
DfuServiceListenerHelper.registerProgressListener(context, listener)
|
||||
awaitClose { DfuServiceListenerHelper.unregisterProgressListener(context, listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and
|
||||
* extracting specific files from Zip archives.
|
||||
*/
|
||||
private class FirmwareFileHandler(private val context: Context, private val client: OkHttpClient) {
|
||||
private val tempDir = File(context.cacheDir, "firmware_update")
|
||||
|
||||
fun cleanupAllTemporaryFiles() {
|
||||
runCatching {
|
||||
if (tempDir.exists()) {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
tempDir.mkdirs()
|
||||
}
|
||||
.onFailure { e -> Timber.w(e, "Failed to cleanup temp directory") }
|
||||
}
|
||||
|
||||
suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).head().build()
|
||||
try {
|
||||
client.newCall(request).execute().use { response -> response.isSuccessful }
|
||||
} catch (e: IOException) {
|
||||
Timber.w(e, "Failed to check URL existence: $url")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun copyUriToFile(uri: Uri): File = withContext(Dispatchers.IO) {
|
||||
val inputStream =
|
||||
context.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open content URI")
|
||||
|
||||
// Ensure tempDir exists
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
val targetFile = File(tempDir, "local_update.zip")
|
||||
|
||||
inputStream.use { input -> FileOutputStream(targetFile).use { output -> input.copyTo(output) } }
|
||||
targetFile
|
||||
}
|
||||
|
||||
suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): File =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) throw IOException("Download failed: ${response.code}")
|
||||
|
||||
val body = response.body ?: throw IOException("Empty response body")
|
||||
val contentLength = body.contentLength()
|
||||
|
||||
// Ensure tempDir exists
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
val targetFile = File(tempDir, fileName)
|
||||
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(targetFile).use { output ->
|
||||
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
|
||||
var bytesRead: Int
|
||||
var totalBytesRead = 0L
|
||||
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
// Check for coroutine cancellation during heavy IO loops
|
||||
if (!isActive) throw CancellationException("Download cancelled")
|
||||
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
|
||||
if (contentLength > 0) {
|
||||
onProgress(totalBytesRead.toFloat() / contentLength)
|
||||
}
|
||||
}
|
||||
// Basic integrity check
|
||||
if (contentLength != -1L && totalBytesRead != contentLength) {
|
||||
throw IOException("Incomplete download: expected $contentLength bytes, got $totalBytesRead")
|
||||
}
|
||||
}
|
||||
}
|
||||
targetFile
|
||||
}
|
||||
|
||||
suspend fun extractFirmware(zipFile: File, hardware: DeviceHardware): File? = withContext(Dispatchers.IO) {
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
if (target.isEmpty()) return@withContext null
|
||||
|
||||
val targetLowerCase = target.lowercase()
|
||||
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
|
||||
|
||||
// Ensure tempDir exists
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
ZipInputStream(zipFile.inputStream()).use { zipInput ->
|
||||
var entry = zipInput.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.lowercase()
|
||||
if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase)) {
|
||||
val outFile = File(tempDir, File(name).name)
|
||||
// We extract to verify it's a valid zip entry payload
|
||||
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
|
||||
matchingEntries.add(entry to outFile)
|
||||
}
|
||||
entry = zipInput.nextEntry
|
||||
}
|
||||
}
|
||||
// Best match heuristic: prefer shortest filename (e.g. 'tbeam' matches 'tbeam-s3', but 'tbeam' is shorter)
|
||||
// This prevents flashing 'tbeam-s3' firmware onto a 'tbeam' device if both are present.
|
||||
matchingEntries.minByOrNull { it.first.name.length }?.second
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a filename matches the target device. Enforces stricter matching to avoid substring false positives
|
||||
* (e.g. "tbeam" matching "tbeam-s3").
|
||||
*/
|
||||
private fun isValidFirmwareFile(filename: String, target: String): Boolean {
|
||||
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_\\.].*")
|
||||
return filename.endsWith(".zip") &&
|
||||
filename.contains(target) &&
|
||||
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
|
||||
}
|
||||
}
|
||||
|
|
@ -133,6 +133,7 @@ fun SettingsScreen(
|
|||
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
|
||||
val isDfuCapable by settingsViewModel.isDfuCapable.collectAsStateWithLifecycle()
|
||||
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
var isWaiting by remember { mutableStateOf(false) }
|
||||
|
|
@ -244,6 +245,7 @@ fun SettingsScreen(
|
|||
state = state,
|
||||
isManaged = localConfig.security.isManaged,
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
isDfuCapable = isDfuCapable,
|
||||
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
|
||||
onRouteClick = { route ->
|
||||
isWaiting = true
|
||||
|
|
|
|||
|
|
@ -26,14 +26,17 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
|
|
@ -44,6 +47,7 @@ import org.meshtastic.core.database.model.Node
|
|||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.positionToMeter
|
||||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
|
|
@ -74,6 +78,8 @@ constructor(
|
|||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
) : ViewModel() {
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
|
||||
|
||||
|
|
@ -109,6 +115,28 @@ constructor(
|
|||
val appVersionName
|
||||
get() = buildConfigProvider.versionName
|
||||
|
||||
val isDfuCapable: StateFlow<Boolean> =
|
||||
combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
|
||||
.flatMapLatest { (node, connectionState) ->
|
||||
if (node == null || !connectionState.isConnected()) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
// Check BLE address
|
||||
val address = radioPrefs.devAddr
|
||||
if (address == null || !address.startsWith("x")) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
// Check hardware
|
||||
val hwModel = node.user.hwModel.number
|
||||
flow {
|
||||
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
|
||||
emit(hw?.requiresDfu == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = false)
|
||||
|
||||
// Device DB cache limit (bounded by DatabaseConstants)
|
||||
val dbCacheLimit: StateFlow<Int> = databaseManager.cacheLimit
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.PowerSettingsNew
|
|||
import androidx.compose.material.icons.rounded.RestartAlt
|
||||
import androidx.compose.material.icons.rounded.Restore
|
||||
import androidx.compose.material.icons.rounded.Storage
|
||||
import androidx.compose.material.icons.rounded.SystemUpdate
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
|
|
@ -48,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.FirmwareRoutes
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.strings.Res
|
||||
|
|
@ -59,6 +61,7 @@ import org.meshtastic.core.strings.debug_panel
|
|||
import org.meshtastic.core.strings.device_configuration
|
||||
import org.meshtastic.core.strings.export_configuration
|
||||
import org.meshtastic.core.strings.factory_reset
|
||||
import org.meshtastic.core.strings.firmware_update_title
|
||||
import org.meshtastic.core.strings.import_configuration
|
||||
import org.meshtastic.core.strings.message_device_managed
|
||||
import org.meshtastic.core.strings.module_settings
|
||||
|
|
@ -82,6 +85,7 @@ fun RadioConfigItemList(
|
|||
state: RadioConfigState,
|
||||
isManaged: Boolean,
|
||||
excludedModulesUnlocked: Boolean = false,
|
||||
isDfuCapable: Boolean = false,
|
||||
onPreserveFavoritesToggle: (Boolean) -> Unit = {},
|
||||
onRouteClick: (Enum<*>) -> Unit = {},
|
||||
onImport: () -> Unit = {},
|
||||
|
|
@ -194,6 +198,15 @@ fun RadioConfigItemList(
|
|||
ManagedMessage()
|
||||
}
|
||||
|
||||
if (isDfuCapable && state.isLocal) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.firmware_update_title),
|
||||
leadingIcon = Icons.Rounded.SystemUpdate,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.clean_node_database_title),
|
||||
leadingIcon = Icons.Rounded.CleaningServices,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue