refactor: migrate core modules to Kotlin Multiplatform and consolidat… (#4735)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-06 16:06:50 -06:00 committed by GitHub
parent f3775a601c
commit cffbd08806
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
265 changed files with 1383 additions and 1340 deletions

View file

@ -1,35 +0,0 @@
# `:core:analytics`
## Overview
The `:core:analytics` module provides a unified interface for event tracking and crash reporting. It is designed to strictly separate analytics providers based on the build flavor.
## Key Components
### 1. `PlatformAnalytics`
An interface defining the standard operations for tracking events and reporting errors.
## Flavor Specifics
- **`google` flavor**: Implements `PlatformAnalytics` using **Firebase Analytics** and **Firebase Crashlytics**.
- **`fdroid` flavor**: Provides a "no-op" implementation that does not collect any user data or report crashes, ensuring FOSS compliance.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:analytics[analytics]:::android-library
:core:analytics -.-> :core:prefs
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->

View file

@ -1,56 +0,0 @@
/*
* 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/>.
*/
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.secrets)
alias(libs.plugins.kover)
}
dependencies {
implementation(projects.core.prefs)
implementation(projects.core.repository)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.navigation.runtime)
implementation(libs.kermit)
googleApi(libs.dd.sdk.android.compose)
googleApi(libs.dd.sdk.android.logs)
googleApi(libs.dd.sdk.android.rum)
googleApi(libs.dd.sdk.android.timber)
googleApi(libs.dd.sdk.android.trace)
googleApi(libs.dd.sdk.android.trace.otel)
googleApi(platform(libs.firebase.bom))
googleApi(libs.firebase.analytics)
googleApi(libs.firebase.crashlytics)
}
configure<LibraryExtension> {
buildFeatures { buildConfig = true }
namespace = "org.meshtastic.core.analytics"
}
secrets {
defaultPropertiesFileName = "secrets.defaults.properties"
propertiesFileName = "secrets.properties"
}

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.analytics.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.analytics.platform.FdroidPlatformAnalytics
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import javax.inject.Singleton
/** Hilt module to provide the [FdroidPlatformAnalytics] for the fdroid flavor. */
@Module
@InstallIn(SingletonComponent::class)
abstract class FdroidPlatformAnalyticsModule {
@Binds
@Singleton
abstract fun bindPlatformHelper(fdroidPlatformAnalytics: FdroidPlatformAnalytics): PlatformAnalytics
}

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.analytics.platform
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import org.meshtastic.core.analytics.BuildConfig
import org.meshtastic.core.analytics.DataPair
import javax.inject.Inject
/**
* F-Droid specific implementation of [org.meshtastic.analytics.platform.PlatformAnalytics]. This provides no-op
* implementations for analytics and other platform services.
*/
class FdroidPlatformAnalytics @Inject constructor() : PlatformAnalytics {
init {
// For F-Droid builds we don't initialize external analytics services.
// In debug builds we attach a DebugTree for convenient local logging, but
// release builds rely on system logging only.
if (BuildConfig.DEBUG) {
Logger.setMinSeverity(Severity.Debug)
Logger.i { "F-Droid platform no-op analytics initialized (Debug mode }." }
} else {
Logger.setMinSeverity(Severity.Info)
Logger.i { "F-Droid platform no-op analytics initialized." }
}
}
override fun setDeviceAttributes(firmwareVersion: String, model: String) {
// No-op for F-Droid
Logger.d { "Set device attributes called: firmwareVersion=$firmwareVersion, deviceHardware=$model" }
}
@Composable
override fun AddNavigationTrackingEffect(navController: NavHostController) {
// No-op for F-Droid, but we can log navigation if needed for debugging
if (BuildConfig.DEBUG) {
navController.addOnDestinationChangedListener { _, destination, _ ->
Logger.d { "Navigation changed to: ${destination.route}" }
}
}
}
override val isPlatformServicesAvailable: Boolean
get() = false
override fun track(event: String, vararg properties: DataPair) {
Logger.d { "Track called: event=$event, properties=${properties.toList()}" }
}
}

View file

@ -1,35 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.analytics.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.analytics.platform.GooglePlatformAnalytics
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import javax.inject.Singleton
/** Hilt module to provide the [GooglePlatformAnalytics] for the google flavor. */
@Module
@InstallIn(SingletonComponent::class)
abstract class GooglePlatformAnalyticsModule {
@Binds @Singleton
abstract fun bindPlatformHelper(googlePlatformHelper: GooglePlatformAnalytics): PlatformAnalytics
}

View file

@ -1,333 +0,0 @@
/*
* 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.core.analytics.platform
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
import com.datadog.android.compose.ExperimentalTrackingApi
import com.datadog.android.compose.NavigationViewTrackingEffect
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
import com.datadog.android.log.Logs
import com.datadog.android.log.LogsConfiguration
import com.datadog.android.privacy.TrackingConsent
import com.datadog.android.rum.GlobalRumMonitor
import com.datadog.android.rum.Rum
import com.datadog.android.rum.RumConfiguration
import com.datadog.android.rum.tracking.AcceptAllNavDestinations
import com.datadog.android.trace.Trace
import com.datadog.android.trace.TraceConfiguration
import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailabilityLight
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics.ConsentStatus
import com.google.firebase.analytics.FirebaseAnalytics.ConsentType
import com.google.firebase.analytics.analytics
import com.google.firebase.crashlytics.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.initialize
import dagger.hilt.android.qualifiers.ApplicationContext
import io.opentelemetry.api.GlobalOpenTelemetry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.analytics.BuildConfig
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.repository.AnalyticsPrefs
import javax.inject.Inject
import co.touchlab.kermit.Logger as KermitLogger
/**
* Google Play Services specific implementation of [PlatformAnalytics]. This helper initializes and manages Firebase and
* Datadog services, and subscribes to analytics preference changes to update consent accordingly.
*
* This implementation delays initialization of SDKs until user consent is granted to reduce tracking "noise" and
* respect privacy-focused environments.
*/
class GooglePlatformAnalytics
@Inject
constructor(
@ApplicationContext private val context: Context,
private val analyticsPrefs: AnalyticsPrefs,
) : PlatformAnalytics {
private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
private var datadogLogger: Logger? = null
private var isFirebaseInitialized = false
private val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab")
return "true" == testLabSetting
}
companion object {
private const val TAG = "GooglePlatformAnalytics"
private const val SERVICE_NAME = "org.meshtastic"
private const val KEY_PRIORITY = "priority"
private const val KEY_TAG = "tag"
private const val KEY_MESSAGE = "message"
}
init {
// Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully.
val writers = buildList {
add(DatadogLogWriter())
add(CrashlyticsLogWriter())
if (BuildConfig.DEBUG) {
add(co.touchlab.kermit.LogcatWriter())
}
}
KermitLogger.setLogWriters(writers)
KermitLogger.setMinSeverity(if (BuildConfig.DEBUG) Severity.Debug else Severity.Info)
// Initial consent state
updateAnalyticsConsent(analyticsPrefs.analyticsAllowed.value)
// Subscribe to analytics preference changes
analyticsPrefs.analyticsAllowed
.onEach { allowed -> updateAnalyticsConsent(allowed) }
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
}
/**
* Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted.
*/
private fun ensureInitialized() {
if (!analyticsPrefs.analyticsAllowed.value || isInTestLab) return
if (!Datadog.isInitialized()) {
initDatadog(context as Application)
datadogLogger =
Logger.Builder()
.setService(SERVICE_NAME)
.setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID
.setRemoteSampleRate(sampleRate)
.setBundleWithTraceEnabled(true)
.setBundleWithRumEnabled(true)
.build()
}
if (!isFirebaseInitialized) {
initCrashlytics(context as Application)
isFirebaseInitialized = true
}
}
private fun initDatadog(application: Application) {
val configuration =
Configuration.Builder(
clientToken = BuildConfig.datadogClientToken,
env = if (BuildConfig.DEBUG) "debug" else "release",
variant = BuildConfig.FLAVOR,
)
.useSite(DatadogSite.US5)
.setCrashReportsEnabled(true)
.setUseDeveloperModeWhenDebuggable(true)
.build()
// Initialize with PENDING, consent will be updated via updateAnalyticsConsent
Datadog.initialize(application, configuration, TrackingConsent.PENDING)
Datadog.setVerbosity(if (BuildConfig.DEBUG) android.util.Log.DEBUG else android.util.Log.WARN)
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
.trackBackgroundEvents(false) // Disable background noise
.trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
.setSessionSampleRate(sampleRate)
.build()
Rum.enable(rumConfiguration)
val logsConfig = LogsConfiguration.Builder().build()
Logs.enable(logsConfig)
val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build()
Trace.enable(traceConfig)
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
// Session Replay disabled to reduce PII collection
}
private fun initCrashlytics(application: Application) {
Firebase.initialize(application)
// Deny all ad-related consent types by default to minimize tracking noise
Firebase.analytics.setConsent(
mapOf(
ConsentType.AD_STORAGE to ConsentStatus.DENIED,
ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
ConsentType.ANALYTICS_STORAGE to ConsentStatus.DENIED,
),
)
// Explicitly disable analytics collection until we confirm user consent
Firebase.analytics.setAnalyticsCollectionEnabled(false)
}
/**
* Updates the consent status for analytics, performance, and crash reporting services.
*
* @param allowed True if analytics are allowed, false otherwise.
*/
fun updateAnalyticsConsent(allowed: Boolean) {
if (isInTestLab) return
if (allowed) {
ensureInitialized()
}
KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" }
if (Datadog.isInitialized()) {
Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
}
if (isFirebaseInitialized) {
Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed
Firebase.analytics.setAnalyticsCollectionEnabled(allowed)
if (allowed) {
Firebase.crashlytics.sendUnsentReports()
// Ensure ad-related PII collection remains disabled even if analytics is allowed
Firebase.analytics.setUserProperty("allow_personalized_ads", "false")
}
// Manage Analytics Storage consent for Advanced Consent Mode
val consentStatus = if (allowed) ConsentStatus.GRANTED else ConsentStatus.DENIED
Firebase.analytics.setConsent(
mapOf(
ConsentType.ANALYTICS_STORAGE to consentStatus,
// Keep ad-related types explicitly denied
ConsentType.AD_STORAGE to ConsentStatus.DENIED,
ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
),
)
}
}
override fun setDeviceAttributes(firmwareVersion: String, model: String) {
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion.extractSemanticVersion())
GlobalRumMonitor.get().addAttribute("device_hardware", model)
}
@OptIn(ExperimentalTrackingApi::class)
@Composable
override fun AddNavigationTrackingEffect(navController: NavHostController) {
if (Datadog.isInitialized()) {
NavigationViewTrackingEffect(
navController = navController,
trackArguments = true,
destinationPredicate = AcceptAllNavDestinations(),
)
}
}
private val isGooglePlayAvailable: Boolean
get() =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let {
it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID
}
override val isPlatformServicesAvailable: Boolean
get() = isGooglePlayAvailable
private inner class CrashlyticsLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
if (!isFirebaseInitialized) return
if (!Firebase.crashlytics.isCrashlyticsCollectionEnabled) return
// Add the log to the Crashlytics log buffer so it appears in reports
Firebase.crashlytics.log("$severity/$tag: $message")
// Filter out normal coroutine cancellations
if (throwable is CancellationException) return
// Only record non-fatal exceptions for actual Errors (Severity.Error or Severity.Assert)
if (severity >= Severity.Error) {
if (throwable != null) {
Firebase.crashlytics.recordException(throwable)
} else {
Firebase.crashlytics.setCustomKeys {
key(KEY_PRIORITY, severity.ordinal)
key(KEY_TAG, tag)
key(KEY_MESSAGE, message)
}
Firebase.crashlytics.recordException(Exception(message))
}
}
}
}
private inner class DatadogLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val logger = datadogLogger ?: return
val datadogPriority =
when (severity) {
Severity.Verbose -> android.util.Log.VERBOSE
Severity.Debug -> android.util.Log.DEBUG
Severity.Info -> android.util.Log.INFO
Severity.Warn -> android.util.Log.WARN
Severity.Error -> android.util.Log.ERROR
Severity.Assert -> android.util.Log.ASSERT
}
logger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
}
}
private fun String.extractSemanticVersion(): String {
val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?".toRegex()
val matchResult = regex.find(this)
return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") ?: this
}
override fun track(event: String, vararg properties: DataPair) {
if (!isFirebaseInitialized) return
val bundle = Bundle()
properties.forEach {
when (it.value) {
is Double -> bundle.putDouble(it.name, it.value)
is Int ->
bundle.putLong(it.name, it.value.toLong()) // Firebase expects Long for integer values in bundles
is Long -> bundle.putLong(it.name, it.value)
is Float -> bundle.putDouble(it.name, it.value.toDouble())
is String -> bundle.putString(it.name, it.value as String?) // Explicitly handle String
else -> bundle.putString(it.name, it.value.toString()) // Fallback for other types
}
KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : ${it.value})" }
}
Firebase.analytics.logEvent(event, bundle)
}
}

View file

@ -32,6 +32,7 @@ kotlin {
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.okio)
implementation(libs.kermit)
}
androidMain.dependencies {

View file

@ -14,44 +14,59 @@
* 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.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.devtools.ksp)
}
configure<LibraryExtension> { namespace = "org.meshtastic.core.data" }
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.data"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
dependencies {
api(projects.core.repository)
implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.network)
implementation(projects.core.prefs)
implementation(projects.core.proto)
sourceSets {
commonMain.dependencies {
api(projects.core.repository)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.network)
implementation(projects.core.prefs)
implementation(projects.core.proto)
// Needed because core:data references MeshtasticDatabase (supertype RoomDatabase)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.sqlite.bundled)
api(libs.javax.inject)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.paging.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.collections.immutable)
}
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.core.location.altitude)
implementation(libs.androidx.paging.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
androidMain.dependencies {
implementation(libs.hilt.android)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.location.altitude)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
// Needed because core:data references MeshtasticDatabase (supertype RoomDatabase)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.sqlite.bundled)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.mockk)
}
}
}
dependencies { add("kspAndroid", libs.hilt.compiler) }

View file

@ -2,10 +2,6 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$1000L</ID>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$30</ID>
<ID>MagicNumber:LocationRepository.kt$LocationRepository$31</ID>
<ID>TooGenericExceptionCaught:LocationRepository.kt$LocationRepository$e: Exception</ID>
<ID>TooManyFunctions:PacketRepository.kt$PacketRepository</ID>
<ID>MaxLineLength:BootloaderOtaQuirksJsonDataSourceImpl.kt$BootloaderOtaQuirksJsonDataSourceImpl$class</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import android.app.Application
@ -26,9 +25,10 @@ import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.BootloaderOtaQuirk
import javax.inject.Inject
class BootloaderOtaQuirksJsonDataSource @Inject constructor(private val application: Application) {
class BootloaderOtaQuirksJsonDataSourceImpl @Inject constructor(private val application: Application) :
BootloaderOtaQuirksJsonDataSource {
@OptIn(ExperimentalSerializationApi::class)
fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk> = runCatching {
override fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk> = runCatching {
val inputStream = application.assets.open("device_bootloader_ota_quirks.json")
inputStream.use { Json.decodeFromStream<ListWrapper>(it).devices }
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import android.app.Application
@ -24,7 +23,8 @@ import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.NetworkDeviceHardware
import javax.inject.Inject
class DeviceHardwareJsonDataSource @Inject constructor(private val application: Application) {
class DeviceHardwareJsonDataSourceImpl @Inject constructor(private val application: Application) :
DeviceHardwareJsonDataSource {
// Use a tolerant JSON parser so that additional fields in the bundled asset
// (e.g., "key") do not break deserialization on older app versions.
@ -35,7 +35,7 @@ class DeviceHardwareJsonDataSource @Inject constructor(private val application:
}
@OptIn(ExperimentalSerializationApi::class)
fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware> =
override fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware> =
application.assets.open("device_hardware.json").use { inputStream ->
json.decodeFromStream<List<NetworkDeviceHardware>>(inputStream)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import android.app.Application
@ -24,7 +23,8 @@ import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.NetworkFirmwareReleases
import javax.inject.Inject
class FirmwareReleaseJsonDataSource @Inject constructor(private val application: Application) {
class FirmwareReleaseJsonDataSourceImpl @Inject constructor(private val application: Application) :
FirmwareReleaseJsonDataSource {
// Match the network client behavior: be tolerant of unknown fields so that
// older app versions can read newer snapshots of firmware_releases.json.
@ -35,7 +35,7 @@ class FirmwareReleaseJsonDataSource @Inject constructor(private val application:
}
@OptIn(ExperimentalSerializationApi::class)
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases =
override fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases =
application.assets.open("firmware_releases.json").use { inputStream ->
json.decodeFromStream<NetworkFirmwareReleases>(inputStream)
}

View file

@ -20,6 +20,7 @@ import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.app.Application
import android.location.LocationManager
import android.os.Build
import androidx.annotation.RequiresPermission
import androidx.core.location.LocationCompat
import androidx.core.location.LocationListenerCompat
@ -29,55 +30,61 @@ import androidx.core.location.altitude.AltitudeConverterCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.Location
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.PlatformAnalytics
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocationRepository
class LocationRepositoryImpl
@Inject
constructor(
private val context: Application,
private val locationManager: dagger.Lazy<LocationManager>,
private val analytics: PlatformAnalytics,
private val dispatchers: CoroutineDispatchers,
) {
) : LocationRepository {
companion object {
private const val DEFAULT_INTERVAL_MS = 30_000L
private const val MIN_DISTANCE_METERS = 0f
private const val API_LEVEL_31 = 31
}
/** Status of whether the app is actively subscribed to location changes. */
private val _receivingLocationUpdates: MutableStateFlow<Boolean> = MutableStateFlow(false)
val receivingLocationUpdates: StateFlow<Boolean>
override val receivingLocationUpdates: StateFlow<Boolean>
get() = _receivingLocationUpdates
@RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION])
private fun LocationManager.requestLocationUpdates() = callbackFlow {
val intervalMs = 30 * 1000L // 30 seconds
val minDistanceM = 0f
private fun LocationManager.requestLocationUpdates(): Flow<Location> = callbackFlow {
val locationRequest =
LocationRequestCompat.Builder(intervalMs)
.setMinUpdateDistanceMeters(minDistanceM)
LocationRequestCompat.Builder(DEFAULT_INTERVAL_MS)
.setMinUpdateDistanceMeters(MIN_DISTANCE_METERS)
.setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY)
.build()
val locationListener = LocationListenerCompat { location ->
if (location.hasAltitude() && !LocationCompat.hasMslAltitude(location)) {
@Suppress("TooGenericExceptionCaught")
try {
AltitudeConverterCompat.addMslAltitudeToLocation(context, location)
} catch (e: Exception) {
Logger.e(e) { "addMslAltitudeToLocation() failed" }
}
}
// info("New location: $location")
trySend(location)
}
val providerList = buildList {
val providers = allProviders
if (android.os.Build.VERSION.SDK_INT >= 31 && LocationManager.FUSED_PROVIDER in providers) {
if (Build.VERSION.SDK_INT >= API_LEVEL_31 && LocationManager.FUSED_PROVIDER in providers) {
add(LocationManager.FUSED_PROVIDER)
} else {
if (LocationManager.GPS_PROVIDER in providers) add(LocationManager.GPS_PROVIDER)
@ -86,11 +93,13 @@ constructor(
}
Logger.i {
"Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m"
"Starting location updates with $providerList intervalMs=$DEFAULT_INTERVAL_MS " +
"and minDistanceM=$MIN_DISTANCE_METERS"
}
_receivingLocationUpdates.value = true
analytics.track("location_start") // Figure out how many users needed to use the phone GPS
analytics.track("location_start")
@Suppress("TooGenericExceptionCaught")
try {
providerList.forEach { provider ->
LocationManagerCompat.requestLocationUpdates(
@ -102,7 +111,7 @@ constructor(
)
}
} catch (e: Exception) {
close(e) // in case of exception, close the Flow
close(e)
}
awaitClose {
@ -116,5 +125,5 @@ constructor(
/** Observable flow for location updates */
@RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION])
fun getLocations() = locationManager.get().requestLocationUpdates()
override fun getLocations(): Flow<Location> = locationManager.get().requestLocationUpdates()
}

View file

@ -14,18 +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.core.data.model
package org.meshtastic.core.data.datasource
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
import org.meshtastic.core.model.BootloaderOtaQuirk
@Serializable
data class CustomTileProviderConfig(
val id: String = Uuid.random().toString(),
val name: String,
val urlTemplate: String,
val localUri: String? = null,
) {
val isLocal: Boolean
get() = localUri != null
interface BootloaderOtaQuirksJsonDataSource {
fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk>
}

View file

@ -14,21 +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.core.data.di
package org.meshtastic.core.data.datasource
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.meshtastic.core.model.NetworkDeviceHardware
@InstallIn(SingletonComponent::class)
@Module
interface DatabaseModule {
@Binds
@Singleton
fun bindDatabaseManager(
impl: org.meshtastic.core.database.DatabaseManager,
): org.meshtastic.core.common.database.DatabaseManager
interface DeviceHardwareJsonDataSource {
fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware>
}

View file

@ -0,0 +1,23 @@
/*
* 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.core.data.datasource
import org.meshtastic.core.model.NetworkFirmwareReleases
interface FirmwareReleaseJsonDataSource {
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import kotlinx.coroutines.flow.Flow

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -45,12 +46,10 @@ import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
@ -63,10 +62,10 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
override val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
override val neighborInfoStartTimes = ConcurrentHashMap<Int, Long>()
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
override val tracerouteStartTimes = mutableMapOf<Int, Long>()
override val neighborInfoStartTimes = mutableMapOf<Int, Long>()
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
@ -87,7 +86,7 @@ constructor(
override fun getCachedChannelSet(): ChannelSet = channelSet.value
override fun getCurrentPacketId(): Long = currentPacketId.get()
override fun getCurrentPacketId(): Long = currentPacketId.value
override fun generatePacketId(): Int {
val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
@ -96,7 +95,7 @@ constructor(
}
override fun setSessionPasskey(key: ByteString) {
sessionPasskey.set(key)
sessionPasskey.value = key
}
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
@ -167,7 +166,7 @@ constructor(
}
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.get())
val adminMsg = initFn().copy(session_passkey = sessionPasskey.value)
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
packetHandler.sendToRadio(packet)

View file

@ -21,8 +21,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
@ -34,6 +32,7 @@ import org.meshtastic.core.model.Position
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
@ -41,6 +40,7 @@ import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel

View file

@ -22,7 +22,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import okio.IOException
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
@ -31,6 +31,7 @@ import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
@ -39,7 +40,6 @@ import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo

View file

@ -28,8 +28,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@ -37,6 +35,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
@ -47,6 +46,7 @@ import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts

View file

@ -24,9 +24,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import okio.IOException
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@ -39,6 +41,7 @@ import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
@ -50,6 +53,7 @@ import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
@ -72,8 +76,6 @@ import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@ -355,11 +357,11 @@ constructor(
u.session_passkey.let { commandSender.setSessionPasskey(it) }
val fromNum = packet.from
u.get_module_config_response?.let { config ->
u.get_module_config_response?.let {
if (fromNum == myNodeNum) {
configHandler.get().handleModuleConfig(config)
configHandler.get().handleModuleConfig(it)
} else {
config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
@ -368,11 +370,11 @@ constructor(
u.get_channel_response?.let { configHandler.get().handleChannel(it) }
}
u.get_device_metadata_response?.let { metadata ->
u.get_device_metadata_response?.let {
if (fromNum == myNodeNum) {
configFlowManager.get().handleLocalMetadata(metadata)
configFlowManager.get().handleLocalMetadata(it)
} else {
nodeManager.insertMetadata(fromNum, metadata)
nodeManager.insertMetadata(fromNum, it)
}
}
}
@ -429,14 +431,20 @@ constructor(
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
scope.launch {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
}
}
} else {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
scope.launch {
batteryMutex.withLock {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
}
}
}
@ -451,7 +459,7 @@ constructor(
}
@Suppress("ReturnCount")
private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
var forceDisplay = false
@ -470,10 +478,12 @@ constructor(
}
if (shouldDisplay) {
val now = nowSeconds
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
return true
batteryMutex.withLock {
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
return true
}
}
}
return false
@ -775,6 +785,7 @@ constructor(
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
private val batteryPercentCooldowns = ConcurrentHashMap<Int, Long>()
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
}
}

View file

@ -24,6 +24,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@ -40,9 +43,6 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import java.util.ArrayDeque
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
@ -60,14 +60,17 @@ constructor(
private val fromRadioDispatcher: FromRadioPacketHandler,
) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val logUuidByPacketId = ConcurrentHashMap<Int, String>()
private val logInsertJobByPacketId = ConcurrentHashMap<Int, Job>()
private val earlyReceivedPackets = ArrayDeque<MeshPacket>()
private val mapsMutex = Mutex()
private val logUuidByPacketId = mutableMapOf<Int, String>()
private val logInsertJobByPacketId = mutableMapOf<Int, Job>()
private val earlyMutex = Mutex()
private val earlyReceivedPackets = kotlin.collections.ArrayDeque<MeshPacket>()
private val maxEarlyPacketBuffer = 10240
override fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } }
}
override fun start(scope: CoroutineScope) {
@ -91,8 +94,7 @@ constructor(
}
.onFailure { _ ->
Logger.e(primaryException) {
"Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " +
"Not a valid FromRadio or LogRecord."
"Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord."
}
}
}
@ -150,27 +152,33 @@ constructor(
if (nodeManager.isNodeDbReady.value) {
processReceivedMeshPacket(preparedPacket, myNodeNum)
} else {
synchronized(earlyReceivedPackets) {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
earlyReceivedPackets.removeFirst()
scope.launch {
earlyMutex.withLock {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
earlyReceivedPackets.removeFirstOrNull()
}
earlyReceivedPackets.addLast(preparedPacket)
}
earlyReceivedPackets.addLast(preparedPacket)
}
}
}
private fun flushEarlyReceivedPackets(reason: String) {
val packets =
synchronized(earlyReceivedPackets) {
if (earlyReceivedPackets.isEmpty()) return
val list = earlyReceivedPackets.toList()
earlyReceivedPackets.clear()
list
}
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
scope.launch {
val packets =
earlyMutex.withLock {
if (earlyReceivedPackets.isEmpty()) return@withLock emptyList<MeshPacket>()
val list = earlyReceivedPackets.toList()
earlyReceivedPackets.clear()
list
}
if (packets.isEmpty()) return@launch
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
}
}
@Suppress("LongMethod")
@ -187,8 +195,13 @@ constructor(
fromRadio = FromRadio(packet = packet),
)
val logJob = insertMeshLog(log)
logInsertJobByPacketId[packet.id] = logJob
logUuidByPacketId[packet.id] = log.uuid
scope.launch {
mapsMutex.withLock {
logInsertJobByPacketId[packet.id] = logJob
logUuidByPacketId[packet.id] = log.uuid
}
}
scope.handledLaunch { serviceRepository.emitMeshPacket(packet) }
@ -235,14 +248,15 @@ constructor(
try {
router.get().dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
} finally {
logUuidByPacketId.remove(packet.id)
logInsertJobByPacketId.remove(packet.id)
scope.launch {
mapsMutex.withLock {
logUuidByPacketId.remove(packet.id)
logInsertJobByPacketId.remove(packet.id)
}
}
}
}
}
private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) }
private fun ByteArray.toHexString(): String =
this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) }
}

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
import javax.inject.Singleton
@ -49,7 +48,7 @@ class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs
} else {
Regex("\\b${Regex.escape(word)}\\b", RegexOption.IGNORE_CASE)
}
} catch (e: PatternSyntaxException) {
} catch (e: IllegalArgumentException) {
Logger.w { "Invalid filter pattern: $word - ${e.message}" }
null
}

View file

@ -17,6 +17,9 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -42,21 +45,12 @@ import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
/**
* Implementation of [NodeManager] that maintains an in-memory database of the mesh.
*
* This component acts as the "brain" for node-related data during a connection session. It manages:
* 1. In-memory maps for fast node lookup by number or ID.
* 2. Synchronization of node data between the radio and the persistent database.
* 3. Processing of incoming node-related packets (User, Position, Telemetry).
* 4. Broadcasting changes to the rest of the application.
*/
/** Implementation of [NodeManager] that maintains an in-memory database of the mesh. */
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
class NodeManagerImpl
@ -68,8 +62,14 @@ constructor(
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override val nodeDBbyNodeNum = ConcurrentHashMap<Int, Node>()
override val nodeDBbyID = ConcurrentHashMap<String, Node>()
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
override val nodeDBbyNodeNum: Map<Int, Node>
get() = _nodeDBbyNodeNum.value
override val nodeDBbyID: Map<String, Node>
get() = _nodeDBbyID.value
override val isNodeDbReady = MutableStateFlow(false)
override val allowNodeDbWrites = MutableStateFlow(false)
@ -95,15 +95,17 @@ constructor(
override fun loadCachedNodeDB() {
scope.handledLaunch {
val nodes = nodeRepository.nodeDBbyNum.first()
nodeDBbyNodeNum.putAll(nodes)
nodes.values.forEach { nodeDBbyID[it.user.id] = it }
_nodeDBbyNodeNum.value = persistentMapOf<Int, Node>().putAll(nodes)
val byId = mutableMapOf<String, Node>()
nodes.values.forEach { byId[it.user.id] = it }
_nodeDBbyID.value = persistentMapOf<String, Node>().putAll(byId)
myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
}
}
override fun clear() {
nodeDBbyNodeNum.clear()
nodeDBbyID.clear()
_nodeDBbyNodeNum.value = persistentMapOf()
_nodeDBbyID.value = persistentMapOf()
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum = null
@ -111,7 +113,7 @@ constructor(
override fun getMyNodeInfo(): MyNodeInfo? {
val mi = nodeRepository.myNodeInfo.value ?: return null
val myNode = nodeDBbyNodeNum[mi.myNodeNum]
val myNode = _nodeDBbyNodeNum.value[mi.myNodeNum]
return MyNodeInfo(
myNodeNum = mi.myNodeNum,
hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
@ -132,34 +134,41 @@ constructor(
override fun getMyId(): String {
val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
return nodeDBbyNodeNum[num]?.user?.id ?: ""
return _nodeDBbyNodeNum.value[num]?.user?.id ?: ""
}
override fun getNodes(): List<NodeInfo> = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
override fun getNodes(): List<NodeInfo> = _nodeDBbyNodeNum.value.values.map { it.toNodeInfo() }
override fun removeByNodenum(nodeNum: Int) {
nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
val removed = atomic<Node?>(null)
_nodeDBbyNodeNum.update { map ->
val node = map[nodeNum]
removed.value = node
map.remove(nodeNum)
}
removed.value?.let { node -> _nodeDBbyID.update { it.remove(node.user.id) } }
}
fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeDBbyNodeNum.getOrPut(n) {
val userId = DataPacket.nodeNumToDefaultId(n)
val defaultUser =
User(
id = userId,
long_name = "Meshtastic ${userId.takeLast(n = 4)}",
short_name = userId.takeLast(n = 4),
hw_model = HardwareModel.UNSET,
)
internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = _nodeDBbyNodeNum.value[n]
?: run {
val userId = DataPacket.nodeNumToDefaultId(n)
val defaultUser =
User(
id = userId,
long_name = "Meshtastic ${userId.takeLast(n = 4)}",
short_name = userId.takeLast(n = 4),
hw_model = HardwareModel.UNSET,
)
Node(num = n, user = defaultUser, channel = channel)
}
Node(num = n, user = defaultUser, channel = channel)
}
override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) {
val current = nodeDBbyNodeNum[nodeNum] ?: getOrCreateNode(nodeNum, channel)
val next = transform(current)
nodeDBbyNodeNum[nodeNum] = next
val next = transform(_nodeDBbyNodeNum.value[nodeNum] ?: getOrCreateNode(nodeNum, channel))
_nodeDBbyNodeNum.update { it.put(nodeNum, next) }
if (next.user.id.isNotEmpty()) {
nodeDBbyID[next.user.id] = next
_nodeDBbyID.update { it.put(next.user.id, next) }
}
if (next.user.id.isNotEmpty() && isNodeDbReady.value) {
@ -252,7 +261,8 @@ constructor(
if (shouldPreserveExistingUser(node.user, user)) {
// keep existing names
} else {
var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
var newUser =
user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
if (info.via_mqtt) {
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
}
@ -292,7 +302,7 @@ constructor(
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
_nodeDBbyNodeNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
}
private fun Node.toNodeInfo(): NodeInfo = NodeInfo(

View file

@ -24,6 +24,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
@ -45,8 +48,6 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@ -72,8 +73,11 @@ constructor(
private var queueJob: Job? = null
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private val queuedPackets = ConcurrentLinkedQueue<MeshPacket>()
private val queueResponse = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
override fun start(scope: CoroutineScope) {
this.scope = scope
@ -103,8 +107,10 @@ constructor(
}
override fun sendToRadio(packet: MeshPacket) {
queuedPackets.add(packet)
startPacketQueue()
scope.launch {
queueMutex.withLock { queuedPackets.add(packet) }
startPacketQueue()
}
}
override fun stopPacketQueue() {
@ -112,9 +118,13 @@ constructor(
Logger.i { "Stopping packet queueJob" }
queueJob?.cancel()
queueJob = null
queuedPackets.clear()
queueResponse.entries.lastOrNull { !it.value.isCompleted }?.value?.complete(false)
queueResponse.clear()
scope.launch {
queueMutex.withLock { queuedPackets.clear() }
responseMutex.withLock {
queueResponse.values.lastOrNull { !it.isCompleted }?.complete(false)
queueResponse.clear()
}
}
}
}
@ -122,15 +132,20 @@ constructor(
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) }
if (success && isFull) return
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
scope.launch {
responseMutex.withLock {
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
}
}
}
}
override fun removeResponse(dataRequestId: Int, complete: Boolean) {
queueResponse.remove(dataRequestId)?.complete(complete)
scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } }
}
private fun startPacketQueue() {
@ -138,20 +153,27 @@ constructor(
queueJob =
scope.handledLaunch {
Logger.d { "packet queueJob started" }
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queuedPackets.poll() ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
} finally {
queueResponse.remove(packet.id)
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
} finally {
responseMutex.withLock { queueResponse.remove(packet.id) }
}
}
} finally {
queueJob = null
if (queueMutex.withLock { queuedPackets.isNotEmpty() }) {
startPacketQueue()
}
}
}
@ -177,9 +199,9 @@ constructor(
}
@Suppress("TooGenericExceptionCaught")
private fun sendPacket(packet: MeshPacket): CompletableDeferred<Boolean> {
private suspend fun sendPacket(packet: MeshPacket): CompletableDeferred<Boolean> {
val deferred = CompletableDeferred<Boolean>()
queueResponse[packet.id] = deferred
responseMutex.withLock { queueResponse[packet.id] = deferred }
try {
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlinx.coroutines.flow.flatMapLatest

View file

@ -31,7 +31,6 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
@ -47,6 +46,7 @@ import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts

View file

@ -28,7 +28,6 @@ import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.MeshDataMapper
@ -43,6 +42,7 @@ import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository

View file

@ -1,42 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import org.meshtastic.core.data.repository.CustomTileProviderRepository
import org.meshtastic.core.data.repository.CustomTileProviderRepositoryImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface GoogleDataModule {
@Binds
@Singleton
fun bindCustomTileProviderRepository(impl: CustomTileProviderRepositoryImpl): CustomTileProviderRepository
companion object {
@Provides @Singleton
fun provideJson(): Json = Json { prettyPrint = false }
}
}

View file

@ -1,107 +0,0 @@
/*
* 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.core.data.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapTileProviderPrefs
import javax.inject.Inject
import javax.inject.Singleton
interface CustomTileProviderRepository {
fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
suspend fun deleteCustomTileProvider(configId: String)
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
}
@Singleton
class CustomTileProviderRepositoryImpl
@Inject
constructor(
private val json: Json,
private val dispatchers: CoroutineDispatchers,
private val mapTileProviderPrefs: MapTileProviderPrefs,
) : CustomTileProviderRepository {
private val customTileProvidersStateFlow = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
init {
loadDataFromPrefs()
}
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
customTileProvidersStateFlow.asStateFlow()
override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value + config
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun deleteCustomTileProvider(configId: String) {
val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
customTileProvidersStateFlow.value.find { it.id == configId }
private fun loadDataFromPrefs() {
val jsonString = mapTileProviderPrefs.customTileProviders.value
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error deserializing tile providers" }
customTileProvidersStateFlow.value = emptyList()
}
} else {
customTileProvidersStateFlow.value = emptyList()
}
}
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
withContext(dispatchers.io) {
try {
val jsonString = json.encodeToString(providers)
mapTileProviderPrefs.setCustomTileProviders(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error serializing tile providers" }
}
}
}
}

View file

@ -1,165 +0,0 @@
/*
* 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.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import org.meshtastic.core.datastore.KEY_APP_INTRO_COMPLETED
import org.meshtastic.core.datastore.KEY_INCLUDE_UNKNOWN
import org.meshtastic.core.datastore.KEY_NODE_SORT
import org.meshtastic.core.datastore.KEY_ONLY_DIRECT
import org.meshtastic.core.datastore.KEY_ONLY_ONLINE
import org.meshtastic.core.datastore.KEY_SHOW_IGNORED
import org.meshtastic.core.datastore.KEY_THEME
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
import javax.inject.Qualifier
import javax.inject.Singleton
private const val USER_PREFERENCES_NAME = "user_preferences"
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class DataStoreScope
@InstallIn(SingletonComponent::class)
@Module
object DataStoreModule {
@Provides
@Singleton
@DataStoreScope
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Singleton
@Provides
fun providePreferencesDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
listOf(
SharedPreferencesMigration(context = appContext, sharedPreferencesName = USER_PREFERENCES_NAME),
SharedPreferencesMigration(
context = appContext,
sharedPreferencesName = "ui-prefs",
keysToMigrate =
setOf(
KEY_APP_INTRO_COMPLETED,
KEY_THEME,
KEY_NODE_SORT,
KEY_INCLUDE_UNKNOWN,
KEY_ONLY_ONLINE,
KEY_ONLY_DIRECT,
KEY_SHOW_IGNORED,
),
),
),
scope = scope,
produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES_NAME) },
)
@Singleton
@Provides
fun provideLocalConfigDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalConfig> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = LocalConfigSerializer,
producePath = { appContext.dataStoreFile("local_config.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
scope = scope,
)
@Singleton
@Provides
fun provideModuleConfigDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalModuleConfig> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = ModuleConfigSerializer,
producePath = { appContext.dataStoreFile("module_config.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
scope = scope,
)
@Singleton
@Provides
fun provideChannelSetDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<ChannelSet> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = ChannelSetSerializer,
producePath = { appContext.dataStoreFile("channel_set.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
)
@Singleton
@Provides
fun provideLocalStatsDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalStats> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = LocalStatsSerializer,
producePath = { appContext.dataStoreFile("local_stats.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
scope = scope,
)
}

View file

@ -1,38 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.data.datasource.SwitchingNodeInfoReadDataSource
import org.meshtastic.core.data.datasource.SwitchingNodeInfoWriteDataSource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface NodeDataSourceModule {
@Binds @Singleton
fun bindNodeInfoReadDataSource(impl: SwitchingNodeInfoReadDataSource): NodeInfoReadDataSource
@Binds @Singleton
fun bindNodeInfoWriteDataSource(impl: SwitchingNodeInfoWriteDataSource): NodeInfoWriteDataSource
}

View file

@ -1,157 +0,0 @@
/*
* 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.core.data.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.data.manager.CommandSenderImpl
import org.meshtastic.core.data.manager.FromRadioPacketHandlerImpl
import org.meshtastic.core.data.manager.HistoryManagerImpl
import org.meshtastic.core.data.manager.MeshActionHandlerImpl
import org.meshtastic.core.data.manager.MeshConfigFlowManagerImpl
import org.meshtastic.core.data.manager.MeshConfigHandlerImpl
import org.meshtastic.core.data.manager.MeshConnectionManagerImpl
import org.meshtastic.core.data.manager.MeshDataHandlerImpl
import org.meshtastic.core.data.manager.MeshMessageProcessorImpl
import org.meshtastic.core.data.manager.MeshRouterImpl
import org.meshtastic.core.data.manager.MessageFilterImpl
import org.meshtastic.core.data.manager.MqttManagerImpl
import org.meshtastic.core.data.manager.NeighborInfoHandlerImpl
import org.meshtastic.core.data.manager.NodeManagerImpl
import org.meshtastic.core.data.manager.PacketHandlerImpl
import org.meshtastic.core.data.manager.TracerouteHandlerImpl
import org.meshtastic.core.data.repository.DeviceHardwareRepositoryImpl
import org.meshtastic.core.data.repository.MeshLogRepositoryImpl
import org.meshtastic.core.data.repository.NodeRepositoryImpl
import org.meshtastic.core.data.repository.PacketRepositoryImpl
import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.TracerouteHandler
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindNodeRepository(nodeRepositoryImpl: NodeRepositoryImpl): NodeRepository
@Binds
@Singleton
abstract fun bindRadioConfigRepository(radioConfigRepositoryImpl: RadioConfigRepositoryImpl): RadioConfigRepository
@Binds
@Singleton
abstract fun bindDeviceHardwareRepository(
deviceHardwareRepositoryImpl: DeviceHardwareRepositoryImpl,
): DeviceHardwareRepository
@Binds @Singleton
abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository
@Binds
@Singleton
abstract fun bindMeshLogRepository(meshLogRepositoryImpl: MeshLogRepositoryImpl): MeshLogRepository
@Binds @Singleton
abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager
@Binds @Singleton
abstract fun bindCommandSender(commandSenderImpl: CommandSenderImpl): CommandSender
@Binds @Singleton
abstract fun bindHistoryManager(historyManagerImpl: HistoryManagerImpl): HistoryManager
@Binds
@Singleton
abstract fun bindTracerouteHandler(tracerouteHandlerImpl: TracerouteHandlerImpl): TracerouteHandler
@Binds
@Singleton
abstract fun bindNeighborInfoHandler(neighborInfoHandlerImpl: NeighborInfoHandlerImpl): NeighborInfoHandler
@Binds @Singleton
abstract fun bindMqttManager(mqttManagerImpl: MqttManagerImpl): MqttManager
@Binds @Singleton
abstract fun bindPacketHandler(packetHandlerImpl: PacketHandlerImpl): PacketHandler
@Binds
@Singleton
abstract fun bindMeshConnectionManager(meshConnectionManagerImpl: MeshConnectionManagerImpl): MeshConnectionManager
@Binds @Singleton
abstract fun bindMeshDataHandler(meshDataHandlerImpl: MeshDataHandlerImpl): MeshDataHandler
@Binds
@Singleton
abstract fun bindMeshActionHandler(meshActionHandlerImpl: MeshActionHandlerImpl): MeshActionHandler
@Binds
@Singleton
abstract fun bindMeshMessageProcessor(meshMessageProcessorImpl: MeshMessageProcessorImpl): MeshMessageProcessor
@Binds @Singleton
abstract fun bindMeshRouter(meshRouterImpl: MeshRouterImpl): MeshRouter
@Binds
@Singleton
abstract fun bindFromRadioPacketHandler(
fromRadioPacketHandlerImpl: FromRadioPacketHandlerImpl,
): FromRadioPacketHandler
@Binds
@Singleton
abstract fun bindMeshConfigHandler(meshConfigHandlerImpl: MeshConfigHandlerImpl): MeshConfigHandler
@Binds
@Singleton
abstract fun bindMeshConfigFlowManager(meshConfigFlowManagerImpl: MeshConfigFlowManagerImpl): MeshConfigFlowManager
@Binds @Singleton
abstract fun bindMessageFilter(messageFilterImpl: MessageFilterImpl): MessageFilter
companion object {
@Provides
@Singleton
fun provideMeshDataMapper(nodeManager: NodeManager): MeshDataMapper = MeshDataMapper(nodeManager)
}
}

View file

@ -1,45 +0,0 @@
/*
* 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.core.data.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {
@Provides
@Singleton
fun provideSendMessageUseCase(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
homoglyphEncodingPrefs: HomoglyphPrefs,
messageQueue: MessageQueue,
): SendMessageUseCase =
SendMessageUseCase(nodeRepository, packetRepository, radioController, homoglyphEncodingPrefs, messageQueue)
}

View file

@ -1,8 +1,5 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:Node.kt$Node$private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List&lt;String></ID>
<ID>TooGenericExceptionCaught:Converters.kt$Converters$ex: Exception</ID>
</CurrentIssues>
<CurrentIssues/>
</SmellBaseline>

View file

@ -27,15 +27,13 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.proto)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
api(libs.androidx.datastore)
api(libs.androidx.datastore.preferences)
api(libs.javax.inject)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
}
androidMain.dependencies {
implementation(libs.hilt.android)
implementation(libs.javax.inject)
}
androidMain.dependencies { implementation(libs.hilt.android) }
}
}

View file

@ -14,30 +14,20 @@
* 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
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins { alias(libs.plugins.meshtastic.kmp.library) }
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.di"
androidResources.enable = false
}
sourceSets {
commonMain.dependencies {
api(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
}
}
}
configure<LibraryExtension> { namespace = "org.meshtastic.core.di" }
dependencies { implementation(libs.androidx.work.runtime.ktx) }

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.di
import kotlinx.coroutines.CoroutineDispatcher

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.di
import javax.inject.Qualifier

View file

@ -1,40 +0,0 @@
/*
* 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.core.di
import android.content.Context
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
fun provideCoroutineDispatchers(): CoroutineDispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
}

View file

@ -16,28 +16,42 @@
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.flavors)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.devtools.ksp)
}
android { namespace = "org.meshtastic.core.domain" }
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.domain"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
dependencies {
implementation(projects.core.repository)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.resources)
sourceSets {
commonMain.dependencies {
implementation(projects.core.repository)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.resources)
implementation(libs.kermit)
implementation(libs.compose.multiplatform.resources)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
api(libs.javax.inject)
implementation(libs.kermit)
implementation(libs.compose.multiplatform.resources)
implementation(libs.okio)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.mockk)
}
}
}
dependencies { add("kspAndroid", libs.hilt.compiler) }

View file

@ -16,15 +16,16 @@
*/
package org.meshtastic.core.domain.usecase.settings
import android.icu.text.SimpleDateFormat
import kotlinx.coroutines.flow.first
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import okio.BufferedSink
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
import org.meshtastic.proto.Position as ProtoPosition
@ -37,14 +38,14 @@ constructor(
private val meshLogRepository: MeshLogRepository,
) {
/**
* Writes all persisted packet data to the provided [BufferedWriter].
* Writes all persisted packet data to the provided [BufferedSink].
*
* @param writer The writer to output the CSV data to.
* @param sink The sink to output the CSV data to.
* @param myNodeNum The node number of the current device.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod", "detekt:NestedBlockDepth")
suspend operator fun invoke(writer: BufferedWriter, myNodeNum: Int, filterPortnum: Int? = null) {
suspend operator fun invoke(sink: BufferedSink, myNodeNum: Int, filterPortnum: Int? = null) {
val nodes = nodeRepository.nodeDBbyNum.value
val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
@ -53,11 +54,10 @@ constructor(
val nodePositions = mutableMapOf<Int, ProtoPosition?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"",
sink.writeUtf8(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"\n",
)
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
@ -74,7 +74,10 @@ constructor(
(filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
proto.rx_snr != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val timeZone = TimeZone.currentSystemDefault()
val rxDateTimeObj = Instant.fromEpochMilliseconds(packet.received_date).toLocalDateTime(timeZone)
val timeString = rxDateTimeObj.time.toString().substringBefore('.')
val rxDateTime = "\"${rxDateTimeObj.date}\",\"$timeString\""
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.long_name ?: ""
@ -112,11 +115,12 @@ constructor(
}
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
sink.writeUtf8(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"\n",
)
}
}
}
sink.flush()
}
}

View file

@ -16,20 +16,21 @@
*/
package org.meshtastic.core.domain.usecase.settings
import okio.BufferedSink
import org.meshtastic.proto.DeviceProfile
import java.io.OutputStream
import javax.inject.Inject
/** Use case for exporting a device profile to an output stream. */
open class ExportProfileUseCase @Inject constructor() {
/**
* Exports the provided [DeviceProfile] to the given [OutputStream].
* Exports the provided [DeviceProfile] to the given [BufferedSink].
*
* @param outputStream The stream to write the profile to.
* @param sink The sink to write the profile to.
* @param profile The device profile to export.
* @return A [Result] indicating success or failure.
*/
operator fun invoke(outputStream: OutputStream, profile: DeviceProfile): Result<Unit> = runCatching {
outputStream.write(profile.encode())
operator fun invoke(sink: BufferedSink, profile: DeviceProfile): Result<Unit> = runCatching {
sink.write(profile.encode())
sink.flush()
}
}

View file

@ -16,43 +16,36 @@
*/
package org.meshtastic.core.domain.usecase.settings
import android.util.Base64
import org.json.JSONObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okio.BufferedSink
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.proto.Config
import java.io.OutputStream
import javax.inject.Inject
/** Use case for exporting security configuration to a JSON format. */
open class ExportSecurityConfigUseCase @Inject constructor() {
/**
* Exports the provided [Config.SecurityConfig] as a JSON string to the given [OutputStream].
* Exports the provided [Config.SecurityConfig] as a JSON string to the given [BufferedSink].
*
* @param outputStream The stream to write the JSON to.
* @param sink The sink to write the JSON to.
* @param securityConfig The security configuration to export.
* @return A [Result] indicating success or failure.
*/
operator fun invoke(outputStream: OutputStream, securityConfig: Config.SecurityConfig): Result<Unit> = runCatching {
val publicKeyBytes = securityConfig.public_key.toByteArray()
val privateKeyBytes = securityConfig.private_key.toByteArray()
// Convert byte arrays to Base64 strings
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
operator fun invoke(sink: BufferedSink, securityConfig: Config.SecurityConfig): Result<Unit> = runCatching {
// Convert ByteStrings to Base64 strings
val publicKeyBase64 = securityConfig.public_key.base64()
val privateKeyBase64 = securityConfig.private_key.base64()
// Create a JSON object
val jsonObject =
JSONObject().apply {
put("timestamp", nowMillis)
put("public_key", publicKeyBase64)
put("private_key", privateKeyBase64)
}
val jsonObject = buildJsonObject {
put("timestamp", nowMillis)
put("public_key", publicKeyBase64)
put("private_key", privateKeyBase64)
}
val jsonString = jsonObject.toString(JSON_INDENT_SPACES)
outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
}
private companion object {
private const val JSON_INDENT_SPACES = 4
val jsonString = jsonObject.toString()
sink.writeUtf8(jsonString)
sink.flush()
}
}

View file

@ -16,20 +16,20 @@
*/
package org.meshtastic.core.domain.usecase.settings
import okio.BufferedSource
import org.meshtastic.proto.DeviceProfile
import java.io.InputStream
import javax.inject.Inject
/** Use case for importing a device profile from an input stream. */
open class ImportProfileUseCase @Inject constructor() {
/**
* Imports a [DeviceProfile] from the provided [InputStream].
* Imports a [DeviceProfile] from the provided [BufferedSource].
*
* @param inputStream The stream to read the profile from.
* @param source The source to read the profile from.
* @return A [Result] containing the imported [DeviceProfile] or an error.
*/
operator fun invoke(inputStream: InputStream): Result<DeviceProfile> = runCatching {
val bytes = inputStream.readBytes()
operator fun invoke(source: BufferedSource): Result<DeviceProfile> = runCatching {
val bytes = source.readByteArray()
DeviceProfile.ADAPTER.decode(bytes)
}
}

View file

@ -24,11 +24,6 @@ import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.domain.FakeRadioController
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
@ -40,6 +35,11 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SendMessageUseCaseTest {
@ -50,7 +50,7 @@ class SendMessageUseCaseTest {
private lateinit var messageQueue: MessageQueue
private lateinit var useCase: SendMessageUseCase
@Before
@BeforeTest
fun setUp() {
nodeRepository = mockk(relaxed = true)
packetRepository = mockk(relaxed = true)
@ -70,7 +70,7 @@ class SendMessageUseCaseTest {
mockkConstructor(Capabilities::class)
}
@After
@AfterTest
fun tearDown() {
unmockkAll()
}

View file

@ -20,11 +20,11 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class AdminActionsUseCaseTest {
@ -32,7 +32,7 @@ class AdminActionsUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var useCase: AdminActionsUseCase
@Before
@BeforeTest
fun setUp() {
radioController = mockk(relaxed = true)
nodeRepository = mockk(relaxed = true)

View file

@ -20,12 +20,12 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.domain.FakeRadioController
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.days
class CleanNodeDatabaseUseCaseTest {
@ -34,7 +34,7 @@ class CleanNodeDatabaseUseCaseTest {
private lateinit var radioController: FakeRadioController
private lateinit var useCase: CleanNodeDatabaseUseCase
@Before
@BeforeTest
fun setUp() {
nodeRepository = mockk(relaxed = true)
radioController = FakeRadioController()

View file

@ -21,11 +21,8 @@ import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import okio.Buffer
import okio.ByteString.Companion.encodeUtf8
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshLogRepository
@ -35,18 +32,17 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
import org.robolectric.RobolectricTestRunner
import java.io.BufferedWriter
import java.io.StringWriter
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
@RunWith(RobolectricTestRunner::class)
class ExportDataUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var meshLogRepository: MeshLogRepository
private lateinit var useCase: ExportDataUseCase
@Before
@BeforeTest
fun setUp() {
nodeRepository = mockk(relaxed = true)
meshLogRepository = mockk(relaxed = true)
@ -82,17 +78,15 @@ class ExportDataUseCaseTest {
)
every { meshLogRepository.getAllLogsInReceiveOrder(any()) } returns flowOf(listOf(meshLog))
val stringWriter = StringWriter()
val bufferedWriter = BufferedWriter(stringWriter)
val buffer = Buffer()
// Act
useCase(bufferedWriter, myNodeNum)
bufferedWriter.flush()
useCase(buffer, myNodeNum)
// Assert
val output = stringWriter.toString()
assertTrue("Header should be present", output.contains("\"date\",\"time\",\"from\",\"sender name\""))
assertTrue("Sender name should be present", output.contains("Sender Name"))
assertTrue("Payload should be present", output.contains("Hello"))
val output = buffer.readUtf8()
assertTrue(output.contains("\"date\",\"time\",\"from\",\"sender name\""), "Header should be present")
assertTrue(output.contains("Sender Name"), "Sender name should be present")
assertTrue(output.contains("Hello"), "Payload should be present")
}
}

View file

@ -16,18 +16,18 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import okio.Buffer
import org.meshtastic.proto.DeviceProfile
import java.io.ByteArrayOutputStream
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertTrue
class ExportProfileUseCaseTest {
private lateinit var useCase: ExportProfileUseCase
@Before
@BeforeTest
fun setUp() {
useCase = ExportProfileUseCase()
}
@ -36,13 +36,13 @@ class ExportProfileUseCaseTest {
fun `invoke writes encoded profile to output stream`() {
// Arrange
val profile = DeviceProfile(long_name = "Export Node")
val outputStream = ByteArrayOutputStream()
val buffer = Buffer()
// Act
val result = useCase(outputStream, profile)
val result = useCase(buffer, profile)
// Assert
assertTrue(result.isSuccess)
assertArrayEquals(profile.encode(), outputStream.toByteArray())
assertContentEquals(profile.encode(), buffer.readByteArray())
}
}

View file

@ -16,23 +16,22 @@
*/
package org.meshtastic.core.domain.usecase.settings
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okio.Buffer
import okio.ByteString.Companion.toByteString
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.Config
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayOutputStream
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@RunWith(RobolectricTestRunner::class)
class ExportSecurityConfigUseCaseTest {
private lateinit var useCase: ExportSecurityConfigUseCase
@Before
@BeforeTest
fun setUp() {
useCase = ExportSecurityConfigUseCase()
}
@ -43,19 +42,19 @@ class ExportSecurityConfigUseCaseTest {
val publicKey = byteArrayOf(1, 2, 3).toByteString()
val privateKey = byteArrayOf(4, 5, 6).toByteString()
val config = Config.SecurityConfig(public_key = publicKey, private_key = privateKey)
val outputStream = ByteArrayOutputStream()
val buffer = Buffer()
// Act
val result = useCase(outputStream, config)
val result = useCase(buffer, config)
// Assert
assertTrue(result.isSuccess)
val json = JSONObject(outputStream.toString())
assertTrue(json.has("timestamp"))
assertTrue(json.has("public_key"))
assertTrue(json.has("private_key"))
val json = Json.parseToJsonElement(buffer.readUtf8()).jsonObject
assertTrue(json.containsKey("timestamp"))
assertTrue(json.containsKey("public_key"))
assertTrue(json.containsKey("private_key"))
// Check base64 values
assertEquals("AQID", json.getString("public_key"))
assertEquals("BAUG", json.getString("private_key"))
assertEquals("AQID", json["public_key"]?.jsonPrimitive?.content)
assertEquals("BAUG", json["private_key"]?.jsonPrimitive?.content)
}
}

View file

@ -16,18 +16,18 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import okio.Buffer
import org.meshtastic.proto.DeviceProfile
import java.io.ByteArrayInputStream
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ImportProfileUseCaseTest {
private lateinit var useCase: ImportProfileUseCase
@Before
@BeforeTest
fun setUp() {
useCase = ImportProfileUseCase()
}
@ -36,10 +36,10 @@ class ImportProfileUseCaseTest {
fun `invoke with valid data returns profile`() {
// Arrange
val profile = DeviceProfile(long_name = "Test Node")
val inputStream = ByteArrayInputStream(profile.encode())
val buffer = Buffer().write(profile.encode())
// Act
val result = useCase(inputStream)
val result = useCase(buffer)
// Assert
assertTrue(result.isSuccess)
@ -49,10 +49,10 @@ class ImportProfileUseCaseTest {
@Test
fun `invoke with invalid data returns failure`() {
// Arrange
val inputStream = ByteArrayInputStream(byteArrayOf(1, 2, 3))
val buffer = Buffer().write(byteArrayOf(1, 2, 3))
// Act
val result = useCase(inputStream)
val result = useCase(buffer)
// Assert
assertTrue(result.isFailure)

View file

@ -20,8 +20,6 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
@ -29,13 +27,15 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
class InstallProfileUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var useCase: InstallProfileUseCase
@Before
@BeforeTest
fun setUp() {
radioController = mockk(relaxed = true)
useCase = InstallProfileUseCase(radioController)

View file

@ -22,16 +22,16 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class IsOtaCapableUseCaseTest {
@ -44,7 +44,7 @@ class IsOtaCapableUseCaseTest {
private val ourNodeInfoFlow = MutableStateFlow<Node?>(null)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
@Before
@BeforeTest
fun setUp() {
nodeRepository = mockk { every { ourNodeInfo } returns ourNodeInfoFlow }
radioController = mockk { every { connectionState } returns connectionStateFlow }

View file

@ -18,16 +18,16 @@ package org.meshtastic.core.domain.usecase.settings
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.RadioController
import kotlin.test.BeforeTest
import kotlin.test.Test
class MeshLocationUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var useCase: MeshLocationUseCase
@Before
@BeforeTest
fun setUp() {
radioController = mockk(relaxed = true)
useCase = MeshLocationUseCase(radioController)

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