mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2025-12-24 00:07:48 -05: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:
@@ -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)
|
||||
|
||||
@@ -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$<no name provided>${ }</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.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${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.<no name provided>$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>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,3 +150,9 @@ object SettingsRoutes {
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
object FirmwareRoutes {
|
||||
@Serializable data object FirmwareGraph : Graph
|
||||
|
||||
@Serializable data object FirmwareUpdate : Route
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
23
core/ui/src/main/res/drawable/chirpy.xml
Normal file
23
core/ui/src/main/res/drawable/chirpy.xml
Normal 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>
|
||||
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,
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -36,6 +36,7 @@ include(
|
||||
":feature:map",
|
||||
":feature:node",
|
||||
":feature:settings",
|
||||
":feature:firmware",
|
||||
":mesh_service_example",
|
||||
)
|
||||
rootProject.name = "MeshtasticAndroid"
|
||||
|
||||
Reference in New Issue
Block a user