mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
a5390a80e7
commit
875cf1cff2
440 changed files with 3738 additions and 3508 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() -> {
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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))
|
||||
}
|
||||
},
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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" } }
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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() }
|
||||
|
||||
|
|
@ -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() }
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -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() }
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 = { _, _ -> },
|
||||
) {
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue