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:
James Rich
2025-11-24 13:02:53 -06:00
committed by GitHub
parent 3e4e9d5f29
commit 4b93065c7e
18 changed files with 1461 additions and 4 deletions

View File

@@ -216,6 +216,7 @@ dependencies {
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.navigationSuite)

View File

@@ -15,7 +15,6 @@
<ID>CyclomaticComplexMethod:SettingsNavigation.kt$@Suppress("LongMethod") fun NavGraphBuilder.settingsGraph(navController: NavHostController)</ID>
<ID>EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>EmptyFunctionBlock:NopInterface.kt$NopInterface${ }</ID>
<ID>EmptyFunctionBlock:NsdManager.kt$&lt;no name provided&gt;${ }</ID>
<ID>EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}</ID>
<ID>FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt</ID>
<ID>FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt</ID>
@@ -64,7 +63,6 @@
<ID>MagicNumber:TCPInterface.kt$TCPInterface$180</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$500</ID>
<ID>MagicNumber:UIState.kt$4</ID>
<ID>MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService.&lt;no name provided&gt;$"sendData dest=${p.to}, id=${p.id} &lt;- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})"</ID>
@@ -122,7 +120,6 @@
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService.&lt;no name provided&gt;$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshServiceStarter.kt$ServiceStarter$ex: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable</ID>
<ID>TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable</ID>

View File

@@ -0,0 +1,31 @@
/*
* 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 com.geeksville.mesh.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
fun NavGraphBuilder.firmwareGraph(navController: NavController) {
navigation<FirmwareRoutes.FirmwareGraph>(startDestination = FirmwareRoutes.FirmwareUpdate) {
composable<FirmwareRoutes.FirmwareUpdate> { FirmwareUpdateScreen(navController) }
}
}

View File

@@ -86,6 +86,7 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.channelsGraph
import com.geeksville.mesh.navigation.connectionsGraph
import com.geeksville.mesh.navigation.contactsGraph
import com.geeksville.mesh.navigation.firmwareGraph
import com.geeksville.mesh.navigation.mapGraph
import com.geeksville.mesh.navigation.nodesGraph
import com.geeksville.mesh.navigation.settingsGraph
@@ -433,6 +434,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
channelsGraph(navController)
connectionsGraph(navController)
settingsGraph(navController)
firmwareGraph(navController)
}
}
}

View File

@@ -150,3 +150,9 @@ object SettingsRoutes {
// endregion
}
object FirmwareRoutes {
@Serializable data object FirmwareGraph : Graph
@Serializable data object FirmwareUpdate : Route
}

View File

@@ -955,4 +955,39 @@
<string name="relayed_by">Relayed by: %1$s</string>
<string name="preserve_favorites">Preserve Favorites?</string>
<string name="usb_devices">USB Devices</string>
</resources>
<!-- Firmware Update -->
<string name="firmware_update_title">Firmware Update</string>
<string name="firmware_update_checking">Checking for updates...</string>
<string name="firmware_update_device">Device: %1$s</string>
<string name="firmware_update_latest">Latest Release: %1$s</string>
<string name="firmware_update_stable">Stable</string>
<string name="firmware_update_alpha">Alpha</string>
<string name="firmware_update_button">Update Firmware</string>
<string name="firmware_update_disconnect_warning">Note: This will temporarily disconnect your device during the update.</string>
<string name="firmware_update_downloading">Downloading firmware... %1$d%</string>
<string name="firmware_update_error">Error: %1$s</string>
<string name="firmware_update_retry">Retry</string>
<string name="firmware_update_success">Update Successful!</string>
<string name="firmware_update_done">Done</string>
<string name="firmware_update_starting_dfu">Starting DFU...</string>
<string name="firmware_update_updating">Updating... %1$s%</string>
<string name="firmware_update_unknown_hardware">Unknown hardware model: %1$d</string>
<string name="firmware_update_invalid_address">Connected device is not a BLE device or address is unknown (%1$s). DFU requires BLE.</string>
<string name="firmware_update_no_device">No device connected</string>
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
<string name="firmware_update_extracting">Extracting firmware...</string>
<string name="firmware_update_starting_service">Disconnecting to start DFU service...</string>
<string name="firmware_update_failed">Update failed</string>
<string name="firmware_update_hang_tight">Hang tight, we are working on it...</string>
<string name="firmware_update_keep_device_close">Keep your device close to your phone.</string>
<string name="firmware_update_do_not_close">Do not close the app.</string>
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_taking_a_while">This might take a minute...</string>
<string name="firmware_update_select_file">Select Local File</string>
<string name="firmware_update_unknown_release">Unknown remote release</string>
<string name="firmware_update_disclaimer_title">Update Warning</string>
<string name="firmware_update_disclaimer_text">You are about to flash new firmware to your device. This process carries risks.\n\n• Ensure your device is charged.\n• Keep the device close to your phone.\n• Do not close the app during the update.\n\nVerify you have selected the correct firmware for your hardware.</string>
<string name="firmware_update_disclaimer_chirpy_says">Chirpy says, "Keep your ladder handy!"</string>
<string name="chirpy">Chirpy</string>
</resources>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="48dp" android:viewportHeight="2607.9" android:viewportWidth="1871.7" android:width="34.449787dp">
<path android:fillColor="#67ea94" android:pathData="M1349.1,573.2h-855.5c-11.1,0 -20,9 -20,20v1219.2c0,10.1 8.2,18.3 18.3,18.3h854.3c8.7,0 15.8,-7.1 15.8,-15.8L1361.9,586c0,-7.1 -5.8,-12.9 -12.9,-12.9ZM1128.5,1143h-411L717.5,787h411v356Z" android:strokeWidth="0"/>
<path android:fillColor="#2c2d3c" android:pathData="M1166.6,5.5c-21.2,13.2 -31.7,24.3 -34.1,36 -2.2,10.6 -1.8,11.4 13.6,26.9 7.7,7.6 15.3,15.7 17.1,18l3.2,4.1 -13.7,5.8c-26.6,11.4 -109.5,54 -125.2,64.4 -4.6,3 -5.5,4.1 -5.5,6.5 0,3.5 2.7,5.6 5.1,4.1 1.3,-0.8 2.9,-0.5 6.4,1.2 9.4,4.5 34.3,10.5 59,14.3 6.9,1.1 27.6,3.3 46,5 49.1,4.5 71,8 71,11.5 0,2.2 -21.1,23.5 -37.5,38 -8.2,7.2 -24.4,20.9 -36,30.5 -29,24 -42.2,35.9 -62,56.1 -27.2,27.7 -54.4,60.8 -60.9,74.1 -2.5,5 -2.7,6.1 -1.7,9.1 0.7,1.9 8.9,12.6 18.4,23.9 9.5,11.2 24.2,29.2 32.8,39.9 30.8,38.2 48.3,56.9 75.2,80.4l13.7,12 -118.2,0.6c-65.1,0.3 -203.8,0.7 -308.4,0.8 -182.2,0.3 -221.1,0.8 -237.2,3.1 -11.6,1.6 -16.2,4.8 -18.1,12.7 -0.9,3.3 -1.3,130.7 -1.6,479.6 -0.3,261.4 -0.7,530.3 -0.9,597.7 -0.5,142.7 -0.1,152.6 6.6,163.8 2.6,4.2 6.9,6.4 13.7,6.8 3.1,0.2 55,0.6 115.4,1 99.7,0.6 109.7,0.8 109.7,2.3 0.2,11.7 -9.3,741.7 -9.6,742 -0.2,0.3 -75.5,-1.9 -167.2,-4.8 -158.1,-4.9 -167.1,-5.1 -171,-3.5 -11.6,4.6 -12.2,20.8 -1,26.3 4.1,1.9 10.2,2.2 165.3,7.1 88.6,2.8 168.2,5.1 177,5.1h16l4.7,-5.3c2.6,-2.9 5.2,-6.2 5.7,-7.3 0.7,-1.5 10.4,-722.4 10.1,-757.4v-4.4l159.2,0.7c87.6,0.4 159.3,0.8 159.4,0.9 0,0.1 2.7,169.2 5.9,375.7l5.7,375.5 3,6c1.6,3.3 4.2,7.6 5.8,9.5l2.9,3.5 167.8,-0.3c158.5,-0.4 168,-0.5 171.9,-2.2 11.2,-4.9 12.1,-19.9 1.5,-25.8l-3.9,-2.2h-318.7l-0.3,-7.7c-0.3,-10.8 -11.2,-710.5 -11.2,-722.6v-9.7h121.8c85.3,0.1 123.8,-0.2 128.7,-1 12.4,-2 18.1,-6.1 21.9,-15.8 1.5,-4 1.6,-46.7 1.6,-608.5 0,-332.3 -0.3,-610.5 -0.6,-618.2 -0.7,-15.7 -1.6,-20 -4.7,-21.8 -1.7,-0.9 -24.6,-1.3 -101.2,-1.5l-99,-0.2 -3,-3.4c-1.6,-1.9 -9.1,-8.8 -16.5,-15.4 -24.3,-21.5 -43.4,-42.2 -71.5,-77.2 -8.2,-10.2 -21.9,-26.8 -30.5,-37 -8.5,-10.2 -16.8,-20 -18.3,-21.9 -3.4,-4.1 -3.2,-5.1 3.8,-15.6 11.1,-16.9 35.8,-45.8 59,-69 16.8,-16.9 30.6,-29.2 57,-51 36,-29.7 54.9,-46.8 67.7,-60.9 8.5,-9.4 8.9,-10.1 8.6,-14 -0.3,-3.8 -0.7,-4.3 -5,-6.4 -8,-4 -29.7,-7.2 -76.3,-11.2 -36.5,-3.1 -62.7,-7.4 -84.3,-13.6 -8.9,-2.6 -17.8,-6.1 -17.1,-6.8 1.4,-1.3 31.8,-17.6 56.9,-30.4 31.9,-16.3 56.3,-27.9 73.3,-34.8l10.7,-4.5 6.3,5.2c11.6,9.7 23.7,13.6 38.1,12.1 8.3,-0.8 10,-2.6 11.7,-11.7 2.1,-11.2 -0.9,-22.9 -12,-47.4 -9.8,-21.7 -19.8,-36.9 -31.4,-47.9 -7.3,-6.8 -8.8,-6.8 -20.6,0.5ZM1356.7,575.6c1.1,1.1 1.3,114 1.6,619.5l0.2,618.3 -2.2,4.4c-3.2,6.3 -8.1,8.7 -19.8,10 -9.2,0.9 -48.7,0.8 -618.5,-0.9 -254.3,-0.8 -236.4,-0.3 -239.4,-6.6 -3.2,-6.6 -4.2,-16 -4.7,-44 -0.2,-15.4 0,-283.4 0.4,-595.5 0.5,-312.1 0.5,-569.2 0,-571.3 -0.9,-4.2 0.5,-18.4 2.5,-24.5 1,-3.2 1.9,-3.9 6.3,-5.4 10.9,-3.6 32.3,-3.9 396.9,-4.7 448.7,-1 475,-1 476.7,0.7Z" android:strokeWidth="0"/>
<path android:fillColor="#fff" android:pathData="M717.5,787h411v356h-411z" android:strokeWidth="0"/>
<path android:fillColor="#454656" android:pathData="M1246.6,622.6c-10.8,3.8 -18.5,10.8 -23.7,21.4 -2.8,5.6 -3,6.4 -3,16.6s0.2,11 3,16.9c7.6,15.9 22,24.3 40.3,23.5 7.4,-0.3 9.3,-0.7 15.6,-3.7 8.7,-4.2 15.4,-10.6 19,-18.3 10,-21 1.9,-43.8 -19.2,-54.2 -6.6,-3.2 -7.5,-3.4 -17.1,-3.7 -8,-0.2 -11.3,0.2 -14.9,1.5Z" android:strokeColor="#2c2d3c" android:strokeWidth="7"/>
<path android:fillColor="#2c2d3c" android:pathData="M651.6,705.9c-8,1.6 -10.3,5.2 -12,19.4 -1.4,12 -1.8,452.5 -0.4,459.2 1.4,6.4 2.7,8.9 5.7,10.8 2.1,1.4 31.7,1.6 275.1,1.5 150,0 275.3,-0.3 278.4,-0.6 6.8,-0.7 9.6,-1.9 10.7,-4.8 0.5,-1.1 0.7,-106.1 0.4,-235.6 -0.6,-263.8 0.3,-239.2 -8.5,-243.1 -5.2,-2.3 -17.1,-5 -23,-5.1 -8,-0.3 -489.4,-2.8 -505.5,-2.7 -9.1,0.1 -18.5,0.5 -20.9,1ZM838.1,787.9c6.5,1.2 8.4,2.3 8.4,4.9s-2.3,3.1 -11.3,1.8c-12.3,-1.6 -35.6,-0.4 -50.4,2.7 -14.5,3 -33.7,9.1 -43.7,13.9 -7.3,3.5 -16.6,10 -16.6,11.6 0,0.5 1.7,1 3.8,1.2 3.2,0.3 3.7,0.6 3.7,2.8s-0.4,2.5 -5.8,2.8c-7,0.4 -8.7,-0.8 -8.7,-5.6 0,-10.9 30.8,-26.7 65.5,-33.7 6.6,-1.3 13.6,-2.6 15.5,-2.8 7.9,-0.9 34,-0.6 39.6,0.4ZM1063,791.2c35.7,7.5 65.5,22.9 65.5,33.8 0,4.8 -1.7,6 -8.7,5.6 -5.4,-0.3 -5.8,-0.5 -5.8,-2.8s0.5,-2.5 3.8,-2.8c2,-0.2 3.7,-0.7 3.7,-1.2 0,-1.6 -9.3,-8.1 -16.6,-11.6 -10,-4.8 -29.2,-10.9 -43.7,-13.9 -14.8,-3.1 -38.1,-4.3 -50.4,-2.7 -9,1.3 -11.3,0.9 -11.3,-1.8 0,-4.7 7.5,-6 32,-5.5 16.1,0.3 21.6,0.8 31.5,2.9ZM824.5,849.6c4.6,2.3 5.9,3.6 8.4,8.4 3.2,6.3 3.7,10.2 5.6,42.3 0.6,9.9 1.5,26.3 2.1,36.5 1.2,20.5 0.5,96.3 -1.1,122.6 -3.3,53.9 -5,74.5 -6.1,76.6 -0.5,1 -1.6,1.8 -2.5,1.8 -0.8,0 -4.1,0.9 -7.3,2 -17.1,5.7 -43.2,-0.5 -50.3,-12 -6,-9.7 -7.6,-27 -8.8,-97 -0.5,-27.2 -1.4,-56.5 -2.2,-65 -0.7,-8.5 -1.3,-29 -1.3,-45.5 0,-40.5 1.5,-46.7 14.4,-59.6 5.3,-5.2 8.2,-7.2 14.6,-9.9 10.4,-4.3 13.6,-5 22,-4.4 4.9,0.3 8.6,1.3 12.5,3.2ZM1055.2,851.5c7.3,3 10,4.8 15.4,10.2 12.9,12.9 14.4,19.1 14.4,59.6 0,16.5 -0.6,37 -1.3,45.5 -0.8,8.5 -1.7,37.8 -2.2,65 -1.2,70 -2.8,87.3 -8.8,97 -7.1,11.5 -33.2,17.7 -50.3,12 -3.2,-1.1 -6.5,-2 -7.3,-2 -0.9,0 -2,-0.8 -2.5,-1.8 -1.1,-2.1 -2.8,-22.7 -6.1,-76.6 -1.6,-26.3 -2.3,-102.1 -1.1,-122.6 4.3,-73.4 4.1,-71.4 7.5,-78.5 6.2,-12.6 23.2,-15.8 42.3,-7.8Z" android:strokeWidth="0"/>
<path android:fillColor="#2c2d3c" android:fillType="evenOdd" android:pathData="m1034.5,1359.6 l-215.8,316.5 -45.5,-31 238.6,-349.9c5.1,-7.5 13.6,-12 22.7,-12 9.1,0 17.6,4.5 22.8,12l239.1,349.3 -45.5,31.1 -216.3,-316ZM581.6,1675.8 L833.7,1306.1 788.2,1275 536.1,1644.8 581.6,1675.8Z" android:strokeWidth="0"/>
<path android:fillColor="#fff" android:pathData="M407.1,1220.2v-6.7h-23.2v-9c0,-11.1 -1.6,-21.9 -4.6,-32 -3.4,-45.9 -33.9,-84.2 -75.5,-99v-2.2c0,-10.3 -5,-19.4 -12.8,-25v-2c0,-10.6 -5.7,-19.8 -14.1,-24.8v-5.2c0,-6.9 -2.6,-13.1 -6.9,-17.8l-39.5,-206.2c-1.2,-6.3 -4.3,-11.7 -8.6,-15.7 -0.6,-15.7 -13.5,-28.2 -29.4,-28.2 -15.7,0 -28.5,12.3 -29.3,27.8 -5,5.3 -8.1,12.4 -8.1,20.2v3.5c-5,6.1 -8.1,14 -8.1,22.5v146.3c0,8 2.7,15.4 7.2,21.4h-10.1c-6.1,-10.3 -17.3,-17.1 -30,-17.1h-10.1c-14,0 -25.5,10.3 -27.6,23.6 -16.1,8.1 -28.2,22.9 -32.4,40.8 -9.4,8.6 -16.5,19.5 -20.6,31.8 -11.7,13.5 -18.7,31 -18.7,50.3v33c0,18.7 10.9,34.8 26.8,42.2 6.9,11.3 18.9,19 32.8,20 8.6,8.6 20.6,14 33.8,14h0.6c0.1,18.4 10.3,34.4 25.3,42.7v3l-31.5,15.8c-3.2,1.6 -4.5,5.5 -2.9,8.8l27.1,54.2c2.7,5.4 8.2,8.5 13.9,8.3 7.1,12.5 19.2,21.8 33.6,24.9 4.3,8.2 12.9,13.8 22.8,14l46,0.9c12.5,0.3 23.9,-5.1 31.7,-13.7h2.9c19.9,0 38.8,-4.2 55.9,-11.6 57.4,-6.5 102,-55.3 102,-114.4v-12.1c0,-12.3 -7.5,-22.8 -18.2,-27.2Z" android:strokeWidth="0"/>
<path android:fillColor="#2c2d3c" android:pathData="M180,744.2c-2.7,1.3 -7.1,4.6 -9.7,7.3 -9.1,9.9 -19.7,35.8 -25.5,63l-2.8,12.8 0.1,49.5c0,52.1 1.1,75.2 4.5,94.8 1,6.3 1.9,11.9 1.9,12.4 0,0.6 -3.7,-2.5 -8.3,-6.9 -11.1,-10.6 -15.7,-12.8 -26.7,-12.7 -9.8,0.1 -14.6,1.8 -24.4,8.8 -13,9.3 -34.8,35.8 -54.7,66.6 -19,29.4 -26.9,47.1 -32,71.5 -3.4,16.1 -3.2,36.1 0.5,50 3.2,12.1 8.1,22.9 13.1,28.3 2,2.3 7.1,6.3 11.3,9 10.8,6.9 43,22.9 53.6,26.6l8.8,3.1 7.2,12.8c9,16.1 14.1,22.7 21.1,27.4l5.6,3.7 -4.3,1.6c-26.9,10.4 -28.9,11.3 -30.8,14 -1.1,1.6 -2,3.7 -2,4.7 0,2.4 13.9,34.6 18.7,43.3 7.2,13.2 15.8,23.2 29.7,34.7 16.4,13.7 26.1,20.2 39,26.4 13.9,6.6 25.5,9.3 36.6,8.4 12.4,-1 22.4,-3.1 38.5,-8.1 7.9,-2.4 19.1,-5.5 24.9,-6.9 5.8,-1.4 14.7,-3.8 19.9,-5.5 5.1,-1.6 18,-5.2 28.5,-8 30.2,-7.9 59.5,-19.7 70.7,-28.7 6.4,-5.1 16.6,-19.3 22.2,-31 2.7,-5.6 6,-15.4 8.6,-25 4.1,-15.4 4.2,-16.2 4.2,-31.8 0,-17.1 -1.1,-23.6 -5.1,-31.6 -5.5,-11 -13.1,-16.3 -23.4,-16.4 -3.6,0 -8,0.3 -9.8,0.7 -3.1,0.7 -3.2,0.7 -2.5,-2 0.5,-1.5 0.8,-7.7 0.7,-13.7 0,-9.7 -0.4,-12.4 -3.8,-23.5 -11.8,-39.3 -19.9,-57.4 -34.4,-76.7 -5.3,-7.1 -7.6,-8.8 -10.3,-7.8 -0.8,0.3 -2.6,-0.3 -4,-1.2 -17.5,-11.7 -23,-17.7 -35.4,-38.4 -22.6,-38.2 -34.7,-77 -44.4,-143.4 -1.7,-11.6 -4.2,-28.7 -5.6,-38 -1.4,-9.4 -3.2,-21.6 -4,-27.2 -1.5,-10.9 -3,-17.4 -10.1,-43.3 -4.9,-17.7 -6.8,-22.3 -14.2,-33.4 -12.9,-19.5 -16.9,-22.6 -29.3,-22.6 -5.7,0 -8.6,0.6 -12.4,2.4ZM200.1,749.7c4.4,2 8.2,6.5 16.6,19.7 6.8,10.5 7.4,12.1 11.7,27.1 7,24.4 10.2,38.5 12.1,52.8 1,7.7 2.5,18 3.4,23 0.8,4.9 1.8,11.7 2.1,15 1.1,10.6 7.4,49 10.1,61.7 6.9,33.1 15.3,59.9 24.9,79.7 6.6,13.4 16.5,31.1 19.3,34.3 1.9,2.2 1.8,2.2 -4.6,-0.2 -7.7,-3 -21,-2.8 -27.7,0.4 -9.8,4.6 -18.9,16.8 -23.6,31.6 -3.8,12.3 -4.7,20.2 -3.7,32.3 1,12.7 3,21.2 7.2,30.2 7.3,15.7 16.1,25.9 30.2,35.2 22.4,14.5 47.8,22.4 82.1,25.5l13.7,1.2 -4.3,1.8c-2.3,1 -9.9,2.9 -16.9,4.4 -7,1.5 -20.3,5.3 -29.5,8.5 -29.2,10.1 -50.1,14.5 -99.7,20.9 -28.9,3.7 -62.9,9.1 -78.5,12.4 -8.3,1.7 -11.4,1.2 -19.9,-3.3 -8.1,-4.3 -16.2,-14.3 -23.5,-28.9l-3.1,-6.2h8.2c4.6,0 12.8,-0.7 18.2,-1.5 5.5,-0.8 11.6,-1.5 13.7,-1.5 6.1,0 18.1,-4.3 26.1,-9.4 4.2,-2.7 13.4,-10.5 21.5,-18.4 15.9,-15.4 18.4,-19.4 23.9,-38.2 3.9,-13.5 3.8,-22.5 -0.3,-35 -1.7,-5.2 -4.8,-15.1 -6.8,-22 -11.3,-38.1 -20.2,-61.5 -30.5,-80.3 -3.7,-6.8 -7,-13.5 -7.3,-14.9 -0.4,-1.4 -2.2,-5.1 -4.1,-8.2 -8.9,-14.9 -12.5,-50.6 -12.9,-128.4 -0.1,-34.8 0.1,-38.6 2,-49.2 5.5,-28.9 16.4,-57.2 26,-66.7 6.7,-6.7 16.2,-8.9 23.9,-5.4ZM124.2,972.9c2.5,1.2 9.2,6.6 15.1,12 9,8.4 11.4,11.3 15.2,18.4 2.5,4.6 6.1,11.2 8.1,14.7 7.3,12.8 9.2,16.5 13.5,26.9 8.3,20.2 30.1,88.8 29,91.4 -0.7,1.9 -3.2,1.9 -13.9,0 -19.4,-3.4 -34.8,-11.1 -53.2,-26.5 -22.6,-19.1 -38.4,-35.1 -53.4,-54.5 -8.7,-11.1 -23.7,-33.2 -25,-36.7 -1.6,-4.3 23.2,-33.2 35.5,-41.3 10.2,-6.8 20.9,-8.4 29.1,-4.4ZM64.5,1038.3c18.6,28 33.5,45.1 59.5,68.3 17.3,15.5 25.2,21.1 38,27.3 12.8,6.1 22.5,8.8 34.8,9.6 8.2,0.5 9.7,0.9 9.7,2.3 0,2.5 -5.9,21.7 -8.5,27.5 -2.4,5.6 -10.9,15.2 -19.9,22.7l-5.8,4.9 -8.2,-0.6c-14.7,-1.2 -43.1,-12.1 -64,-24.5 -33.2,-19.8 -62.4,-49.6 -77.6,-79.3 -2.7,-5.4 -5,-10.5 -5,-11.3 0,-5.6 35,-63.9 37.1,-61.7 0.3,0.5 4.8,7.1 9.9,14.8ZM295.6,1070c5.3,2.4 22.2,13.2 33.5,21.3 3.1,2.2 6.6,3.8 8.5,4 3.1,0.2 4.1,1.2 10.2,10.1 11.9,17.3 19,34.1 29.6,69.9 4,13.7 5.2,21.9 4.2,28.2 -1.3,8.5 -1.6,8.7 -16.1,8 -40.6,-1.9 -82,-17.7 -99.4,-37.8 -5,-5.9 -11.1,-16.4 -14.2,-24.6 -3.3,-8.8 -5.7,-24.9 -5,-33.8 0.6,-8.7 5.8,-25.5 9.7,-31.6 3.9,-6.2 13,-14.7 16.5,-15.4 1.6,-0.4 3.4,-0.8 3.9,-1.1 3,-1.1 13.5,0.4 18.6,2.8ZM27,1116.6c16.2,24.3 40.8,47.5 67.1,63.5 22.1,13.5 49.9,24.7 65.5,26.5l6.4,0.8 -4.5,2.9c-6.7,4.3 -15.9,7.8 -21.5,8.1 -2.7,0.2 -9.2,1.1 -14.2,2 -6.8,1.2 -12.9,1.5 -23,1.2 -16.4,-0.6 -20.1,-1.7 -44.8,-14 -29.6,-14.8 -38,-21 -42.9,-31.8 -10,-22 -11.1,-45.9 -3.4,-74.3l2.1,-7.9 4.3,8.2c2.4,4.5 6.4,11.1 8.9,14.8ZM405.2,1219.9c1.5,0.5 4.3,2.5 6.1,4.2 7.3,6.9 10.2,17.1 10.2,35.8 0,10.9 -0.4,14.3 -3.4,26.5 -1.9,7.7 -4.7,17.2 -6.1,21 -5.1,13.4 -16.7,30.6 -24.8,36.5 -11.8,8.6 -40.4,19.8 -69,26.9 -6.7,1.7 -17.8,4.9 -24.7,7 -6.9,2.2 -17.4,5.1 -23.5,6.5 -6,1.4 -15.7,4.1 -21.5,6 -14.3,4.8 -26.7,7.4 -38,8.1 -8,0.6 -10.8,0.3 -17.5,-1.5 -15.3,-4.2 -33.7,-14.7 -51.3,-29.3 -16.7,-14 -22.8,-21.2 -31.4,-36.8 -5.1,-9.3 -17.1,-37.9 -16.5,-39.4s30.2,-12.6 39.7,-15c17,-4.1 61,-11.4 95.5,-15.6 43.6,-5.4 62.3,-9.3 93.5,-19.6 12.4,-4 27.7,-8.4 34,-9.8 6.3,-1.3 14.4,-3.6 18,-5.1 18.2,-7.7 24.1,-8.9 30.7,-6.4Z" android:strokeWidth="0"/>
<path android:fillColor="#fff" android:pathData="M1819.8,966.4c-25.9,-6.6 -55.4,12.3 -80.2,47.2 -7.2,2.1 -13.9,6.5 -18.7,13l-62.7,83.5c-4,5.3 -6.6,11.1 -8,17.1l-5.4,1.6c-13.4,3.9 -23.7,13.3 -29.3,24.9 -5.7,2.5 -10.7,6.2 -14.8,10.6 -3.1,-3.5 -6.9,-6.3 -11,-8.4l-8.6,-29.3c-2.4,-8.3 -7.4,-15.1 -13.8,-20 -1.3,-3 -3,-5.9 -4.9,-8.4l-1.8,-6.3c-5.5,-18.9 -25.3,-29.7 -44.2,-24.2l-1.2,0.3c-17,5 -27.5,21.6 -25.3,38.7 -2.3,3.8 -4,7.9 -5.2,12.1 -6.9,11 -9.3,24.8 -5.3,38.3l4.3,14.6c-2.8,8.7 -3.2,18.3 -0.4,27.8l43.8,149.4 -4.6,-1.5c-5.4,-1.8 -11,-1.8 -16.1,-0.2l-5.2,-1.7c-6.7,-2.2 -14,1.4 -16.2,8.2 -2,5.9 -1.1,12.1 1.9,17 -2.3,10.6 -0.4,21.2 4.6,30.1 1.2,12 7.4,23.1 17.2,30.4 5,12.3 15.2,22.3 28.7,26.8l6.3,2.1c4.1,7 10.8,12.7 19.1,15.4l20.5,6.8 1.1,3.7 5.4,-1.6 15.5,5.1 1.1,3.7 5.4,-1.6 78.8,26c6.9,2.3 14,2.2 20.5,0.2l18.2,6c18.8,6.2 39.1,-4 45.3,-22.8 6.2,-18.8 -4,-39.1 -22.8,-45.3l-4.4,-1.5c-1.7,-4.4 -4.3,-8.5 -7.7,-12l13.5,-4 -0.5,-1.7c10.2,-4.2 17.6,-12.7 20.8,-22.7l4.6,3.8 41.3,-50.6c3.2,-3.9 5.3,-8.3 6.3,-12.9 25.3,-12.2 42.7,-38.1 42.7,-68.1v-186.1c5.9,-69.1 -12.5,-124.6 -48.5,-133.7Z" android:strokeWidth="0"/>
<path android:fillColor="#2c2d3c" android:pathData="M1797,960.2c-13.1,3.3 -25.8,12.9 -52.1,39.1 -30.1,30 -54.1,58.2 -80.5,94.5 -15.4,21.1 -16.2,22.5 -15.6,25.7 0.5,2.7 0.2,2.9 -5.5,4.7 -3.3,1 -7.2,2.9 -8.7,4 -1.4,1.2 -3.9,3 -5.6,4 -7.2,4.5 -16.8,13.3 -22.5,20.7 -5.2,6.7 -6.5,7.8 -7.6,6.7 -2.9,-3 -12.7,-23.3 -17.8,-37.1 -3,-8.1 -7.4,-18.3 -9.7,-22.7 -7.7,-14.4 -23.3,-30.5 -34.8,-35.8 -7.3,-3.4 -18,-2.9 -26.1,1.3 -7.9,4 -17.1,13.8 -21.3,22.7 -8.5,17.7 -11.2,34 -11.1,66.8 0,24.7 1.1,36.1 6,62.9 5.7,31.1 21.6,85.9 33.3,114.6 2.2,5.4 3.8,10.1 3.6,10.3s-5.8,-0.1 -12.4,-0.7c-14.8,-1.5 -18.2,-0.9 -22,3.5 -6.6,7.5 -6.7,17.7 -0.6,51.3 1.2,6.6 2.8,10.9 7.1,19 3.1,5.8 6.4,11.7 7.3,13.1s2.3,3.8 3.1,5.4 4.9,6.3 9,10.5c6.7,6.8 10.1,9.1 32.5,21.8 32.7,18.6 41.4,22.6 69.5,32.1 41.9,14.1 68.2,21 94.5,24.6 6.1,0.8 16.3,2.4 22.8,3.6 20.4,3.5 26.1,2.6 34.3,-5.1 2.8,-2.8 6.3,-6.7 7.7,-8.7 3.8,-5.5 6.8,-15.6 7,-23.3 0.2,-5.7 -0.2,-7.2 -2.1,-9.6 -1.2,-1.5 -4.4,-5.7 -7,-9.3 -8.5,-11.8 -35.6,-37.2 -44.4,-41.6 -7.5,-3.8 -22.4,-9 -33.6,-11.8 -10.4,-2.6 -27.5,-9.1 -46.7,-17.9 -43.6,-19.9 -57.9,-26.8 -76.7,-36.6 -11.6,-6.2 -23.4,-11.9 -26.2,-12.8s-5.5,-2 -6,-2.4c-0.6,-0.5 -1.8,-0.9 -2.8,-0.9s-1.8,-0.5 -1.8,-1.1 -1.6,-2.5 -3.5,-4.4c-4,-3.7 -8,-13.2 -16.3,-38.5 -25.9,-79.2 -34.5,-139.3 -26.7,-185 4,-23.4 13.7,-40 27.2,-46.7 11.6,-5.6 20.3,-3.1 33.6,9.6s18.2,20.7 26.2,42.6c5.2,14.3 17.1,38.5 21.8,44.3 4,5 16.6,30.8 21.1,43.4 9.7,26.8 16.6,65.4 16.6,92.4v13.4h2.4c1.9,0 2.7,-0.8 3.7,-3.7 2.5,-7.8 -0.7,-41.8 -6.8,-70.8 -4.5,-21.7 -12.8,-44.4 -23.8,-65l-5.4,-10 2.7,-3.1c1.5,-1.7 3.9,-4.7 5.4,-6.6 1.5,-1.9 6,-6.1 10.2,-9.2 4.1,-3.1 7.7,-6.4 8,-7.4 0.9,-2.6 10.3,-8 19.3,-11 12.9,-4.2 20.3,-3.7 39.3,2.8 8.5,2.9 18.3,6.6 21.8,8.2 6.9,3.3 9.6,3.3 10,0.2 0.4,-2.6 -5,-6.9 -11.3,-9.1 -2.5,-0.8 -4.7,-1.9 -5.1,-2.4 -0.3,-0.5 -1.6,-0.9 -2.9,-0.9s-4.8,-0.9 -7.7,-2c-11.8,-4.3 -21.7,-6.2 -30.8,-5.8l-8.8,0.3 1,-2.4c1.1,-3.1 26.2,-37 39.2,-53 16.1,-19.8 40.9,-47.2 59.2,-65.2 34.3,-33.6 47,-39.6 65.4,-30.8 10.3,4.9 16.8,11.2 25.6,24.6 13.3,20.1 15.3,26.3 19.9,61.4l0.5,3.5 -14.3,3.9c-26.3,7 -48.8,16 -84.2,33.6 -24.9,12.4 -33,17.4 -33,20.4 0,3.5 4.1,2.9 12.3,-1.8 20.3,-11.7 60.9,-30.7 81.9,-38.3 13.4,-4.9 36.3,-11.5 37.4,-10.8 1,0.6 1.2,89.5 0.3,93.1 -0.4,1.6 -1.7,2.3 -4.7,2.8 -14.3,2.5 -43.6,18.3 -57.9,31.4 -6.7,6.1 -8.6,9.4 -6.8,11.6 0.7,0.8 1.6,1.5 2.1,1.5s3.7,-2.7 7,-6c11.7,-11.3 37.2,-26.7 51.7,-31 4,-1.2 7.5,-2.1 7.6,-1.9 0.4,0.4 1,78.5 0.8,89.4l-0.2,7 -5.2,1.9c-7.2,2.5 -19.6,9.4 -20.7,11.4 -2,4 2.3,5.7 6.8,2.6 1.2,-0.9 5.7,-3.4 9.9,-5.5l7.7,-3.9 0.3,12.3c0.1,6.7 -0.1,17.2 -0.7,23.3 -0.7,9.4 -1.3,12 -3.7,16 -6,10.6 -16.7,21.8 -24.9,26.1 -2,1.1 -2,0.7 -2.3,-13.5 -0.3,-15.1 -1.1,-18 -4.4,-16.7 -1.4,0.5 -1.7,3.1 -2,15.3 -0.4,13.5 -0.6,15.3 -3.3,21.7 -3.7,8.8 -18.4,28.6 -28.4,38 -4,3.8 -9.4,9.2 -11.9,12 -9.2,10 -16,16.1 -25,22.5 -6.8,4.7 -9.2,7 -9.2,8.6 0,2.9 2.5,3.7 5.9,1.9 6.1,-3.1 23.6,-17.7 29.6,-24.6 3.5,-4.1 9.4,-10.1 13.1,-13.4 8.2,-7.2 24.8,-27.9 28.9,-35.8 2.8,-5.4 3.6,-6.1 10.5,-9.6 4.1,-2 8.7,-4.5 10.2,-5.5 4.3,-2.9 16.4,-18 19.7,-24.8 2.9,-5.9 3.2,-7.6 4.3,-22.8 0.6,-9.1 1.2,-44.9 1.3,-79.5 0.1,-34.7 0.4,-84.4 0.6,-110.5 0.3,-50.6 -0.2,-59.6 -5.2,-84.4 -2.9,-14.5 -11.3,-31 -23.8,-46.9 -11.9,-15.3 -31.5,-24 -45.6,-20.3ZM1510,1348.8c6.1,0.6 13.3,1.7 16,2.5 2.8,0.8 7.5,2.1 10.5,3 3,0.8 16.3,7.1 29.5,14 13.2,7 30.1,15.3 37.5,18.7 7.4,3.3 20.9,9.5 30,13.8 22.9,10.8 46.9,20.5 57,22.8 9.6,2.3 27.5,8.4 33.1,11.3 7.5,3.8 36.6,30.4 39.5,36.2 0.8,1.5 3.7,5.2 6.4,8.3 6,6.5 6.4,9.6 2.5,21.3 -2.1,6.3 -3.6,8.6 -8.2,13.5 -6.5,6.7 -8.4,7.6 -15.6,7.6 -12.4,0 -68.1,-10.2 -90.2,-16.6 -28.9,-8.3 -65.5,-21.3 -79,-27.9 -13.3,-6.5 -40.2,-21.3 -51,-28 -7.7,-4.8 -16.5,-15.2 -24.2,-28.5 -10.7,-18.5 -10.8,-18.9 -14.9,-47.9 -1.7,-11.8 -0.8,-18.9 2.8,-22.8 2.4,-2.6 3.3,-2.7 18.3,-1.3Z" android:strokeWidth="0"/>
</vector>

View 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)
}

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,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
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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."))
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -145,6 +145,7 @@ markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-rend
material = { module = "com.google.android.material:material", version = "1.13.0" }
mgrs = { module = "mil.nga:mgrs", version = "2.1.3" }
nordic = { module = "no.nordicsemi.kotlin.ble:client-android", version = "2.0.0-alpha11" }
nordic-dfu = { module = "no.nordicsemi.android:dfu", version = "2.10.1" }
org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" }

View File

@@ -36,6 +36,7 @@ include(
":feature:map",
":feature:node",
":feature:settings",
":feature:firmware",
":mesh_service_example",
)
rootProject.name = "MeshtasticAndroid"