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

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

View file

@ -14,54 +14,77 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.kover)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kmp.library.compose)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.meshtastic.koin)
}
configure<LibraryExtension> { namespace = "org.meshtastic.feature.firmware" }
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.firmware"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
dependencies {
implementation(projects.core.ble)
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.network)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.resources)
implementation(projects.core.ui)
sourceSets {
commonMain.dependencies {
implementation(projects.core.ble)
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.network)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.resources)
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.navigation.compose)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kermit)
implementation(libs.ktor.client.core)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kermit)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.ktor.client.core)
}
implementation(libs.nordic.client.android)
implementation(libs.nordic.dfu)
implementation(libs.coil)
implementation(libs.coil.network.okhttp)
implementation(libs.markdown.renderer)
implementation(libs.markdown.renderer.m3)
androidMain.dependencies {
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.compose)
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.navigation.common)
implementation(libs.coil)
implementation(libs.coil.network.okhttp)
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.nordic.client.android.mock)
testImplementation(libs.nordic.client.core.mock)
testImplementation(libs.nordic.core.mock)
testImplementation(libs.mockk)
// DFU / Nordic specific dependencies
implementation(libs.nordic.client.android)
implementation(libs.nordic.dfu)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.mockk)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.test.ext.junit)
implementation(libs.nordic.client.android.mock)
implementation(libs.nordic.client.core.mock)
implementation(libs.nordic.core.mock)
}
}
}

View file

@ -17,9 +17,7 @@
package org.meshtastic.feature.firmware
import android.content.Context
import android.net.Uri
import co.touchlab.kermit.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.head
@ -31,14 +29,15 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileInputStream
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 DOWNLOAD_BUFFER_SIZE = 8192
@ -46,15 +45,11 @@ private const val DOWNLOAD_BUFFER_SIZE = 8192
* Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and
* extracting specific files from Zip archives.
*/
class FirmwareFileHandler
@Inject
constructor(
@ApplicationContext private val context: Context,
private val client: HttpClient,
) {
@Single
class AndroidFirmwareFileHandler(private val context: Context, private val client: HttpClient) : FirmwareFileHandler {
private val tempDir = File(context.cacheDir, "firmware_update")
fun cleanupAllTemporaryFiles() {
override fun cleanupAllTemporaryFiles() {
runCatching {
if (tempDir.exists()) {
tempDir.deleteRecursively()
@ -64,7 +59,7 @@ constructor(
.onFailure { e -> Logger.w(e) { "Failed to cleanup temp directory" } }
}
suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
override suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
try {
client.head(url).status.isSuccess()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
@ -73,7 +68,7 @@ constructor(
}
}
suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): File? =
override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): String? =
withContext(Dispatchers.IO) {
val response =
try {
@ -93,10 +88,10 @@ constructor(
if (!tempDir.exists()) tempDir.mkdirs()
val targetFile = File(tempDir, fileName)
val targetFile = java.io.File(tempDir, fileName)
body.toInputStream().use { input ->
FileOutputStream(targetFile).use { output ->
java.io.FileOutputStream(targetFile).use { output ->
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead = 0L
@ -116,15 +111,16 @@ constructor(
}
}
}
targetFile
targetFile.absolutePath
}
suspend fun extractFirmware(
zipFile: File,
override suspend fun extractFirmwareFromZip(
zipFilePath: String,
hardware: DeviceHardware,
fileExtension: String,
preferredFilename: String? = null,
): File? = withContext(Dispatchers.IO) {
preferredFilename: String?,
): String? = withContext(Dispatchers.IO) {
val zipFile = java.io.File(zipFilePath)
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
if (target.isEmpty() && preferredFilename == null) return@withContext null
@ -153,21 +149,21 @@ constructor(
matchingEntries.add(entry to outFile)
if (preferredFilenameLower != null) {
return@withContext outFile
return@withContext outFile.absolutePath
}
}
entry = zipInput.nextEntry
}
}
matchingEntries.minByOrNull { it.first.name.length }?.second
matchingEntries.minByOrNull { it.first.name.length }?.second?.absolutePath
}
suspend fun extractFirmware(
uri: Uri,
override suspend fun extractFirmware(
uri: CommonUri,
hardware: DeviceHardware,
fileExtension: String,
preferredFilename: String? = null,
): File? = withContext(Dispatchers.IO) {
preferredFilename: String?,
): String? = withContext(Dispatchers.IO) {
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
if (target.isEmpty() && preferredFilename == null) return@withContext null
@ -178,7 +174,8 @@ constructor(
if (!tempDir.exists()) tempDir.mkdirs()
try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
val platformUri = uri.toPlatformUri() as android.net.Uri
val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null
ZipInputStream(inputStream).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
@ -198,7 +195,7 @@ constructor(
matchingEntries.add(entry to outFile)
if (preferredFilenameLower != null) {
return@withContext outFile
return@withContext outFile.absolutePath
}
}
entry = zipInput.nextEntry
@ -208,7 +205,17 @@ constructor(
Logger.w(e) { "Failed to extract firmware from URI" }
return@withContext null
}
matchingEntries.minByOrNull { it.first.name.length }?.second
matchingEntries.minByOrNull { it.first.name.length }?.second?.absolutePath
}
override suspend fun getFileSize(path: String): Long = withContext(Dispatchers.IO) {
val file = File(path)
if (file.exists()) file.length() else 0L
}
override suspend fun deleteFile(path: String) = withContext(Dispatchers.IO) {
val file = File(path)
if (file.exists()) file.delete()
}
private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
@ -218,22 +225,25 @@ constructor(
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
}
suspend fun copyFileToUri(sourceFile: File, destinationUri: Uri) = withContext(Dispatchers.IO) {
val inputStream = FileInputStream(sourceFile)
val outputStream =
context.contentResolver.openOutputStream(destinationUri)
?: throw IOException("Cannot open content URI for writing")
override suspend fun copyFileToUri(sourcePath: String, destinationUri: CommonUri): Long =
withContext(Dispatchers.IO) {
val inputStream = java.io.FileInputStream(java.io.File(sourcePath))
val outputStream =
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
?: throw IOException("Cannot open content URI for writing")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
suspend fun copyUriToUri(sourceUri: Uri, destinationUri: Uri) = withContext(Dispatchers.IO) {
val inputStream =
context.contentResolver.openInputStream(sourceUri) ?: throw IOException("Cannot open source URI")
val outputStream =
context.contentResolver.openOutputStream(destinationUri)
?: throw IOException("Cannot open destination URI")
override suspend fun copyUriToUri(sourceUri: CommonUri, destinationUri: CommonUri): Long =
withContext(Dispatchers.IO) {
val inputStream =
context.contentResolver.openInputStream(sourceUri.toPlatformUri() as android.net.Uri)
?: throw IOException("Cannot open source URI")
val outputStream =
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
?: throw IOException("Cannot open destination URI")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
}

View file

@ -16,8 +16,9 @@
*/
package org.meshtastic.feature.firmware
import android.net.Uri
import kotlinx.coroutines.flow.Flow
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.RadioPrefs
@ -25,29 +26,24 @@ import org.meshtastic.core.repository.isBle
import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/** Orchestrates the firmware update process by choosing the correct handler. */
@Singleton
class FirmwareUpdateManager
@Inject
constructor(
@Single
class AndroidFirmwareUpdateManager(
private val radioPrefs: RadioPrefs,
private val nordicDfuHandler: NordicDfuHandler,
private val usbUpdateHandler: UsbUpdateHandler,
private val esp32OtaUpdateHandler: Esp32OtaUpdateHandler,
) {
) : FirmwareUpdateManager {
/** Start the update process based on the current connection and hardware. */
suspend fun startUpdate(
override suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri? = null,
): File? {
firmwareUri: CommonUri?,
): String? {
val handler = getHandler(hardware)
val target = getTarget(address)
@ -60,7 +56,7 @@ constructor(
)
}
fun dfuProgressFlow(): Flow<DfuInternalState> = nordicDfuHandler.progressFlow()
override fun dfuProgressFlow(): Flow<DfuInternalState> = nordicDfuHandler.progressFlow()
private fun getHandler(hardware: DeviceHardware): FirmwareUpdateHandler = when {
radioPrefs.isSerial() -> {

View file

@ -23,18 +23,16 @@ import android.content.IntentFilter
import android.hardware.usb.UsbManager
import android.os.Build
import co.touchlab.kermit.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
import javax.inject.Singleton
import org.koin.core.annotation.Single
/** Manages USB-related interactions for firmware updates. */
@Singleton
class UsbManager @Inject constructor(@ApplicationContext private val context: Context) {
@Single
class AndroidFirmwareUsbManager(private val context: Context) : FirmwareUsbManager {
/** Observe when a USB device is detached. */
fun deviceDetachFlow(): Flow<Unit> = callbackFlow {
override fun deviceDetachFlow(): Flow<Unit> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {

View file

@ -50,11 +50,13 @@ class FirmwareDfuService : DfuBaseService() {
}
override fun getNotificationTarget(): Class<out Activity>? = try {
// Best effort to find the main activity
// Best effort to find the main activity dynamically
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: "org.meshtastic.app.MainActivity"
@Suppress("UNCHECKED_CAST")
Class.forName("com.geeksville.mesh.MainActivity") as Class<out Activity>
} catch (_: ClassNotFoundException) {
null
Class.forName(className) as Class<out Activity>
} catch (_: Exception) {
Activity::class.java
}
override fun isDebug(): Boolean = isDebugFlag

View file

@ -17,18 +17,18 @@
package org.meshtastic.feature.firmware
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import javax.inject.Inject
/** Retrieves firmware files, either by direct download or by extracting from a release asset. */
class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFileHandler) {
@Single
class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
suspend fun retrieveOtaFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): File? = retrieve(
): String? = retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
@ -40,7 +40,7 @@ class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFil
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): File? = retrieve(
): String? = retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
@ -52,7 +52,7 @@ class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFil
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): File? {
): String? {
val mcu = hardware.architecture.replace("-", "")
val otaFilename = "mt-$mcu-ota.bin"
retrieve(
@ -84,7 +84,7 @@ class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFil
fileSuffix: String,
internalFileExtension: String,
preferredFilename: String? = null,
): File? {
): String? {
val version = release.id.removePrefix("v")
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
val filename = preferredFilename ?: "firmware-$target-$version$fileSuffix"
@ -105,7 +105,7 @@ class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFil
val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture)
val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress)
return downloadedZip?.let {
fileHandler.extractFirmware(it, hardware, internalFileExtension, preferredFilename)
fileHandler.extractFirmwareFromZip(it, hardware, internalFileExtension, preferredFilename)
}
}

View file

@ -79,15 +79,14 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.mikepenz.markdown.m3.Markdown
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.model.DeviceHardware
@ -153,11 +152,7 @@ private const val CYCLE_DELAY_MS = 4500L
@Composable
@Suppress("LongMethod")
fun FirmwareUpdateScreen(
navController: NavController,
modifier: Modifier = Modifier,
viewModel: FirmwareUpdateViewModel = hiltViewModel(),
) {
fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateViewModel, modifier: Modifier = Modifier) {
val state by viewModel.state.collectAsStateWithLifecycle()
val selectedReleaseType by viewModel.selectedReleaseType.collectAsStateWithLifecycle()
val deviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle()
@ -165,21 +160,19 @@ fun FirmwareUpdateScreen(
val selectedRelease by viewModel.selectedRelease.collectAsStateWithLifecycle()
var showExitConfirmation by remember { mutableStateOf(false) }
val getFileLauncher =
val filePickerLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let { viewModel.startUpdateFromFile(it) }
uri?.let { viewModel.startUpdateFromFile(CommonUri(it)) }
}
val saveFileLauncher =
val createDocumentLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri: Uri? ->
uri?.let { viewModel.saveDfuFile(it) }
uri?.let { viewModel.saveDfuFile(CommonUri(it)) }
}
val actions =
remember(viewModel, navController, state) {
remember(viewModel, onNavigateUp, state) {
FirmwareUpdateActions(
onReleaseTypeSelect = viewModel::setReleaseType,
onStartUpdate = viewModel::startUpdate,
@ -190,16 +183,16 @@ fun FirmwareUpdateScreen(
readyState.updateMethod is FirmwareUpdateMethod.Ble ||
readyState.updateMethod is FirmwareUpdateMethod.Wifi
) {
getFileLauncher.launch("*/*")
filePickerLauncher.launch("*/*")
} else if (readyState.updateMethod is FirmwareUpdateMethod.Usb) {
getFileLauncher.launch("*/*")
filePickerLauncher.launch("*/*")
}
}
},
onSaveFile = { fileName -> saveFileLauncher.launch(fileName) },
onSaveFile = { fileName -> createDocumentLauncher.launch(fileName) },
onRetry = viewModel::checkForUpdates,
onCancel = { showExitConfirmation = true },
onDone = { navController.navigateUp() },
onDone = { onNavigateUp() },
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
)
}
@ -217,7 +210,7 @@ fun FirmwareUpdateScreen(
onConfirm = {
showExitConfirmation = false
viewModel.cancelUpdate()
navController.navigateUp()
onNavigateUp()
},
dismissText = stringResource(Res.string.back),
)
@ -225,7 +218,7 @@ fun FirmwareUpdateScreen(
FirmwareUpdateScaffold(
modifier = modifier,
navController = navController,
onNavigateUp = onNavigateUp,
state = state,
selectedReleaseType = selectedReleaseType,
actions = actions,
@ -237,7 +230,7 @@ fun FirmwareUpdateScreen(
@Composable
private fun FirmwareUpdateScaffold(
navController: NavController,
onNavigateUp: () -> Unit,
state: FirmwareUpdateState,
selectedReleaseType: FirmwareReleaseType,
actions: FirmwareUpdateActions,
@ -252,7 +245,7 @@ private fun FirmwareUpdateScaffold(
CenterAlignedTopAppBar(
title = { Text(stringResource(Res.string.firmware_update_title)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
IconButton(onClick = { onNavigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},

View file

@ -17,10 +17,8 @@
package org.meshtastic.feature.firmware
import android.content.Context
import android.net.Uri
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@ -31,6 +29,9 @@ import no.nordicsemi.android.dfu.DfuProgressListenerAdapter
import no.nordicsemi.android.dfu.DfuServiceInitiator
import no.nordicsemi.android.dfu.DfuServiceListenerHelper
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.RadioController
@ -39,8 +40,6 @@ import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_nordic_failed
import org.meshtastic.core.resources.firmware_update_not_found_in_release
import org.meshtastic.core.resources.firmware_update_starting_service
import java.io.File
import javax.inject.Inject
private const val SCAN_TIMEOUT = 5000L
private const val PACKETS_BEFORE_PRN = 8
@ -48,11 +47,10 @@ private const val PERCENT_MAX = 100
private const val PREPARE_DATA_DELAY = 400L
/** Handles Over-the-Air (OTA) firmware updates for nRF52-based devices using the Nordic DFU library. */
class NordicDfuHandler
@Inject
constructor(
@Single
class NordicDfuHandler(
private val firmwareRetriever: FirmwareRetriever,
@ApplicationContext private val context: Context,
private val context: Context,
private val radioController: RadioController,
) : FirmwareUpdateHandler {
@ -61,8 +59,8 @@ constructor(
hardware: DeviceHardware,
target: String, // Bluetooth address
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri?,
): File? =
firmwareUri: CommonUri?,
): String? =
try {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0)
@ -90,7 +88,7 @@ constructor(
updateState(FirmwareUpdateState.Error(errorMsg))
null
} else {
initiateDfu(target, hardware, Uri.fromFile(firmwareFile), updateState)
initiateDfu(target, hardware, CommonUri.parse("file://$firmwareFile"), updateState)
firmwareFile
}
}
@ -106,7 +104,7 @@ constructor(
private suspend fun initiateDfu(
address: String,
deviceHardware: DeviceHardware,
firmwareUri: Uri,
firmwareUri: CommonUri,
updateState: (FirmwareUpdateState) -> Unit,
) {
val startingMsg = getString(Res.string.firmware_update_starting_service)
@ -127,7 +125,7 @@ constructor(
.setPacketsReceiptNotificationsEnabled(true)
.setScanTimeout(SCAN_TIMEOUT)
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
.setZip(firmwareUri)
.setZip(firmwareUri.toPlatformUri() as android.net.Uri)
.start(context, FirmwareDfuService::class.java)
}
@ -215,36 +213,3 @@ constructor(
}
}
}
sealed interface DfuInternalState {
val address: String
data class Connecting(override val address: String) : DfuInternalState
data class Connected(override val address: String) : DfuInternalState
data class Starting(override val address: String) : DfuInternalState
data class EnablingDfuMode(override val address: String) : DfuInternalState
data class Progress(
override val address: String,
val percent: Int,
val speed: Float,
val avgSpeed: Float,
val currentPart: Int,
val partsTotal: Int,
) : DfuInternalState
data class Validating(override val address: String) : DfuInternalState
data class Disconnecting(override val address: String) : DfuInternalState
data class Disconnected(override val address: String) : DfuInternalState
data class Completed(override val address: String) : DfuInternalState
data class Aborted(override val address: String) : DfuInternalState
data class Error(override val address: String, val message: String?) : DfuInternalState
}

View file

@ -16,11 +16,12 @@
*/
package org.meshtastic.feature.firmware
import android.net.Uri
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.RadioController
@ -30,16 +31,13 @@ import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_rebooting
import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_usb_failed
import java.io.File
import javax.inject.Inject
private const val REBOOT_DELAY = 5000L
private const val PERCENT_MAX = 100
/** Handles firmware updates via USB Mass Storage (UF2). */
class UsbUpdateHandler
@Inject
constructor(
@Single
class UsbUpdateHandler(
private val firmwareRetriever: FirmwareRetriever,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
@ -50,8 +48,8 @@ constructor(
hardware: DeviceHardware,
target: String, // Unused for USB
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri?,
): File? =
firmwareUri: CommonUri?,
): String? =
try {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0)
@ -91,7 +89,7 @@ constructor(
radioController.rebootToDfu(myNodeNum)
delay(REBOOT_DELAY)
updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, firmwareFile.name))
updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, java.io.File(firmwareFile).name))
firmwareFile
}
}

View file

@ -17,9 +17,7 @@
package org.meshtastic.feature.firmware.ota
import android.content.Context
import android.net.Uri
import co.touchlab.kermit.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -27,9 +25,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.RadioController
@ -38,10 +39,10 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_connecting_attempt
import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_erasing
import org.meshtastic.core.resources.firmware_update_extracting
import org.meshtastic.core.resources.firmware_update_hash_rejected
import org.meshtastic.core.resources.firmware_update_loading
import org.meshtastic.core.resources.firmware_update_not_found_in_release
import org.meshtastic.core.resources.firmware_update_ota_failed
import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_starting_ota
import org.meshtastic.core.resources.firmware_update_uploading
import org.meshtastic.core.resources.firmware_update_waiting_reboot
@ -49,8 +50,6 @@ import org.meshtastic.feature.firmware.FirmwareRetriever
import org.meshtastic.feature.firmware.FirmwareUpdateHandler
import org.meshtastic.feature.firmware.FirmwareUpdateState
import org.meshtastic.feature.firmware.ProgressState
import java.io.File
import javax.inject.Inject
private const val RETRY_DELAY = 2000L
private const val PERCENT_MAX = 100
@ -68,15 +67,14 @@ private const val GATT_RELEASE_DELAY_MS = 1000L
* UnifiedOtaProtocol.
*/
@Suppress("TooManyFunctions")
class Esp32OtaUpdateHandler
@Inject
constructor(
@Single
class Esp32OtaUpdateHandler(
private val firmwareRetriever: FirmwareRetriever,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
private val bleScanner: BleScanner,
private val bleConnectionFactory: BleConnectionFactory,
@ApplicationContext private val context: Context,
private val context: Context,
) : FirmwareUpdateHandler {
/** Entry point for FirmwareUpdateHandler interface. Decides between BLE and WiFi based on target format. */
@ -85,8 +83,8 @@ constructor(
hardware: DeviceHardware,
target: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri?,
): File? = if (target.contains(":")) {
firmwareUri: CommonUri?,
): String? = if (target.contains(":")) {
startBleUpdate(release, hardware, target, updateState, firmwareUri)
} else {
startWifiUpdate(release, hardware, target, updateState, firmwareUri)
@ -97,8 +95,8 @@ constructor(
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri? = null,
): File? = performUpdate(
firmwareUri: CommonUri? = null,
): String? = performUpdate(
release = release,
hardware = hardware,
updateState = updateState,
@ -113,8 +111,8 @@ constructor(
hardware: DeviceHardware,
deviceIp: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri? = null,
): File? = performUpdate(
firmwareUri: CommonUri? = null,
): String? = performUpdate(
release = release,
hardware = hardware,
updateState = updateState,
@ -128,18 +126,18 @@ constructor(
release: FirmwareRelease,
hardware: DeviceHardware,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri?,
firmwareUri: CommonUri?,
transportFactory: () -> UnifiedOtaProtocol,
rebootMode: Int,
connectionAttempts: Int,
): File? = try {
): String? = try {
withContext(Dispatchers.IO) {
// Step 1: Get firmware file
val firmwareFile =
obtainFirmwareFile(release, hardware, firmwareUri, updateState) ?: return@withContext null
// Step 2: Calculate Hash and Trigger Reboot
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(firmwareFile)
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(java.io.File(firmwareFile))
val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes)
Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash" }
triggerRebootOta(rebootMode, sha256Bytes)
@ -180,11 +178,12 @@ constructor(
null
}
@Suppress("UnusedPrivateMember")
private suspend fun downloadFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
updateState: (FirmwareUpdateState) -> Unit,
): File? {
): String? {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0).replace(Regex(":?\\s*%1\\\$d%?"), "").trim()
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
@ -198,12 +197,14 @@ constructor(
}
}
private suspend fun getFirmwareFromUri(uri: Uri): File? = withContext(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin")
private suspend fun getFirmwareFromUri(uri: CommonUri): String? = withContext(Dispatchers.IO) {
val inputStream =
context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri)
?: return@withContext null
val tempFile = java.io.File(context.cacheDir, "firmware_update/ota_firmware.bin")
tempFile.parentFile?.mkdirs()
inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } }
tempFile
tempFile.absolutePath
}
private fun triggerRebootOta(mode: Int, hash: ByteArray?) {
@ -227,24 +228,37 @@ constructor(
private suspend fun obtainFirmwareFile(
release: FirmwareRelease,
hardware: DeviceHardware,
firmwareUri: Uri?,
firmwareUri: CommonUri?,
updateState: (FirmwareUpdateState) -> Unit,
): File? {
val firmwareFile =
if (firmwareUri != null) {
val loadingMsg = getString(Res.string.firmware_update_loading)
updateState(FirmwareUpdateState.Processing(ProgressState(loadingMsg)))
getFirmwareFromUri(firmwareUri)
} else {
downloadFirmware(release, hardware, updateState)
}
): String? {
val downloadingMsg =
getString(Res.string.firmware_update_downloading_percent, 0).replace(Regex(":?\\s*%1\\\$d%?"), "").trim()
if (firmwareFile == null) {
val retrievalFailedMsg = getString(Res.string.firmware_update_retrieval_failed)
updateState(FirmwareUpdateState.Error(retrievalFailedMsg))
return null
updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f)))
return if (firmwareUri != null) {
val extractingMsg = getString(Res.string.firmware_update_extracting)
updateState(FirmwareUpdateState.Processing(ProgressState(message = extractingMsg)))
getFirmwareFromUri(firmwareUri)
} else {
val firmwareFile =
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"),
),
)
}
if (firmwareFile == null) {
val errorMsg = getString(Res.string.firmware_update_not_found_in_release, hardware.displayName)
updateState(FirmwareUpdateState.Error(errorMsg))
null
} else {
firmwareFile
}
}
return firmwareFile
}
private suspend fun connectToDevice(
@ -273,16 +287,17 @@ constructor(
@Suppress("LongMethod")
private suspend fun executeOtaSequence(
transport: UnifiedOtaProtocol,
firmwareFile: File,
firmwareFile: String,
sha256Hash: String,
rebootMode: Int,
updateState: (FirmwareUpdateState) -> Unit,
) {
val file = java.io.File(firmwareFile)
// Step 5: Start OTA
val startingOtaMsg = getString(Res.string.firmware_update_starting_ota)
updateState(FirmwareUpdateState.Processing(ProgressState(startingOtaMsg)))
transport
.startOta(sizeBytes = firmwareFile.length(), sha256Hash = sha256Hash) { status ->
.startOta(sizeBytes = file.length(), sha256Hash = sha256Hash) { status ->
when (status) {
OtaHandshakeStatus.Erasing -> {
val erasingMsg = getString(Res.string.firmware_update_erasing)
@ -295,7 +310,7 @@ constructor(
// Step 6: Stream
val uploadingMsg = getString(Res.string.firmware_update_uploading)
updateState(FirmwareUpdateState.Updating(ProgressState(uploadingMsg, 0f)))
val firmwareData = firmwareFile.readBytes()
val firmwareData = file.readBytes()
val chunkSize =
if (rebootMode == 1) {
BleOtaTransport.RECOMMENDED_CHUNK_SIZE

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
sealed interface DfuInternalState {
val address: String
data class Connecting(override val address: String) : DfuInternalState
data class Connected(override val address: String) : DfuInternalState
data class Starting(override val address: String) : DfuInternalState
data class EnablingDfuMode(override val address: String) : DfuInternalState
data class Progress(
override val address: String,
val percent: Int,
val speed: Float,
val avgSpeed: Float,
val currentPart: Int,
val partsTotal: Int,
) : DfuInternalState
data class Validating(override val address: String) : DfuInternalState
data class Disconnecting(override val address: String) : DfuInternalState
data class Disconnected(override val address: String) : DfuInternalState
data class Completed(override val address: String) : DfuInternalState
data class Aborted(override val address: String) : DfuInternalState
data class Error(override val address: String, val message: String?) : DfuInternalState
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.DeviceHardware
interface FirmwareFileHandler {
fun cleanupAllTemporaryFiles()
suspend fun checkUrlExists(url: String): Boolean
suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): String?
suspend fun extractFirmware(
uri: CommonUri,
hardware: DeviceHardware,
fileExtension: String,
preferredFilename: String? = null,
): String?
suspend fun extractFirmwareFromZip(
zipFilePath: String,
hardware: DeviceHardware,
fileExtension: String,
preferredFilename: String? = null,
): String?
suspend fun getFileSize(path: String): Long
suspend fun deleteFile(path: String)
suspend fun copyFileToUri(sourcePath: String, destinationUri: CommonUri): Long
suspend fun copyUriToUri(sourceUri: CommonUri, destinationUri: CommonUri): Long
}

View file

@ -16,10 +16,9 @@
*/
package org.meshtastic.feature.firmware
import android.net.Uri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import java.io.File
/** Common interface for all firmware update handlers (BLE DFU, ESP32 OTA, USB). */
interface FirmwareUpdateHandler {
@ -31,13 +30,13 @@ interface FirmwareUpdateHandler {
* @param target The target identifier (e.g., Bluetooth address, IP address, or empty for USB)
* @param updateState Callback to report back state changes
* @param firmwareUri Optional URI for a local firmware file (bypasses download)
* @return The downloaded/extracted firmware file, or null if it was a local file or update finished
* @return The downloaded/extracted firmware file path, or null if it was a local file or update finished
*/
suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
target: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: Uri? = null,
): File?
firmwareUri: CommonUri? = null,
): String?
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
interface FirmwareUpdateManager {
suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri? = null,
): String?
fun dfuProgressFlow(): kotlinx.coroutines.flow.Flow<DfuInternalState>
}

View file

@ -16,10 +16,9 @@
*/
package org.meshtastic.feature.firmware
import android.net.Uri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import java.io.File
/**
* Represents the progress of a long-running firmware update task.
@ -58,6 +57,6 @@ sealed interface FirmwareUpdateState {
data object Success : FirmwareUpdateState
data class AwaitingFileSave(val uf2File: File?, val fileName: String, val sourceUri: Uri? = null) :
data class AwaitingFileSave(val uf2FilePath: String?, val fileName: String, val sourceUri: CommonUri? = null) :
FirmwareUpdateState
}

View file

@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.firmware
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -37,6 +35,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
@ -73,8 +72,6 @@ import org.meshtastic.core.resources.firmware_update_unknown_hardware
import org.meshtastic.core.resources.firmware_update_updating
import org.meshtastic.core.resources.firmware_update_validating
import org.meshtastic.core.resources.unknown
import java.io.File
import javax.inject.Inject
private const val DFU_RECONNECT_PREFIX = "x"
private const val PERCENT_MAX_VALUE = 100f
@ -87,11 +84,8 @@ private const val MILLIS_PER_SECOND = 1000L
private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
@HiltViewModel
@Suppress("LongParameterList", "TooManyFunctions")
class FirmwareUpdateViewModel
@Inject
constructor(
open class FirmwareUpdateViewModel(
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val nodeRepository: NodeRepository,
@ -99,7 +93,7 @@ constructor(
private val radioPrefs: RadioPrefs,
private val bootloaderWarningDataSource: BootloaderWarningDataSource,
private val firmwareUpdateManager: FirmwareUpdateManager,
private val usbManager: UsbManager,
private val usbManager: FirmwareUsbManager,
private val fileHandler: FirmwareFileHandler,
) : ViewModel() {
@ -121,7 +115,7 @@ constructor(
val currentFirmwareVersion = _currentFirmwareVersion.asStateFlow()
private var updateJob: Job? = null
private var tempFirmwareFile: File? = null
private var tempFirmwareFile: String? = null
private var originalDeviceAddress: String? = null
init {
@ -135,7 +129,7 @@ constructor(
override fun onCleared() {
super.onCleared()
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
viewModelScope.launch { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) }
}
fun setReleaseType(type: FirmwareReleaseType) {
@ -251,9 +245,9 @@ constructor(
}
}
fun saveDfuFile(uri: Uri) {
fun saveDfuFile(uri: CommonUri) {
val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return
val firmwareFile = currentState.uf2File
val firmwareFile = currentState.uf2FilePath
val sourceUri = currentState.sourceUri
viewModelScope.launch {
@ -284,7 +278,7 @@ constructor(
}
}
fun startUpdateFromFile(uri: Uri) {
fun startUpdateFromFile(uri: CommonUri) {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
if (currentState.updateMethod is FirmwareUpdateMethod.Ble && !isValidBluetoothAddress(currentState.address)) {
viewModelScope.launch {
@ -305,7 +299,7 @@ constructor(
val extractedFile = fileHandler.extractFirmware(uri, currentState.deviceHardware, extension)
tempFirmwareFile = extractedFile
val firmwareUri = if (extractedFile != null) Uri.fromFile(extractedFile) else uri
val firmwareUri = if (extractedFile != null) CommonUri.parse("file://$extractedFile") else uri
tempFirmwareFile =
firmwareUpdateManager.startUpdate(
@ -385,7 +379,7 @@ constructor(
}
}
private fun handleDfuProgress(dfuState: DfuInternalState.Progress) {
private suspend fun handleDfuProgress(dfuState: DfuInternalState.Progress) {
val progress = dfuState.percent / PERCENT_MAX_VALUE
val percentText = "${dfuState.percent}%"
@ -394,7 +388,7 @@ constructor(
val speedKib = speedBytesPerSec / KIB_DIVISOR
// Calculate ETA
val totalBytes = tempFirmwareFile?.length() ?: 0L
val totalBytes = tempFirmwareFile?.let { fileHandler.getFileSize(it) } ?: 0L
val etaText =
if (totalBytes > 0 && speedBytesPerSec > 0 && dfuState.percent > 0) {
val remainingBytes = totalBytes * (1f - progress)
@ -483,9 +477,9 @@ constructor(
}
}
private fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmwareFile: File?): File? {
private suspend fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmwareFile: String?): String? {
runCatching {
tempFirmwareFile?.takeIf { it.exists() }?.delete()
tempFirmwareFile?.let { fileHandler.deleteFile(it) }
fileHandler.cleanupAllTemporaryFiles()
}
.onFailure { e -> Logger.w(e) { "Failed to cleanup temp files" } }

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,15 +14,10 @@
* 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
package org.meshtastic.feature.node.metrics
import kotlinx.coroutines.flow.Flow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
internal object TracerouteMapOverlayInsets {
val overlayAlignment: Alignment = Alignment.BottomCenter
val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp)
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally
interface FirmwareUsbManager {
fun deviceDetachFlow(): Flow<Unit>
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.firmware")
class FeatureFirmwareModule

View file

@ -19,7 +19,7 @@ plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kmp.library.compose)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.devtools.ksp)
alias(libs.plugins.meshtastic.koin)
}
kotlin {
@ -39,13 +39,12 @@ kotlin {
implementation(projects.core.resources)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.javax.inject)
}
androidMain.dependencies {
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
@ -53,7 +52,6 @@ kotlin {
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.navigation3.ui)
implementation(libs.hilt.android)
}
androidUnitTest.dependencies {
@ -67,8 +65,3 @@ kotlin {
}
}
}
dependencies {
add("kspAndroid", libs.androidx.hilt.compiler)
add("kspAndroid", libs.hilt.compiler)
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.intro")
class FeatureIntroModule

View file

@ -18,7 +18,7 @@ plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kmp.library.compose)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.devtools.ksp)
alias(libs.plugins.meshtastic.koin)
}
kotlin {
@ -45,12 +45,11 @@ kotlin {
implementation(projects.core.di)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.javax.inject)
implementation(libs.koin.compose.viewmodel)
}
androidMain.dependencies {
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.accompanist.permissions)
@ -68,7 +67,6 @@ kotlin {
implementation(libs.androidx.savedstate.ktx)
implementation(libs.material)
implementation(libs.kermit)
implementation(libs.hilt.android)
}
androidUnitTest.dependencies {
@ -81,8 +79,3 @@ kotlin {
}
}
}
dependencies {
add("kspAndroid", libs.androidx.hilt.compiler)
add("kspAndroid", libs.hilt.compiler)
}

View file

@ -16,15 +16,14 @@
*/
package org.meshtastic.feature.map
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import javax.inject.Inject
open class SharedMapViewModel
@Inject
constructor(
@KoinViewModel
open class SharedMapViewModel(
mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.map")
class FeatureMapModule

View file

@ -18,7 +18,7 @@
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kmp.library.compose)
alias(libs.plugins.devtools.ksp)
alias(libs.plugins.meshtastic.koin)
}
kotlin {
@ -44,13 +44,12 @@ kotlin {
implementation(projects.core.ui)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kermit)
implementation(libs.javax.inject)
}
androidMain.dependencies {
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
@ -64,8 +63,6 @@ kotlin {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
implementation(libs.hilt.android)
}
commonTest.dependencies {
@ -82,8 +79,3 @@ kotlin {
}
}
}
dependencies {
add("kspAndroid", libs.androidx.hilt.compiler)
add("kspAndroid", libs.hilt.compiler)
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.messaging")
class FeatureMessagingModule

View file

@ -14,60 +14,82 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.flavors)
alias(libs.plugins.meshtastic.android.library.compose)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kmp.library.compose)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.meshtastic.koin)
}
configure<LibraryExtension> {
namespace = "org.meshtastic.feature.node"
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.node"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
defaultConfig { manifestPlaceholders["MAPS_API_KEY"] = "DEBUG_KEY" }
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.proto)
implementation(projects.core.repository)
implementation(projects.core.resources)
implementation(projects.core.service)
implementation(projects.core.ui)
implementation(projects.core.di)
implementation(projects.feature.map)
testOptions { unitTests { isIncludeAndroidResources = true } }
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.navigation)
implementation(projects.feature.map)
implementation(libs.androidx.activity.compose)
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.navigation.common)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.kermit)
implementation(libs.coil)
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
implementation(libs.vico.compose)
implementation(libs.vico.compose.m2)
implementation(libs.vico.compose.m3)
googleImplementation(libs.location.services)
googleImplementation(libs.maps.compose)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.robolectric)
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kermit)
implementation(libs.kotlinx.collections.immutable)
}
androidMain.dependencies {
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.compose)
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.navigation.common)
implementation(libs.coil)
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
implementation(libs.vico.compose)
implementation(libs.vico.compose.m2)
implementation(libs.vico.compose.m3)
implementation(libs.nordic.common.core)
implementation(libs.nordic.common.permissions.ble)
// These were in googleImplementation, but KMP with android-kotlin-multiplatform-library
// handles flavors differently. For now, we put them in androidMain if they are needed.
// In a real KMP flavored module, we'd use different source sets.
// But Priority 4b suggests Option A: extract flavored stuff to app module.
// So InlineMap will move to app module soon.
implementation(libs.location.services)
implementation(libs.maps.compose)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.mockk)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.test.ext.junit)
}
}
}

View file

@ -5,8 +5,8 @@
<ID>CyclomaticComplexMethod:CompassViewModel.kt$CompassViewModel$@Suppress("ReturnCount") private fun calculatePositionalAccuracyMeters(): Float?</ID>
<ID>CyclomaticComplexMethod:NodeDetailActions.kt$NodeDetailActions$fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction)</ID>
<ID>CyclomaticComplexMethod:NodeDetailViewModel.kt$NodeDetailViewModel$fun handleNodeMenuAction(action: NodeMenuAction)</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7</ID>
<ID>MagicNumber:CompassViewModel.kt$CompassViewModel$180.0</ID>
<ID>TooGenericExceptionCaught:MetricsViewModel.kt$MetricsViewModel$e: Exception</ID>
<ID>TooGenericExceptionCaught:NodeManagementActions.kt$NodeManagementActions$ex: Exception</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* 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.node.compass
import android.content.Context
@ -22,29 +21,19 @@ import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
import org.koin.core.annotation.Single
private const val ROTATION_MATRIX_SIZE = 9
private const val ORIENTATION_SIZE = 3
private const val FULL_CIRCLE_DEGREES = 360f
data class HeadingState(
val heading: Float? = null, // 0..360 degrees
val hasSensor: Boolean = true,
val accuracy: Int = SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
)
@Single
class AndroidCompassHeadingProvider(private val context: Context) : CompassHeadingProvider {
class CompassHeadingProvider @Inject constructor(@ApplicationContext private val context: Context) {
/**
* Emits compass heading in degrees (magnetic). Callers can correct for true north using the latest location data
* when available.
*/
fun headingUpdates(): Flow<HeadingState> = callbackFlow {
override fun headingUpdates(): Flow<HeadingState> = callbackFlow {
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
if (sensorManager == null) {
trySend(HeadingState(hasSensor = false))
@ -93,7 +82,7 @@ class CompassHeadingProvider @Inject constructor(@ApplicationContext private val
}
SensorManager.getOrientation(rotationMatrix, orientation)
var azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
val azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
val heading = (azimuth + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
trySend(HeadingState(heading = heading, hasSensor = true, accuracy = event.accuracy))

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
import android.hardware.GeomagneticField
import org.koin.core.annotation.Single
@Single
class AndroidMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float {
val geomagneticField = GeomagneticField(latitude.toFloat(), longitude.toFloat(), altitude.toFloat(), timeMillis)
return geomagneticField.declination
}
}

View file

@ -25,31 +25,18 @@ import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Inject
data class PhoneLocationState(
val permissionGranted: Boolean,
val providerEnabled: Boolean,
val location: Location? = null,
) {
val hasFix: Boolean
get() = location != null
}
@Single
class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) :
PhoneLocationProvider {
class PhoneLocationProvider
@Inject
constructor(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) {
// Streams phone location (and permission/provider state) so the compass stays gated on real fixes.
fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
override fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
if (locationManager == null) {
trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
@ -59,7 +46,7 @@ constructor(
if (!hasLocationPermission()) {
trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
close() // Just closing it off, like how I'll close my legs around your waist
close()
return@callbackFlow
}
@ -70,7 +57,7 @@ constructor(
PhoneLocationState(
permissionGranted = true,
providerEnabled = LocationManagerCompat.isLocationEnabled(locationManager),
location = lastLocation,
location = lastLocation?.toPhoneLocation(),
),
)
}
@ -96,7 +83,6 @@ constructor(
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
try {
// Get initial fix if available
lastLocation =
providers
.mapNotNull { provider -> locationManager.getLastKnownLocation(provider) }
@ -131,6 +117,9 @@ constructor(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
private fun Location.toPhoneLocation() =
PhoneLocation(latitude = latitude, longitude = longitude, altitude = altitude, timeMillis = time)
companion object {
private const val MIN_UPDATE_INTERVAL_MS = 1_000L
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* 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.node.component
import androidx.compose.material.icons.Icons

View file

@ -20,7 +20,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource

View file

@ -52,6 +52,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.position
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@ -59,6 +60,7 @@ import org.meshtastic.proto.Config
private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
private const val COMPASS_BUTTON_WEIGHT = 0.9f
private const val MAP_HEIGHT_DP = 200
/**
* Displays node position details, last update time, distance, and related actions like requesting position and
@ -126,8 +128,8 @@ fun PositionSection(
@Composable
private fun PositionMap(node: Node, distance: String?) {
Box(modifier = Modifier.padding(vertical = 4.dp)) {
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(200.dp)) {
InlineMap(node = node, Modifier.fillMaxSize())
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(MAP_HEIGHT_DP.dp)) {
LocalInlineMapProvider.current(node, Modifier.fillMaxSize())
}
if (distance != null && distance.isNotEmpty()) {
Surface(

View file

@ -17,15 +17,13 @@
package org.meshtastic.feature.node.detail
import kotlinx.coroutines.CoroutineScope
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.feature.node.component.NodeMenuAction
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@Single
class NodeDetailActions
@Inject
constructor(
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,

View file

@ -21,10 +21,7 @@ import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@ -55,9 +52,9 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.Route
@ -94,10 +91,11 @@ private sealed interface NodeDetailOverlay {
fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
viewModel: NodeDetailViewModel = hiltViewModel(),
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
@ -120,6 +118,7 @@ fun NodeDetailScreen(
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
onNavigateUp = onNavigateUp,
compassViewModel = compassViewModel,
)
}
@ -133,12 +132,13 @@ private fun NodeDetailScaffold(
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel? = null,
) {
var activeOverlay by remember { mutableStateOf<NodeDetailOverlay?>(null) }
val inspectionMode = LocalInspectionMode.current
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
val actualCompassViewModel = compassViewModel ?: if (inspectionMode) null else koinViewModel()
val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
val node = uiState.node
val listState = rememberLazyListState()
@ -167,7 +167,7 @@ private fun NodeDetailScaffold(
when (action) {
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
is NodeDetailAction.OpenCompass -> {
compassViewModel?.start(action.node, action.displayUnits)
actualCompassViewModel?.start(action.node, action.displayUnits)
activeOverlay = NodeDetailOverlay.Compass
}
else ->
@ -186,7 +186,7 @@ private fun NodeDetailScaffold(
)
}
NodeDetailOverlays(activeOverlay, node, compassUiState, compassViewModel, { activeOverlay = null }) {
NodeDetailOverlays(activeOverlay, node, compassUiState, actualCompassViewModel, { activeOverlay = null }) {
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
}
}
@ -200,12 +200,7 @@ private fun NodeDetailContent(
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = uiState.node != null,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
label = "NodeDetailContent",
modifier = modifier,
) { isNodePresent ->
Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent ->
if (isNodePresent && uiState.node != null) {
NodeDetailList(
node = uiState.node,

View file

@ -61,7 +61,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@ -96,8 +95,8 @@ import org.meshtastic.proto.SharedContact
@Composable
fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
viewModel: NodeListViewModel,
onNavigateToChannels: () -> Unit = {},
viewModel: NodeListViewModel = hiltViewModel(),
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
) {
@ -156,7 +155,9 @@ fun NodeListScreen(
alignment = Alignment.BottomEnd,
),
onImport = { uri ->
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
viewModel.handleScannedUri(uri.toString()) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,

View file

@ -51,7 +51,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
@ -123,7 +122,7 @@ private val LEGEND_DATA =
@Suppress("LongMethod")
@Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()

View file

@ -46,7 +46,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
@ -73,7 +72,7 @@ import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val graphData by viewModel.environmentGraphingData.collectAsStateWithLifecycle()
val filteredTelemetries by viewModel.filteredEnvironmentMetrics.collectAsStateWithLifecycle()

View file

@ -53,7 +53,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
@ -78,7 +77,7 @@ import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View file

@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
@ -61,11 +60,7 @@ import org.meshtastic.feature.node.detail.NodeRequestEffect
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NeighborInfoLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View file

@ -43,7 +43,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@ -174,7 +173,7 @@ private fun PaxMetricsChart(
@Composable
@Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val paxMetrics by metricsViewModel.filteredPaxMetrics.collectAsStateWithLifecycle()
val timeFrame by metricsViewModel.timeFrame.collectAsStateWithLifecycle()

View file

@ -59,7 +59,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
@ -172,7 +171,7 @@ private fun ActionButtons(
@Suppress("LongMethod")
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View file

@ -51,7 +51,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
@ -107,7 +106,7 @@ private val LEGEND_DATA =
@Suppress("LongMethod")
@Composable
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()

View file

@ -47,7 +47,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
@ -85,7 +84,7 @@ private val LEGEND_DATA =
@Suppress("LongMethod")
@Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()

View file

@ -42,7 +42,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
@ -83,7 +82,7 @@ import org.meshtastic.proto.RouteDiscovery
@Composable
fun TracerouteLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
viewModel: MetricsViewModel,
onNavigateUp: () -> Unit,
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
) {

View file

@ -38,7 +38,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
@ -53,12 +52,13 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position
@Composable
fun TracerouteMapScreen(
metricsViewModel: MetricsViewModel = hiltViewModel(),
metricsViewModel: MetricsViewModel,
requestId: Int,
logUuid: String? = null,
onNavigateUp: () -> Unit,
@ -102,6 +102,7 @@ private fun TracerouteMapScaffold(
) {
var tracerouteNodesShown by remember { mutableStateOf(0) }
var tracerouteNodesTotal by remember { mutableStateOf(0) }
val insets = LocalTracerouteMapOverlayInsetsProvider.current
Scaffold(
topBar = {
MainAppBar(
@ -128,10 +129,8 @@ private fun TracerouteMapScaffold(
},
)
Column(
modifier =
Modifier.align(TracerouteMapOverlayInsets.overlayAlignment)
.padding(TracerouteMapOverlayInsets.overlayPadding),
horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment,
modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),
horizontalAlignment = insets.contentHorizontalAlignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,15 +14,16 @@
* 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.node.compass
package org.meshtastic.feature.node.metrics
import kotlinx.coroutines.flow.Flow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
data class HeadingState(
val heading: Float? = null, // 0..360 degrees
val hasSensor: Boolean = true,
val accuracy: Int = 0,
)
internal object TracerouteMapOverlayInsets {
val overlayAlignment: Alignment = Alignment.BottomEnd
val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp)
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End
interface CompassHeadingProvider {
fun headingUpdates(): Flow<HeadingState>
}

View file

@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.node.compass
import android.hardware.GeomagneticField
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -39,7 +37,6 @@ import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.ui.component.precisionBitsToMeters
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.min
@ -54,13 +51,11 @@ private const val SECONDS_PER_MINUTE = 60
private const val HUNDRED = 100f
private const val MILLIMETERS_PER_METER = 1000f
@HiltViewModel
@Suppress("TooManyFunctions")
class CompassViewModel
@Inject
constructor(
open class CompassViewModel(
private val headingProvider: CompassHeadingProvider,
private val phoneLocationProvider: PhoneLocationProvider,
private val magneticFieldProvider: MagneticFieldProvider,
private val dispatchers: CoroutineDispatchers,
) : ViewModel() {
@ -192,9 +187,8 @@ constructor(
private fun applyTrueNorthCorrection(heading: Float?, locationState: PhoneLocationState): Float? {
val loc = locationState.location ?: return heading
val baseHeading = heading ?: return null
val geomagnetic =
GeomagneticField(loc.latitude.toFloat(), loc.longitude.toFloat(), loc.altitude.toFloat(), nowMillis)
return (baseHeading + geomagnetic.declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
val declination = magneticFieldProvider.getDeclination(loc.latitude, loc.longitude, loc.altitude, nowMillis)
return (baseHeading + declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
}
private fun formatElapsed(timestampSec: Long): String {
@ -246,6 +240,8 @@ constructor(
if (distance <= 0) return FULL_CIRCLE_DEGREES / 2
val radians = atan2(accuracy.toDouble(), distance.toDouble())
return Math.toDegrees(radians).toFloat().coerceIn(0f, FULL_CIRCLE_DEGREES / 2)
return radiansToDegrees(radians).toFloat().coerceIn(0f, FULL_CIRCLE_DEGREES / 2)
}
private fun radiansToDegrees(radians: Double): Double = radians * 180.0 / kotlin.math.PI
}

View file

@ -14,13 +14,8 @@
* 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.node.component
package org.meshtastic.feature.node.compass
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.Node
@Composable
internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
// No-op for F-Droid builds
interface MagneticFieldProvider {
fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
import kotlinx.coroutines.flow.Flow
data class PhoneLocation(val latitude: Double, val longitude: Double, val altitude: Double, val timeMillis: Long)
data class PhoneLocationState(
val permissionGranted: Boolean,
val providerEnabled: Boolean,
val location: PhoneLocation? = null,
) {
val hasFix: Boolean
get() = location != null
}
interface PhoneLocationProvider {
fun locationUpdates(): Flow<PhoneLocationState>
}

View file

@ -20,7 +20,6 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -43,20 +42,8 @@ import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import javax.inject.Inject
/**
* UI state for the Node Details screen.
*
* @property node The node being viewed, or null if loading.
* @property nodeName The display name for the node, resolved in the UI.
* @property ourNode Information about the locally connected node.
* @property metricsState Aggregated sensor and signal metrics.
* @property environmentState Standardized environmental sensor data.
* @property availableLogs a set of log types available for this node.
* @property lastTracerouteTime Timestamp of the last successful traceroute request.
* @property lastRequestNeighborsTime Timestamp of the last successful neighbor info request.
*/
/** UI state for the Node Details screen. */
@androidx.compose.runtime.Stable
data class NodeDetailUiState(
val node: Node? = null,
@ -73,11 +60,8 @@ data class NodeDetailUiState(
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class NodeDetailViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
open class NodeDetailViewModel(
private val savedStateHandle: SavedStateHandle,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
private val serviceRepository: ServiceRepository,

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
@ -40,12 +41,9 @@ import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_node_text
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@Single
class NodeManagementActions
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
@ -127,10 +125,8 @@ constructor(
scope.launch(Dispatchers.IO) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
Logger.e { "Set node notes IO error: ${ex.message}" }
} catch (ex: java.sql.SQLException) {
Logger.e { "Set node notes SQL error: ${ex.message}" }
} catch (ex: Exception) {
Logger.e(ex) { "Set node notes error" }
}
}
}

Some files were not shown because too many files have changed in this diff Show more