mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(ble): Centralize BLE logic into a core module (#4550)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
7a68802bc2
commit
6bfa5b5f70
214 changed files with 3471 additions and 2405 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -19,6 +19,7 @@ This file serves as a comprehensive guide for AI agents and developers working o
|
|||
| :--- | :--- |
|
||||
| `app/` | Main application module. Contains `MainActivity`, `AppNavigation`, and Hilt entry points. Uses package `com.geeksville.mesh`. |
|
||||
| `core/` | Shared library modules. Most code here uses package `org.meshtastic.core.*`. |
|
||||
| `core/ble/` | **New:** Bluetooth Low Energy stack using Nordic libraries. |
|
||||
| `core/strings/` | **Crucial:** Centralized string resources using Compose Multiplatform Resources. |
|
||||
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`). Each is a standalone Gradle module. Uses package `org.meshtastic.feature.*`. |
|
||||
| `build-logic/` | Custom Gradle convention plugins. Defines build logic for the entire project. |
|
||||
|
|
@ -58,13 +59,20 @@ This file serves as a comprehensive guide for AI agents and developers working o
|
|||
- Routes are defined in `core/navigation` (e.g., `ContactsRoutes`, `SettingsRoutes`).
|
||||
- The main `NavHost` is located in `app/src/main/java/com/geeksville/mesh/ui/Main.kt`.
|
||||
|
||||
### D. Dependency Management
|
||||
### D. Bluetooth (BLE)
|
||||
- **Library:** Uses **Nordic Semiconductor's Kotlin BLE Library** and **Android Common Libraries**.
|
||||
- **Location:** Core logic resides in `core/ble`.
|
||||
- **Key Classes:** `BluetoothRepository`, `NordicBleInterface`, `BleConnection`.
|
||||
- **Usage:** Use `BluetoothRepository` for scanning and bonding. Use `BleConnection` for managing connections. Avoid legacy `BluetoothAdapter` APIs directly.
|
||||
- **Environment Mocking:** Use `LocalEnvironmentOwner` and `MockAndroidEnvironment` to test UI hardware reactions without a real device.
|
||||
|
||||
### E. Dependency Management
|
||||
- **Never** hardcode versions in `build.gradle.kts` files.
|
||||
- **Action:** Add the library and version to `gradle/libs.versions.toml`.
|
||||
- **Action:** Apply plugins using the alias from the catalog (e.g., `alias(libs.plugins.meshtastic.android.library)`).
|
||||
- **Alpha Libraries:** Do not be shy about using alpha libraries from Google if they provide necessary features.
|
||||
|
||||
### E. Build Variants (Flavors)
|
||||
### F. Build Variants (Flavors)
|
||||
- **`google`**: Includes Google Play Services (Maps, Firebase, Crashlytics).
|
||||
- **`fdroid`**: FOSS version. **Strictly segregate sensitive data** (Crashlytics, Firebase, etc.) out of this flavor.
|
||||
- **Task Example:** `./gradlew assembleFdroidDebug`
|
||||
|
|
@ -83,7 +91,10 @@ This file serves as a comprehensive guide for AI agents and developers working o
|
|||
|
||||
### C. Testing
|
||||
- **Unit Tests:** JUnit 4/5 in `src/test/java`. Run with `./gradlew test`.
|
||||
- **UI Tests:** Espresso/Compose in `src/androidTest/java`. Run with `./gradlew connectedAndroidTest`.
|
||||
- **Compose UI Tests (JVM):** Preferred for component testing. Use **Robolectric** in `src/test/java`.
|
||||
- **Important:** Annotate with `@Config(sdk = [34])` if using Java 17 to avoid SDK 35 compatibility issues.
|
||||
- **Best Practice:** Pass mocked ViewModels to Composables instead of using Hilt in Robolectric tests.
|
||||
- **Instrumented Tests:** For full E2E or Hilt integration tests, use `src/androidTest/java`. Run with `./gradlew connectedAndroidTest`.
|
||||
- **Feature Test:** `./gradlew feature:settings:testGoogleDebug`
|
||||
|
||||
## 5. Agent Workflow
|
||||
|
|
|
|||
|
|
@ -19,15 +19,16 @@ Thank you for your interest in contributing to Meshtastic-Android! We welcome co
|
|||
- Write clear, descriptive variable and function names.
|
||||
- Add comments where necessary, especially for complex logic.
|
||||
- Keep methods and classes focused and concise.
|
||||
- Use localised strings; edit the English [`strings.xml`](app/src/main/res/values/strings.xml) file. CrowdIn will manage translations to other languages.
|
||||
- For example,
|
||||
|
||||
- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:strings`.
|
||||
- Do **not** use the legacy `app/src/main/res/values/strings.xml`.
|
||||
- **Definition:** Add strings to `core/strings/src/commonMain/composeResources/values/strings.xml`.
|
||||
- **Usage:**
|
||||
```kotlin
|
||||
// instead of hardcoding a string in your code:
|
||||
Text("Settings")
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.your_string_key
|
||||
|
||||
// use the localised string resource:
|
||||
Text(stringResource(R.string.settings))
|
||||
Text(text = stringResource(Res.string.your_string_key))
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
|
@ -43,18 +44,20 @@ Consistent linting helps keep the codebase clean and maintainable.
|
|||
|
||||
### Testing
|
||||
|
||||
Meshtastic-Android uses both unit tests and instrumented UI tests to ensure code quality and reliability.
|
||||
Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI tests to ensure code quality and reliability.
|
||||
|
||||
- **Unit tests** are located in `app/src/test/java/` and should be written for all new logic where possible.
|
||||
- **Instrumented tests** (including UI tests using Jetpack Compose) are located in `app/src/androidTest/java/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing).
|
||||
- **Unit tests** are located in the `src/test/` directory of each module.
|
||||
- **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**.
|
||||
- Note: If using Java 17, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues.
|
||||
- **Instrumented tests** (including full E2E UI tests) are located in `src/androidTest/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing).
|
||||
|
||||
#### Guidelines for Testing
|
||||
|
||||
- Add or update tests for any new features or bug fixes.
|
||||
- Ensure all tests pass by running:
|
||||
- `./gradlew test` for unit tests
|
||||
- `./gradlew test` for unit and Robolectric tests
|
||||
- `./gradlew connectedAndroidTest` for instrumented tests
|
||||
- For UI components, write Compose UI tests to verify user interactions and visual elements. See existing tests in `DebugFiltersTest.kt` for examples.
|
||||
- For UI components, write Robolectric Compose tests where possible for faster execution.
|
||||
- If your change is difficult to test, explain why in your pull request.
|
||||
|
||||
Comprehensive testing helps prevent regressions and ensures a stable experience for all users.
|
||||
|
|
@ -70,7 +73,7 @@ Comprehensive testing helps prevent regressions and ensures a stable experience
|
|||
- reserved (release, automation)
|
||||
- Ensure your branch is up to date with the latest `main` branch before submitting a PR.
|
||||
- Provide a meaningful title and description for your PR.
|
||||
- Inlude information on how to test and/or replicate if it is not obvious.
|
||||
- Include information on how to test and/or replicate if it is not obvious.
|
||||
- Include screenshots or logs if your change affects the UI or user experience.
|
||||
- Be responsive to feedback and make requested changes promptly.
|
||||
- Squash commits if requested by a maintainer.
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -56,6 +56,19 @@ You can generate the documentation locally to preview your changes.
|
|||
2. **View the output:**
|
||||
The generated HTML files will be located in the `app/build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Modern Android Development (MAD)
|
||||
The app follows modern Android development practices:
|
||||
- **UI:** Jetpack Compose (Material 3).
|
||||
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
|
||||
- **Dependency Injection:** Hilt.
|
||||
- **Navigation:** Type-Safe Navigation (Jetpack Navigation).
|
||||
- **Data Layer:** Repository pattern with Room (local DB), DataStore (prefs), and Protobuf (device comms).
|
||||
|
||||
### Bluetooth Low Energy (BLE)
|
||||
The BLE stack has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**. This provides a robust, Coroutine-based architecture for reliable device communication. See [core/ble/README.md](core/ble/README.md) for details.
|
||||
|
||||
## Translations
|
||||
|
||||
You can help translate the app into your native language using [Crowdin](https://crowdin.meshtastic.org/android).
|
||||
|
|
|
|||
|
|
@ -1,11 +1,53 @@
|
|||
# `:app`
|
||||
|
||||
## Overview
|
||||
The `:app` module is the entry point for the Meshtastic Android application. It orchestrates the various feature modules, manages global state, and provides the main UI shell.
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. `MainActivity` & `Main.kt`
|
||||
The single Activity of the application. It hosts the `NavHost` and manages the root UI structure (Navigation Bar, Rail, etc.).
|
||||
|
||||
### 2. `MeshService`
|
||||
The core background service that manages long-running communication with the mesh radio. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
|
||||
|
||||
### 3. Hilt Application
|
||||
`MeshUtilApplication` is the Hilt entry point, providing the global dependency injection container.
|
||||
|
||||
## Architecture
|
||||
The module primarily serves as a "glue" layer, connecting:
|
||||
- `core:*` modules for shared logic.
|
||||
- `feature:*` modules for specific user-facing screens.
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
<!--region graph-->
|
||||
```mermaid
|
||||
graph TB
|
||||
:app[app]:::null
|
||||
:app[app]:::android-application
|
||||
:app -.-> :core:analytics
|
||||
:app -.-> :core:ble
|
||||
:app -.-> :core:common
|
||||
:app -.-> :core:data
|
||||
:app -.-> :core:database
|
||||
:app -.-> :core:datastore
|
||||
:app -.-> :core:di
|
||||
:app -.-> :core:model
|
||||
:app -.-> :core:navigation
|
||||
:app -.-> :core:network
|
||||
:app -.-> :core:nfc
|
||||
:app -.-> :core:prefs
|
||||
:app -.-> :core:proto
|
||||
:app -.-> :core:service
|
||||
:app -.-> :core:strings
|
||||
:app -.-> :core:ui
|
||||
:app -.-> :core:barcode
|
||||
:app -.-> :feature:intro
|
||||
:app -.-> :feature:messaging
|
||||
:app -.-> :feature:map
|
||||
:app -.-> :feature:node
|
||||
:app -.-> :feature:settings
|
||||
:app -.-> :feature:firmware
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -141,7 +141,10 @@ configure<ApplicationExtension> {
|
|||
// Configure existing product flavors (defined by convention plugin)
|
||||
// with their dynamic version names.
|
||||
productFlavors {
|
||||
named("google") { versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) google" }
|
||||
named("google") {
|
||||
versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) google"
|
||||
manifestPlaceholders["MAPS_API_KEY"] = "dummy"
|
||||
}
|
||||
named("fdroid") { versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) fdroid" }
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +161,8 @@ configure<ApplicationExtension> {
|
|||
}
|
||||
}
|
||||
bundle { language { enableSplit = false } }
|
||||
|
||||
testOptions { unitTests { isIncludeAndroidResources = true } }
|
||||
}
|
||||
|
||||
secrets {
|
||||
|
|
@ -195,6 +200,7 @@ project.afterEvaluate {
|
|||
|
||||
dependencies {
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.ble)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
|
|
@ -225,9 +231,7 @@ dependencies {
|
|||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.runtime.livedata)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
|
|
@ -246,6 +250,11 @@ dependencies {
|
|||
implementation(libs.kermit)
|
||||
|
||||
implementation(libs.nordic.client.android)
|
||||
implementation(libs.nordic.common.core)
|
||||
implementation(libs.nordic.common.permissions.ble)
|
||||
implementation(libs.nordic.common.permissions.notification)
|
||||
implementation(libs.nordic.common.scanner.ble)
|
||||
implementation(libs.nordic.common.ui)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
|
|
@ -259,16 +268,20 @@ dependencies {
|
|||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.nordic.client.android.mock)
|
||||
androidTestImplementation(libs.nordic.core.mock)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.nordic.client.android.mock)
|
||||
testImplementation(libs.nordic.client.mock)
|
||||
testImplementation(libs.nordic.client.core.mock)
|
||||
testImplementation(libs.nordic.core.mock)
|
||||
testImplementation(libs.nordic.core.android.mock)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testImplementation(libs.androidx.test.ext.junit)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,8 @@
|
|||
android:hardwareAccelerated="true"
|
||||
android:theme="@style/SplashTheme"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<uses-library
|
||||
android:name="org.apache.http.legacy"
|
||||
|
|
|
|||
|
|
@ -26,17 +26,17 @@ import android.nfc.NdefMessage
|
|||
import android.nfc.NfcAdapter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.ReportDrawnWhen
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -46,7 +46,8 @@ import com.geeksville.mesh.model.UIViewModel
|
|||
import com.geeksville.mesh.ui.MainScreen
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
|
||||
import org.meshtastic.core.model.util.dispatchMeshtasticUri
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.strings.Res
|
||||
|
|
@ -58,59 +59,64 @@ import org.meshtastic.feature.intro.AppIntroductionScreen
|
|||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val model: UIViewModel by viewModels()
|
||||
|
||||
// This is aware of the Activity lifecycle and handles binding to the mesh service.
|
||||
/**
|
||||
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
|
||||
* itself as a LifecycleObserver in its init block.
|
||||
*/
|
||||
@Inject internal lateinit var meshServiceClient: MeshServiceClient
|
||||
|
||||
@Inject internal lateinit var uiPreferencesDataSource: UiPreferencesDataSource
|
||||
@Inject internal lateinit var androidEnvironment: AndroidEnvironment
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge(
|
||||
// Disable three-button navbar scrim on pre-Q devices
|
||||
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Disable three-button navbar scrim
|
||||
window.setNavigationBarContrastEnforced(false)
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val theme by model.theme.collectAsState()
|
||||
val theme by model.theme.collectAsStateWithLifecycle()
|
||||
val dynamic = theme == MODE_DYNAMIC
|
||||
val dark =
|
||||
when (theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> true
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> false
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect { AppCompatDelegate.setDefaultNightMode(theme) }
|
||||
}
|
||||
// Apply modern edge-to-edge drawing with theme-aware system bars
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
|
||||
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
|
||||
)
|
||||
|
||||
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
|
||||
if (appIntroCompleted) {
|
||||
MainScreen(uIViewModel = model)
|
||||
} else {
|
||||
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
|
||||
// Ensure the navigation bar remains seamless on modern Android versions
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
@Suppress("SpreadOperator")
|
||||
CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
|
||||
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
|
||||
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
|
||||
|
||||
// Signal to the system that the initial UI is "fully drawn"
|
||||
// once we've decided whether to show the intro or the main screen.
|
||||
ReportDrawnWhen { true }
|
||||
|
||||
if (appIntroCompleted) {
|
||||
MainScreen(uIViewModel = model)
|
||||
} else {
|
||||
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent
|
||||
addOnNewIntentListener { intent -> handleIntent(intent) }
|
||||
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +131,12 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
|
||||
val rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
|
||||
val rawMessages =
|
||||
IntentCompat.getParcelableArrayExtra(
|
||||
intent,
|
||||
NfcAdapter.EXTRA_NDEF_MESSAGES,
|
||||
NdefMessage::class.java,
|
||||
)
|
||||
if (rawMessages != null) {
|
||||
for (rawMsg in rawMessages) {
|
||||
val msg = rawMsg as NdefMessage
|
||||
|
|
|
|||
|
|
@ -17,21 +17,21 @@
|
|||
package com.geeksville.mesh
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity.BIND_ABOVE_CLIENT
|
||||
import androidx.appcompat.app.AppCompatActivity.BIND_AUTO_CREATE
|
||||
import android.content.Context.BIND_ABOVE_CLIENT
|
||||
import android.content.Context.BIND_AUTO_CREATE
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.android.BindFailedException
|
||||
import com.geeksville.mesh.android.ServiceClient
|
||||
import com.geeksville.mesh.concurrent.SequentialJob
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.service.startService
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.common.util.SequentialJob
|
||||
import org.meshtastic.core.service.BindFailedException
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.ServiceClient
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -84,6 +84,12 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
super.onStop(owner)
|
||||
Logger.d { "Lifecycle: ON_STOP" }
|
||||
close()
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
super.onDestroy(owner)
|
||||
Logger.d { "Lifecycle: ON_DESTROY" }
|
||||
|
|
@ -103,6 +109,6 @@ constructor(
|
|||
Logger.e { "Failed to start service from activity - but ignoring because bind will work: ${ex.message}" }
|
||||
}
|
||||
|
||||
connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT)
|
||||
connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE or BIND_ABOVE_CLIENT)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +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 com.geeksville.mesh.android
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.PrintWriter
|
||||
|
||||
/**
|
||||
* Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME)
|
||||
*
|
||||
* write strings to that file
|
||||
*/
|
||||
class DebugLogFile(context: Context, name: String) {
|
||||
val stream = FileOutputStream(File(context.getExternalFilesDir(null), name), true)
|
||||
val file = PrintWriter(stream)
|
||||
|
||||
fun close() {
|
||||
file.close()
|
||||
}
|
||||
|
||||
fun log(s: String) {
|
||||
file.println(s) // FIXME, optionally include timestamps
|
||||
file.flush() // for debugging
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME)
|
||||
*
|
||||
* write strings to that file
|
||||
*/
|
||||
class BinaryLogFile(context: Context, name: String) :
|
||||
FileOutputStream(File(context.getExternalFilesDir(null), name), true) {
|
||||
|
||||
}
|
||||
|
|
@ -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 com.geeksville.mesh.model
|
||||
|
||||
import android.hardware.usb.UsbManager
|
||||
|
|
@ -23,6 +22,8 @@ import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
|||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.BondState
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
|
||||
/**
|
||||
|
|
@ -33,34 +34,65 @@ import org.meshtastic.core.model.util.anonymize
|
|||
* @param name The display name of the device.
|
||||
* @param fullAddress The unique address of the device, prefixed with a type identifier.
|
||||
* @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB).
|
||||
* @param node The [Node] associated with this device, if found in the database.
|
||||
*/
|
||||
sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) {
|
||||
sealed class DeviceListEntry(
|
||||
open val name: String,
|
||||
open val fullAddress: String,
|
||||
open val bonded: Boolean,
|
||||
open val node: Node? = null,
|
||||
) {
|
||||
val address: String
|
||||
get() = fullAddress.substring(1)
|
||||
|
||||
abstract fun copy(node: Node?): DeviceListEntry
|
||||
|
||||
override fun toString(): String =
|
||||
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)"
|
||||
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
data class Ble(val peripheral: Peripheral) :
|
||||
data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
|
||||
DeviceListEntry(
|
||||
name = peripheral.name ?: "unnamed-${peripheral.address}",
|
||||
fullAddress = "x${peripheral.address}",
|
||||
bonded = peripheral.bondState.value == BondState.BONDED,
|
||||
)
|
||||
node = node,
|
||||
) {
|
||||
override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
|
||||
}
|
||||
|
||||
data class Usb(
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val usbManager: UsbManager,
|
||||
val driver: UsbSerialDriver,
|
||||
override val node: Node? = null,
|
||||
) : DeviceListEntry(
|
||||
name = driver.device.deviceName,
|
||||
fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName),
|
||||
bonded = usbManager.hasPermission(driver.device),
|
||||
)
|
||||
node = node,
|
||||
) {
|
||||
override fun copy(node: Node?): Usb =
|
||||
copy(radioInterfaceService = radioInterfaceService, usbManager = usbManager, driver = driver, node = node)
|
||||
}
|
||||
|
||||
data class Tcp(override val name: String, override val fullAddress: String) :
|
||||
DeviceListEntry(name, fullAddress, true)
|
||||
data class Tcp(override val name: String, override val fullAddress: String, override val node: Node? = null) :
|
||||
DeviceListEntry(name, fullAddress, true, node) {
|
||||
override fun copy(node: Node?): Tcp = copy(name = name, fullAddress = fullAddress, node = node)
|
||||
}
|
||||
|
||||
data class Mock(override val name: String) : DeviceListEntry(name, "m", true)
|
||||
data class Mock(override val name: String, override val node: Node? = null) :
|
||||
DeviceListEntry(name, "m", true, node) {
|
||||
override fun copy(node: Node?): Mock = copy(name = name, node = node)
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches names like Meshtastic_1234. */
|
||||
private val bleNameRegex = Regex(BLE_NAME_PATTERN)
|
||||
|
||||
/**
|
||||
* Returns the short name of the device if it's a Meshtastic device, otherwise null.
|
||||
*
|
||||
* @return The short name (e.g., 1234) or null.
|
||||
*/
|
||||
fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }
|
||||
|
|
|
|||
|
|
@ -16,12 +16,9 @@
|
|||
*/
|
||||
package com.geeksville.mesh.model
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import co.touchlab.kermit.Logger
|
||||
|
|
@ -72,42 +69,11 @@ import org.meshtastic.proto.ClientNotification
|
|||
import org.meshtastic.proto.SharedContact
|
||||
import javax.inject.Inject
|
||||
|
||||
// Given a human name, strip out the first letter of the first three words and return that as the
|
||||
// initials for
|
||||
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
|
||||
// name and if the result is 3 or more characters, use the first three characters. If not, just take
|
||||
// the first 3 characters of the original name.
|
||||
fun getInitials(fullName: String): String {
|
||||
val maxInitialLength = 4
|
||||
val minWordCountForInitials = 2
|
||||
val name = fullName.trim().withoutEmojis()
|
||||
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
|
||||
val initials =
|
||||
when (words.size) {
|
||||
in 0 until minWordCountForInitials -> {
|
||||
val nameWithoutVowels =
|
||||
if (name.isNotEmpty()) {
|
||||
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
|
||||
}
|
||||
|
||||
else -> words.map { it.first() }.joinToString("")
|
||||
}
|
||||
return initials.take(maxInitialLength)
|
||||
}
|
||||
|
||||
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
|
||||
@HiltViewModel
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
class UIViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val app: Application,
|
||||
private val nodeDB: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
radioInterfaceService: RadioInterfaceService,
|
||||
|
|
@ -293,8 +259,7 @@ constructor(
|
|||
serviceRepository.clearTracerouteResponse()
|
||||
}
|
||||
|
||||
val neighborInfoResponse: LiveData<String?>
|
||||
get() = serviceRepository.neighborInfoResponse.asLiveData()
|
||||
val neighborInfoResponse: StateFlow<String?> = serviceRepository.neighborInfoResponse
|
||||
|
||||
fun clearNeighborInfoResponse() {
|
||||
serviceRepository.clearNeighborInfoResponse()
|
||||
|
|
@ -1,55 +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 com.geeksville.mesh.repository.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import com.geeksville.mesh.util.exceptionReporter
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A helper class to call onChanged when bluetooth is enabled or disabled
|
||||
*/
|
||||
class BluetoothBroadcastReceiver @Inject constructor(
|
||||
private val bluetoothRepository: BluetoothRepository
|
||||
) : BroadcastReceiver() {
|
||||
internal val intentFilter get() = IntentFilter().apply {
|
||||
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
|
||||
if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
|
||||
when (intent.bluetoothAdapterState) {
|
||||
// Simulate a disconnection if the user disables bluetooth entirely
|
||||
BluetoothAdapter.STATE_OFF -> bluetoothRepository.refreshState()
|
||||
BluetoothAdapter.STATE_ON -> bluetoothRepository.refreshState()
|
||||
}
|
||||
}
|
||||
if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
|
||||
bluetoothRepository.refreshState()
|
||||
}
|
||||
}
|
||||
|
||||
private val Intent.bluetoothAdapterState: Int
|
||||
get() = getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)
|
||||
}
|
||||
|
|
@ -1,49 +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 com.geeksville.mesh.repository.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.geeksville.mesh.util.registerReceiverCompat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
@RequiresPermission("android.permission.BLUETOOTH_CONNECT")
|
||||
internal fun BluetoothDevice.createBond(context: Context): Flow<Int> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
|
||||
trySend(state)
|
||||
|
||||
// we stay registered until bonding completes (either with BONDED or NONE)
|
||||
if (state != BluetoothDevice.BOND_BONDING) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
context.registerReceiverCompat(receiver, filter)
|
||||
createBond()
|
||||
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@
|
|||
package com.geeksville.mesh.repository.network
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.util.ignoreException
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
|
@ -31,6 +30,7 @@ import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
|
|||
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
|
||||
import org.eclipse.paho.client.mqttv3.MqttMessage
|
||||
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.model.util.subscribeList
|
||||
|
|
|
|||
|
|
@ -17,16 +17,16 @@
|
|||
package com.geeksville.mesh.repository.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.model.getInitials
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.delay
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.nowSeconds
|
||||
import org.meshtastic.core.model.util.getInitials
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Data
|
||||
|
|
|
|||
|
|
@ -18,15 +18,10 @@ package com.geeksville.mesh.repository.radio
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMNUM_CHARACTER
|
||||
import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMRADIO_CHARACTER
|
||||
import com.geeksville.mesh.repository.radio.BleConstants.BTM_LOGRADIO_CHARACTER
|
||||
import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID
|
||||
import com.geeksville.mesh.repository.radio.BleConstants.BTM_TORADIO_CHARACTER
|
||||
import com.geeksville.mesh.service.RadioNotConnectedException
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -36,26 +31,38 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
|
||||
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import java.util.UUID
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.toKotlinUuid
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleError
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.retryBleOperation
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 1000L
|
||||
private const val CONNECTION_TIMEOUT_MS = 15_000L
|
||||
private val SCAN_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* A [IRadioInterface] implementation for BLE devices using Nordic Kotlin BLE Library.
|
||||
|
|
@ -82,7 +89,7 @@ constructor(
|
|||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
serviceScope.launch {
|
||||
try {
|
||||
peripheral?.disconnect()
|
||||
bleConnection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
|
||||
}
|
||||
|
|
@ -90,11 +97,12 @@ constructor(
|
|||
service.onDisconnect(BleError.from(throwable))
|
||||
}
|
||||
|
||||
private val connectionScope = CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
|
||||
private val drainMutex = Mutex()
|
||||
private val writeMutex = Mutex()
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
|
||||
private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
|
||||
private val drainMutex: Mutex = Mutex()
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
private var peripheral: Peripheral? = null
|
||||
private var connectionStartTime: Long = 0
|
||||
private var packetsReceived: Int = 0
|
||||
private var packetsSent: Int = 0
|
||||
|
|
@ -150,10 +158,8 @@ constructor(
|
|||
|
||||
private suspend fun drainPacketQueueAndDispatch() {
|
||||
drainMutex.withLock {
|
||||
var drainedCount = 0
|
||||
fromRadioPacketFlow()
|
||||
.onEach { packet ->
|
||||
drainedCount++
|
||||
Logger.d { "[$address] Read packet from queue (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
|
|
@ -164,217 +170,181 @@ constructor(
|
|||
|
||||
// --- Connection & Discovery Logic ---
|
||||
|
||||
private fun findPeripheral(): Peripheral =
|
||||
centralManager.getBondedPeripherals().firstOrNull { it.address == address }
|
||||
?: throw RadioNotConnectedException("Device not found at address $address")
|
||||
/** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
|
||||
private suspend fun findPeripheral(): Peripheral {
|
||||
centralManager
|
||||
.getBondedPeripherals()
|
||||
.firstOrNull { it.address == address }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning..." }
|
||||
val scanner = BleScanner(centralManager)
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
val p = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
|
||||
if (p != null) return p
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
delay(SCAN_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
throw RadioNotConnectedException("Device not found at address $address")
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
connectionScope.launch {
|
||||
try {
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started at $connectionStartTime" }
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
|
||||
peripheral = retryCall { findAndConnectPeripheral() }
|
||||
peripheral?.let {
|
||||
val connectionTime = nowMillis - connectionStartTime
|
||||
Logger.i { "[$address] BLE peripheral connected in ${connectionTime}ms" }
|
||||
onConnected()
|
||||
observePeripheralChanges()
|
||||
discoverServicesAndSetupCharacteristics(it)
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
if (state is ConnectionState.Disconnected) {
|
||||
onDisconnected(state)
|
||||
}
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
|
||||
val p = retryBleOperation(tag = address) { findPeripheral() }
|
||||
val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
|
||||
if (state !is ConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
onConnected()
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
// BLE connection errors are common and often transient
|
||||
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findAndConnectPeripheral(): Peripheral {
|
||||
val p = findPeripheral()
|
||||
centralManager.connect(
|
||||
peripheral = p,
|
||||
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
p.requestConnectionPriority(ConnectionPriority.HIGH)
|
||||
return p
|
||||
}
|
||||
|
||||
private suspend fun onConnected() {
|
||||
try {
|
||||
peripheral?.let { p ->
|
||||
val rssi = retryCall { p.readRssi() }
|
||||
Logger.d { "[$address] Connection established. RSSI: $rssi dBm" }
|
||||
|
||||
val phyInUse = retryCall { p.readPhy() }
|
||||
Logger.d { "[$address] PHY in use: $phyInUse" }
|
||||
bleConnection.peripheral?.let { p ->
|
||||
val rssi = retryBleOperation(tag = address) { p.readRssi() }
|
||||
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to read initial connection properties" }
|
||||
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun observePeripheralChanges() {
|
||||
peripheral?.let { p ->
|
||||
p.phy.onEach { phy -> Logger.i { "[$address] BLE PHY changed to $phy" } }.launchIn(connectionScope)
|
||||
private fun onDisconnected(state: ConnectionState.Disconnected) {
|
||||
clearCharacteristics()
|
||||
|
||||
p.connectionParameters
|
||||
.onEach { params -> Logger.i { "[$address] BLE connection parameters changed to $params" } }
|
||||
.launchIn(connectionScope)
|
||||
|
||||
p.state
|
||||
.onEach { state ->
|
||||
Logger.i { "[$address] BLE connection state changed to $state" }
|
||||
if (state is ConnectionState.Disconnected) {
|
||||
clearCharacteristics()
|
||||
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] BLE disconnected - Reason: ${state.reason}, " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
service.onDisconnect(BleError.Disconnected(reason = state.reason))
|
||||
}
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] BLE disconnected - Reason: ${state.reason}, " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
centralManager.state
|
||||
.onEach { state -> Logger.i { "[$address] CentralManager state changed to $state" } }
|
||||
.launchIn(connectionScope)
|
||||
service.onDisconnect(BleError.Disconnected(reason = state.reason))
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun discoverServicesAndSetupCharacteristics(peripheral: Peripheral) {
|
||||
connectionScope.launch {
|
||||
peripheral
|
||||
.services(listOf(BTM_SERVICE_UUID.toKotlinUuid()))
|
||||
.onEach { services ->
|
||||
val meshtasticService = services?.find { it.uuid == BTM_SERVICE_UUID.toKotlinUuid() }
|
||||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
try {
|
||||
val chars =
|
||||
bleConnection.discoverCharacteristics(
|
||||
SERVICE_UUID,
|
||||
listOf(
|
||||
TORADIO_CHARACTERISTIC,
|
||||
FROMNUM_CHARACTERISTIC,
|
||||
FROMRADIO_CHARACTERISTIC,
|
||||
LOGRADIO_CHARACTERISTIC,
|
||||
),
|
||||
)
|
||||
|
||||
if (meshtasticService != null) {
|
||||
toRadioCharacteristic =
|
||||
meshtasticService.characteristics.find { it.uuid == BTM_TORADIO_CHARACTER.toKotlinUuid() }
|
||||
fromNumCharacteristic =
|
||||
meshtasticService.characteristics.find { it.uuid == BTM_FROMNUM_CHARACTER.toKotlinUuid() }
|
||||
fromRadioCharacteristic =
|
||||
meshtasticService.characteristics.find { it.uuid == BTM_FROMRADIO_CHARACTER.toKotlinUuid() }
|
||||
logRadioCharacteristic =
|
||||
meshtasticService.characteristics.find { it.uuid == BTM_LOGRADIO_CHARACTER.toKotlinUuid() }
|
||||
if (chars != null) {
|
||||
toRadioCharacteristic = chars[TORADIO_CHARACTERISTIC]
|
||||
fromNumCharacteristic = chars[FROMNUM_CHARACTERISTIC]
|
||||
fromRadioCharacteristic = chars[FROMRADIO_CHARACTERISTIC]
|
||||
logRadioCharacteristic = chars[LOGRADIO_CHARACTERISTIC]
|
||||
|
||||
if (
|
||||
listOf(toRadioCharacteristic, fromNumCharacteristic, fromRadioCharacteristic).all {
|
||||
it != null
|
||||
}
|
||||
) {
|
||||
Logger.d {
|
||||
"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}"
|
||||
}
|
||||
Logger.d {
|
||||
"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"
|
||||
}
|
||||
Logger.d {
|
||||
"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"
|
||||
}
|
||||
Logger.d {
|
||||
"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"
|
||||
}
|
||||
setupNotifications()
|
||||
service.onConnect()
|
||||
} else {
|
||||
Logger.w { "[$address] Discovery failed: missing required characteristics" }
|
||||
service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
|
||||
}
|
||||
} else {
|
||||
Logger.w { "[$address] Discovery failed: Meshtastic service not found" }
|
||||
service.onDisconnect(BleError.DiscoveryFailed("Meshtastic service not found"))
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Service discovery failed" }
|
||||
try {
|
||||
peripheral.disconnect()
|
||||
} catch (e2: Exception) {
|
||||
Logger.w(e2) { "[$address] Failed to disconnect in discovery catch" }
|
||||
}
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
Logger.d { "[$address] Characteristics discovered successfully" }
|
||||
setupNotifications()
|
||||
service.onConnect()
|
||||
} else {
|
||||
Logger.w { "[$address] Discovery failed: missing required characteristics" }
|
||||
service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Service discovery failed" }
|
||||
bleConnection.disconnect()
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification Setup ---
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private suspend fun setupNotifications() {
|
||||
retryCall { fromNumCharacteristic?.subscribe() }
|
||||
?.onStart { Logger.d { "[$address] Subscribing to fromNumCharacteristic" } }
|
||||
val fromNumReady = CompletableDeferred<Unit>()
|
||||
val logRadioReady = CompletableDeferred<Unit>()
|
||||
|
||||
fromNumCharacteristic
|
||||
?.subscribe {
|
||||
Logger.d { "[$address] FromNum subscription active" }
|
||||
fromNumReady.complete(Unit)
|
||||
}
|
||||
?.onEach { notifyBytes ->
|
||||
Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" }
|
||||
connectionScope.launch { drainPacketQueueAndDispatch() }
|
||||
}
|
||||
?.catch { e ->
|
||||
Logger.w(e) { "[$address] Error subscribing to fromNumCharacteristic" }
|
||||
if (!fromNumReady.isCompleted) fromNumReady.completeExceptionally(e)
|
||||
Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" }
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
?.launchIn(scope = connectionScope)
|
||||
?.launchIn(connectionScope) ?: fromNumReady.complete(Unit)
|
||||
|
||||
retryCall { logRadioCharacteristic?.subscribe() }
|
||||
?.onStart { Logger.d { "[$address] Subscribing to logRadioCharacteristic" } }
|
||||
logRadioCharacteristic
|
||||
?.subscribe {
|
||||
Logger.d { "[$address] LogRadio subscription active" }
|
||||
logRadioReady.complete(Unit)
|
||||
}
|
||||
?.onEach { notifyBytes ->
|
||||
Logger.d { "[$address] LogRadio Notification (${notifyBytes.size} bytes), dispatching packet" }
|
||||
dispatchPacket(notifyBytes)
|
||||
}
|
||||
?.catch { e ->
|
||||
Logger.w(e) { "[$address] Error subscribing to logRadioCharacteristic" }
|
||||
if (!logRadioReady.isCompleted) logRadioReady.completeExceptionally(e)
|
||||
Logger.w(e) { "[$address] Error in logRadioCharacteristic subscription" }
|
||||
service.onDisconnect(BleError.from(e))
|
||||
}
|
||||
?.launchIn(scope = connectionScope)
|
||||
}
|
||||
?.launchIn(connectionScope) ?: logRadioReady.complete(Unit)
|
||||
|
||||
private suspend fun <T> retryCall(block: suspend () -> T): T {
|
||||
var currentAttempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
currentAttempt++
|
||||
if (currentAttempt >= RETRY_COUNT) {
|
||||
Logger.w(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" }
|
||||
throw e
|
||||
}
|
||||
Logger.w(e) {
|
||||
"[$address] BLE operation failed (attempt $currentAttempt/$RETRY_COUNT), " +
|
||||
"retrying in ${RETRY_DELAY_MS}ms..."
|
||||
}
|
||||
delay(RETRY_DELAY_MS)
|
||||
try {
|
||||
withTimeout(CONNECTION_TIMEOUT_MS) {
|
||||
fromNumReady.await()
|
||||
logRadioReady.await()
|
||||
}
|
||||
Logger.d { "[$address] All notifications successfully subscribed" }
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "[$address] Timeout or error waiting for characteristic subscriptions" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// --- IRadioInterface Implementation ---
|
||||
|
||||
/**
|
||||
* Sends a packet to the radio.
|
||||
* Sends a packet to the radio with retry support.
|
||||
*
|
||||
* @param p The packet to send.
|
||||
*/
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
toRadioCharacteristic?.let { characteristic ->
|
||||
if (peripheral == null) {
|
||||
Logger.w { "[$address] BLE peripheral is null, cannot send packet" }
|
||||
return@let
|
||||
}
|
||||
connectionScope.launch {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
|
|
@ -384,14 +354,15 @@ constructor(
|
|||
} else {
|
||||
WriteType.WITH_RESPONSE
|
||||
}
|
||||
retryCall {
|
||||
packetsSent++
|
||||
bytesSent += p.size
|
||||
Logger.d {
|
||||
"[$address] Writing packet #$packetsSent to toRadioCharacteristic with $writeType - " +
|
||||
"${p.size} bytes (Total TX: $bytesSent bytes)"
|
||||
}
|
||||
characteristic.write(p, writeType = writeType)
|
||||
|
||||
retryBleOperation(tag = address) { characteristic.write(p, writeType = writeType) }
|
||||
|
||||
packetsSent++
|
||||
bytesSent += p.size
|
||||
Logger.d {
|
||||
"[$address] Successfully wrote packet #$packetsSent " +
|
||||
"to toRadioCharacteristic with $writeType - " +
|
||||
"${p.size} bytes (Total TX: $bytesSent bytes)"
|
||||
}
|
||||
drainPacketQueueAndDispatch()
|
||||
} catch (e: InvalidAttributeException) {
|
||||
|
|
@ -429,7 +400,7 @@ constructor(
|
|||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
connectionScope.cancel()
|
||||
peripheral?.disconnect()
|
||||
bleConnection.disconnect()
|
||||
service.onDisconnect(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -445,18 +416,4 @@ constructor(
|
|||
fromRadioCharacteristic = null
|
||||
logRadioCharacteristic = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RETRY_COUNT = 3
|
||||
private const val RETRY_DELAY_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
object BleConstants {
|
||||
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
|
||||
val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
|
||||
val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7")
|
||||
val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453")
|
||||
val BTM_FROMRADIO_CHARACTER: UUID = UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002")
|
||||
val BTM_LOGRADIO_CHARACTER: UUID = UUID.fromString("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 +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 com.geeksville.mesh.repository.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
|
|||
|
|
@ -22,19 +22,11 @@ import androidx.lifecycle.Lifecycle
|
|||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.android.BinaryLogFile
|
||||
import com.geeksville.mesh.android.BuildUtils
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.util.ignoreException
|
||||
import com.geeksville.mesh.util.toRemoteExceptions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -43,11 +35,19 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.android.common.core.simpleSharedFlow
|
||||
import org.meshtastic.core.analytics.platform.PlatformAnalytics
|
||||
import org.meshtastic.core.ble.BleError
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.util.BinaryLogFile
|
||||
import org.meshtastic.core.common.util.BuildUtils
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toRemoteExceptions
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.di.ProcessLifecycle
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
|
|
@ -82,10 +82,10 @@ constructor(
|
|||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>()
|
||||
private val _receivedData = simpleSharedFlow<ByteArray>()
|
||||
val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _connectionError = MutableSharedFlow<BleError>()
|
||||
private val _connectionError = simpleSharedFlow<BleError>()
|
||||
val connectionError: SharedFlow<BleError> = _connectionError.asSharedFlow()
|
||||
|
||||
// Thread-safe StateFlow for tracking device address changes
|
||||
|
|
@ -371,12 +371,7 @@ constructor(
|
|||
serviceScope.handledLaunch { handleSendToRadio(a) }
|
||||
}
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
replay = 0, // No replay needed for event-like emissions
|
||||
extraBufferCapacity = 1, // Buffer one event to avoid loss on rapid emissions
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, // Drop oldest if buffer overflows
|
||||
)
|
||||
private val _meshActivity = simpleSharedFlow<MeshActivity>()
|
||||
val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private fun emitSendActivity() {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import com.geeksville.mesh.repository.usb.SerialConnectionListener
|
|||
import com.geeksville.mesh.repository.usb.UsbRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** An interface that assumes we are talking to a meshtastic device via USB serial */
|
||||
|
|
|
|||
|
|
@ -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 com.geeksville.mesh.repository.radio
|
||||
|
||||
import android.hardware.usb.UsbManager
|
||||
|
|
@ -22,24 +21,18 @@ import com.geeksville.mesh.repository.usb.UsbRepository
|
|||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Serial/USB interface backend implementation.
|
||||
*/
|
||||
class SerialInterfaceSpec @Inject constructor(
|
||||
/** Serial/USB interface backend implementation. */
|
||||
class SerialInterfaceSpec
|
||||
@Inject
|
||||
constructor(
|
||||
private val factory: SerialInterfaceFactory,
|
||||
private val usbManager: dagger.Lazy<UsbManager>,
|
||||
private val usbRepository: UsbRepository,
|
||||
) : InterfaceSpec<SerialInterface> {
|
||||
override fun createInterface(rest: String): SerialInterface {
|
||||
return factory.create(rest)
|
||||
}
|
||||
override fun createInterface(rest: String): SerialInterface = factory.create(rest)
|
||||
|
||||
override fun addressValid(
|
||||
rest: String
|
||||
): Boolean {
|
||||
usbRepository.serialDevicesWithDrivers.value.filterValues {
|
||||
usbManager.get().hasPermission(it.device)
|
||||
}
|
||||
override fun addressValid(rest: String): Boolean {
|
||||
usbRepository.serialDevices.value.filterValues { usbManager.get().hasPermission(it.device) }
|
||||
findSerial(rest)?.let { d ->
|
||||
return usbManager.get().hasPermission(d.device)
|
||||
}
|
||||
|
|
@ -47,7 +40,7 @@ class SerialInterfaceSpec @Inject constructor(
|
|||
}
|
||||
|
||||
internal fun findSerial(rest: String): UsbSerialDriver? {
|
||||
val deviceMap = usbRepository.serialDevicesWithDrivers.value
|
||||
val deviceMap = usbRepository.serialDevices.value
|
||||
return if (deviceMap.containsKey(rest)) {
|
||||
deviceMap[rest]!!
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -17,15 +17,15 @@
|
|||
package com.geeksville.mesh.repository.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.util.Exceptions
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.common.util.Exceptions
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import java.io.BufferedInputStream
|
||||
|
|
|
|||
|
|
@ -18,16 +18,15 @@ package com.geeksville.mesh.repository.usb
|
|||
|
||||
import android.hardware.usb.UsbManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.util.ignoreException
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||
import com.hoho.android.usbserial.util.SerialInputOutputManager
|
||||
import org.meshtastic.core.model.util.await
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import java.nio.BufferOverflowException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal class SerialConnectionImpl(
|
||||
private val usbManagerLazy: dagger.Lazy<UsbManager?>,
|
||||
|
|
@ -65,7 +64,7 @@ internal class SerialConnectionImpl(
|
|||
// Allow a short amount of time for the manager to quit (so the port can be cleanly closed)
|
||||
if (waitForStopped) {
|
||||
Logger.d { "Waiting for USB manager to stop..." }
|
||||
ignoreException(silent = true) { closedLatch.await(1.seconds) }
|
||||
ignoreException(silent = true) { closedLatch.await(1, TimeUnit.SECONDS) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 com.geeksville.mesh.repository.usb
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
|
|
@ -24,8 +23,8 @@ import android.content.IntentFilter
|
|||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.util.exceptionReporter
|
||||
import com.geeksville.mesh.util.getParcelableExtraCompat
|
||||
import org.meshtastic.core.common.util.exceptionReporter
|
||||
import org.meshtastic.core.common.util.getParcelableExtraCompat
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A helper class to call onChanged when bluetooth is enabled or disabled or when permissions are changed. */
|
||||
|
|
|
|||
|
|
@ -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 com.geeksville.mesh.repository.usb
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
|
|
@ -24,33 +23,32 @@ import android.content.IntentFilter
|
|||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import com.geeksville.mesh.util.registerReceiverCompat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.meshtastic.core.common.util.registerReceiverCompat
|
||||
|
||||
private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
|
||||
|
||||
internal fun UsbManager.requestPermission(
|
||||
context: Context,
|
||||
device: UsbDevice,
|
||||
): Flow<Boolean> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (ACTION_USB_PERMISSION == intent.action) {
|
||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
trySend(granted)
|
||||
close()
|
||||
internal fun UsbManager.requestPermission(context: Context, device: UsbDevice): Flow<Boolean> = callbackFlow {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (ACTION_USB_PERMISSION == intent.action) {
|
||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
trySend(granted)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val permissionIntent = PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
|
||||
0,
|
||||
true
|
||||
)
|
||||
val permissionIntent =
|
||||
PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
|
||||
0,
|
||||
true,
|
||||
)
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
context.registerReceiverCompat(receiver, filter)
|
||||
requestPermission(device, permissionIntent)
|
||||
|
|
|
|||
|
|
@ -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 com.geeksville.mesh.repository.usb
|
||||
|
||||
import android.app.Application
|
||||
|
|
@ -22,19 +21,18 @@ import android.hardware.usb.UsbDevice
|
|||
import android.hardware.usb.UsbManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import com.geeksville.mesh.util.registerReceiverCompat
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.common.util.registerReceiverCompat
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.di.ProcessLifecycle
|
||||
import javax.inject.Inject
|
||||
|
|
@ -55,11 +53,7 @@ constructor(
|
|||
) {
|
||||
private val _serialDevices = MutableStateFlow(emptyMap<String, UsbDevice>())
|
||||
|
||||
@Suppress("unused") // Retained as public API
|
||||
val serialDevices = _serialDevices.asStateFlow()
|
||||
|
||||
@Suppress("unused") // Retained as public API
|
||||
val serialDevicesWithDrivers =
|
||||
val serialDevices =
|
||||
_serialDevices
|
||||
.mapLatest { serialDevices ->
|
||||
val serialProber = usbSerialProberLazy.get()
|
||||
|
|
@ -69,16 +63,6 @@ constructor(
|
|||
}
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
@Suppress("unused") // Retained as public API
|
||||
val serialDevicesWithPermission =
|
||||
_serialDevices
|
||||
.mapLatest { serialDevices ->
|
||||
usbManagerLazy.get()?.let { usbManager ->
|
||||
serialDevices.filterValues { device -> usbManager.hasPermission(device) }
|
||||
} ?: emptyMap()
|
||||
}
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
init {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
refreshStateInternal()
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@
|
|||
*/
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.util.ignoreException
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -25,13 +23,15 @@ 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.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.database.entity.ReactionEntity
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.prefs.mesh.MeshPrefs
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.util.isWithinSizeLimit
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.model.util.nowSeconds
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@
|
|||
package com.geeksville.mesh.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import org.meshtastic.core.analytics.platform.PlatformAnalytics
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
*/
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.Channel
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ package com.geeksville.mesh.service
|
|||
|
||||
import android.app.Notification
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.meshtastic.core.strings.getString
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -32,10 +30,11 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
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
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.model.util.nowSeconds
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
|
|
@ -44,6 +43,7 @@ import org.meshtastic.core.strings.connected_count
|
|||
import org.meshtastic.core.strings.connecting
|
||||
import org.meshtastic.core.strings.device_sleeping
|
||||
import org.meshtastic.core.strings.disconnected
|
||||
import org.meshtastic.core.strings.getString
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ import android.util.Log
|
|||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.repository.radio.InterfaceId
|
||||
import com.meshtastic.core.strings.getString
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -32,6 +30,9 @@ import kotlinx.coroutines.flow.first
|
|||
import okio.ByteString.Companion.toByteString
|
||||
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
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
|
|
@ -40,8 +41,6 @@ import org.meshtastic.core.model.DataPacket
|
|||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.util.SfppHasher
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.model.util.nowSeconds
|
||||
import org.meshtastic.core.model.util.toOneLiner
|
||||
import org.meshtastic.core.prefs.mesh.MeshPrefs
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
|
|
@ -50,6 +49,7 @@ import org.meshtastic.core.service.filter.MessageFilterService
|
|||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.critical_alert
|
||||
import org.meshtastic.core.strings.error_duty_cycle
|
||||
import org.meshtastic.core.strings.getString
|
||||
import org.meshtastic.core.strings.unknown_username
|
||||
import org.meshtastic.core.strings.waypoint_received
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
|
|
@ -477,13 +477,12 @@ constructor(
|
|||
val isAck = routingError == Routing.Error.NONE.value
|
||||
val p = packetRepository.get().getPacketById(requestId)
|
||||
val reaction = packetRepository.get().getReactionByPacketId(requestId)
|
||||
val isMaxRetransmit = routingError == Routing.Error.MAX_RETRANSMIT.value
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
Logger.d {
|
||||
val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
|
||||
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
|
||||
"maxRetransmit=$isMaxRetransmit packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
|
||||
"packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
|
||||
}
|
||||
|
||||
val m =
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import android.util.Log
|
|||
import androidx.annotation.VisibleForTesting
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
|
||||
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.prefs.mesh.MeshPrefs
|
||||
import org.meshtastic.proto.Data
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ package com.geeksville.mesh.service
|
|||
import android.util.Log
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -27,11 +26,12 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.model.util.isLora
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.model.util.nowSeconds
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.LogRecord
|
||||
|
|
@ -39,10 +39,10 @@ import org.meshtastic.proto.MeshPacket
|
|||
import org.meshtastic.proto.PortNum
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@Singleton
|
||||
|
|
@ -125,7 +125,7 @@ constructor(
|
|||
|
||||
insertMeshLog(
|
||||
MeshLog(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
uuid = Uuid.random().toString(),
|
||||
message_type = type,
|
||||
received_date = nowMillis,
|
||||
raw_message = message,
|
||||
|
|
@ -185,7 +185,7 @@ constructor(
|
|||
val decoded = packet.decoded ?: return
|
||||
val log =
|
||||
MeshLog(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
uuid = Uuid.random().toString(),
|
||||
message_type = "Packet",
|
||||
received_date = nowMillis,
|
||||
raw_message = packet.toString(),
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@
|
|||
package com.geeksville.mesh.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.meshtastic.core.strings.getString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.getString
|
||||
import org.meshtastic.core.strings.unknown_username
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@ package com.geeksville.mesh.service
|
|||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
|
|
@ -32,7 +33,6 @@ import org.meshtastic.core.model.DataPacket
|
|||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.NodeInfo
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
|
|
|
|||
|
|
@ -25,10 +25,8 @@ import android.os.IBinder
|
|||
import androidx.core.app.ServiceCompat
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.util.toRemoteExceptions
|
||||
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -38,6 +36,8 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.meshtastic.core.common.hasLocationPermission
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.toRemoteExceptions
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
|
|
|
|||
|
|
@ -41,23 +41,23 @@ import com.geeksville.mesh.R.raw
|
|||
import com.geeksville.mesh.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
|
||||
import com.geeksville.mesh.service.ReactionReceiver.Companion.REACT_ACTION
|
||||
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
|
||||
import com.meshtastic.core.strings.getString
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.client_notification
|
||||
import org.meshtastic.core.strings.getString
|
||||
import org.meshtastic.core.strings.low_battery_message
|
||||
import org.meshtastic.core.strings.low_battery_title
|
||||
import org.meshtastic.core.strings.mark_as_read
|
||||
|
|
|
|||
|
|
@ -17,19 +17,19 @@
|
|||
package com.geeksville.mesh.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.meshtastic.core.strings.getString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getFullTracerouteResponse
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.service.TracerouteResponse
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.getString
|
||||
import org.meshtastic.core.strings.traceroute_duration
|
||||
import org.meshtastic.core.strings.traceroute_route_back_to_us
|
||||
import org.meshtastic.core.strings.traceroute_route_towards_dest
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package com.geeksville.mesh.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
|
@ -28,12 +27,13 @@ import kotlinx.coroutines.TimeoutCancellationException
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.model.util.toOneLineString
|
||||
import org.meshtastic.core.model.util.toPIIString
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
|
|
@ -41,13 +41,13 @@ import org.meshtastic.proto.FromRadio
|
|||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.QueueStatus
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@Singleton
|
||||
|
|
@ -90,7 +90,7 @@ constructor(
|
|||
if (packet?.decoded != null) {
|
||||
val packetToSave =
|
||||
MeshLog(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
uuid = Uuid.random().toString(),
|
||||
message_type = "Packet",
|
||||
received_date = nowMillis,
|
||||
raw_message = packet.toString(),
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@
|
|||
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.Animatable
|
||||
|
|
@ -81,7 +79,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||
import androidx.navigation.compose.rememberNavController
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.model.BTScanModel
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.navigation.channelsGraph
|
||||
import com.geeksville.mesh.navigation.connectionsGraph
|
||||
|
|
@ -93,12 +90,11 @@ import com.geeksville.mesh.navigation.settingsGraph
|
|||
import com.geeksville.mesh.repository.radio.MeshActivity
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.connections.DeviceType
|
||||
import com.geeksville.mesh.ui.connections.ScannerViewModel
|
||||
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -161,10 +157,10 @@ enum class TopLevelDestination(val label: StringResource, val icon: ImageVector,
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanModel = hiltViewModel()) {
|
||||
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerViewModel = hiltViewModel()) {
|
||||
val navController = rememberNavController()
|
||||
LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) } }
|
||||
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
|
||||
|
|
@ -172,16 +168,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
|
|||
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
||||
LaunchedEffect(connectionState, notificationPermissionState) {
|
||||
if (connectionState == ConnectionState.Connected && !notificationPermissionState.status.isGranted) {
|
||||
notificationPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
RequestNotificationPermission {
|
||||
// Nordic handled the trigger for POST_NOTIFICATIONS when connected
|
||||
}
|
||||
|
||||
sharedContactRequested?.let {
|
||||
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import androidx.compose.animation.Crossfade
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -36,13 +35,11 @@ import androidx.compose.material.icons.rounded.Language
|
|||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -54,7 +51,6 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.BTScanModel
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import com.geeksville.mesh.ui.connections.components.BLEDevices
|
||||
import com.geeksville.mesh.ui.connections.components.ConnectingDeviceInfo
|
||||
|
|
@ -64,7 +60,6 @@ import com.geeksville.mesh.ui.connections.components.EmptyStateContent
|
|||
import com.geeksville.mesh.ui.connections.components.NetworkDevices
|
||||
import com.geeksville.mesh.ui.connections.components.UsbDevices
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.Route
|
||||
|
|
@ -80,7 +75,6 @@ import org.meshtastic.core.strings.must_set_region
|
|||
import org.meshtastic.core.strings.no_device_selected
|
||||
import org.meshtastic.core.strings.not_connected
|
||||
import org.meshtastic.core.strings.set_your_region
|
||||
import org.meshtastic.core.strings.warning_not_paired
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
|
|
@ -91,6 +85,7 @@ import org.meshtastic.feature.settings.navigation.getNavRouteFrom
|
|||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
||||
import org.meshtastic.proto.Config
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
|
||||
false
|
||||
|
|
@ -105,12 +100,12 @@ fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
|
|||
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
|
||||
* displays connection status.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalUuidApi::class)
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder")
|
||||
@Composable
|
||||
fun ConnectionsScreen(
|
||||
connectionsViewModel: ConnectionsViewModel = hiltViewModel(),
|
||||
scanModel: BTScanModel = hiltViewModel(),
|
||||
scanModel: ScannerViewModel = hiltViewModel(),
|
||||
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
onClickNodeChip: (Int) -> Unit,
|
||||
onNavigateToNodeDetails: (Int) -> Unit,
|
||||
|
|
@ -118,13 +113,10 @@ fun ConnectionsScreen(
|
|||
) {
|
||||
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val scrollState = rememberScrollState()
|
||||
val scanStatusText by scanModel.errorText.observeAsState("")
|
||||
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
|
||||
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
|
||||
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
|
||||
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
|
||||
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
|
||||
|
||||
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
||||
|
|
@ -153,14 +145,6 @@ fun ConnectionsScreen(
|
|||
)
|
||||
}
|
||||
|
||||
// when scanning is true - wait 10000ms and then stop scanning
|
||||
LaunchedEffect(scanning) {
|
||||
if (scanning) {
|
||||
delay(SCAN_PERIOD)
|
||||
scanModel.stopScan()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(connectionState, regionUnset) {
|
||||
when (connectionState) {
|
||||
ConnectionState.Connected -> {
|
||||
|
|
@ -189,13 +173,10 @@ fun ConnectionsScreen(
|
|||
) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.height(IntrinsicSize.Max)
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val uiState =
|
||||
when {
|
||||
connectionState.isConnected() && ourNode != null -> 2
|
||||
|
|
@ -205,11 +186,7 @@ fun ConnectionsScreen(
|
|||
else -> 0
|
||||
}
|
||||
|
||||
Crossfade(
|
||||
targetState = uiState,
|
||||
label = "connection_state",
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
) { state ->
|
||||
Crossfade(targetState = uiState, label = "connection_state") { state ->
|
||||
when (state) {
|
||||
2 -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
|
|
@ -278,61 +255,47 @@ fun ConnectionsScreen(
|
|||
selectedDeviceType = it
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (selectedDeviceType) {
|
||||
DeviceType.BLE -> {
|
||||
val (bonded, available) = bleDevices.partition { it.bonded }
|
||||
BLEDevices(
|
||||
connectionState = connectionState,
|
||||
bondedDevices = bonded,
|
||||
availableDevices = available,
|
||||
selectedDevice = selectedDevice,
|
||||
scanModel = scanModel,
|
||||
bluetoothEnabled = bluetoothState.enabled,
|
||||
)
|
||||
}
|
||||
|
||||
DeviceType.TCP -> {
|
||||
NetworkDevices(
|
||||
connectionState = connectionState,
|
||||
discoveredNetworkDevices = discoveredTcpDevices,
|
||||
recentNetworkDevices = recentTcpDevices,
|
||||
selectedDevice = selectedDevice,
|
||||
scanModel = scanModel,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
NetworkDevices(
|
||||
connectionState = connectionState,
|
||||
discoveredNetworkDevices = discoveredTcpDevices,
|
||||
recentNetworkDevices = recentTcpDevices,
|
||||
selectedDevice = selectedDevice,
|
||||
scanModel = scanModel,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
DeviceType.USB -> {
|
||||
UsbDevices(
|
||||
connectionState = connectionState,
|
||||
usbDevices = usbDevices,
|
||||
selectedDevice = selectedDevice,
|
||||
scanModel = scanModel,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
UsbDevices(
|
||||
connectionState = connectionState,
|
||||
usbDevices = usbDevices,
|
||||
selectedDevice = selectedDevice,
|
||||
scanModel = scanModel,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Warning Not Paired
|
||||
val hasShownNotPairedWarning by
|
||||
connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle()
|
||||
val (bonded, _) = bleDevices.partition { it.bonded }
|
||||
val showWarningNotPaired =
|
||||
!connectionState.isConnected() && !hasShownNotPairedWarning && bonded.isEmpty()
|
||||
if (showWarningNotPaired) {
|
||||
Text(
|
||||
text = stringResource(Res.string.warning_not_paired),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LaunchedEffect(Unit) { connectionsViewModel.suppressNoPairedWarning() }
|
||||
}
|
||||
}
|
||||
}
|
||||
scanStatusText?.let {
|
||||
|
|
@ -354,5 +317,3 @@ fun ConnectionsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SCAN_PERIOD: Long = 10000 // 10 seconds
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package com.geeksville.mesh.ui.connections
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -39,7 +38,6 @@ constructor(
|
|||
radioConfigRepository: RadioConfigRepository,
|
||||
serviceRepository: ServiceRepository,
|
||||
nodeRepository: NodeRepository,
|
||||
bluetoothRepository: BluetoothRepository,
|
||||
private val uiPrefs: UiPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -52,8 +50,6 @@ constructor(
|
|||
|
||||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
||||
|
||||
val bluetoothState = bluetoothRepository.state
|
||||
|
||||
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning)
|
||||
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,11 +14,9 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.connections
|
||||
|
||||
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
|
||||
|
||||
/** Represent the different ways a device can connect to the phone. */
|
||||
enum class DeviceType {
|
||||
BLE,
|
||||
TCP,
|
||||
|
|
|
|||
|
|
@ -14,18 +14,18 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.model
|
||||
package com.geeksville.mesh.ui.connections
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.RemoteException
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import com.geeksville.mesh.model.getMeshtasticShortName
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
|
|
@ -43,6 +43,10 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.datastore.RecentAddressesDataSource
|
||||
import org.meshtastic.core.datastore.model.RecentAddress
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
|
|
@ -50,11 +54,12 @@ import org.meshtastic.core.service.ServiceRepository
|
|||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.meshtastic
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
class BTScanModel
|
||||
class ScannerViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val application: Application,
|
||||
|
|
@ -65,28 +70,28 @@ constructor(
|
|||
private val networkRepository: NetworkRepository,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val recentAddressesDataSource: RecentAddressesDataSource,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val databaseManager: DatabaseManager,
|
||||
) : ViewModel() {
|
||||
private val context: Context
|
||||
get() = application.applicationContext
|
||||
|
||||
val showMockInterface: StateFlow<Boolean>
|
||||
get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
|
||||
val showMockInterface: StateFlow<Boolean> = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
|
||||
|
||||
private val _errorText = MutableStateFlow<String?>(null)
|
||||
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
||||
|
||||
private val nodeDb: StateFlow<Map<Int, Node>> = nodeRepository.nodeDBbyNum
|
||||
|
||||
val errorText = MutableLiveData<String?>(null)
|
||||
private val bondedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
|
||||
bluetoothRepository.state
|
||||
.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
private val scannedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
|
||||
bluetoothRepository.scannedDevices
|
||||
.map { peripherals -> peripherals.map { DeviceListEntry.Ble(it) } }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
// Flow for discovered TCP devices, using recent addresses for potential name enrichment
|
||||
private val processedDiscoveredTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
|
||||
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
|
||||
val recentMap = recentList.associateBy({ it.address }, { it.name })
|
||||
val recentMap = recentList.associateBy({ it.address }) { it.name }
|
||||
tcpServices
|
||||
.map { service ->
|
||||
val address = "t${service.toAddressString()}"
|
||||
|
|
@ -98,7 +103,7 @@ constructor(
|
|||
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
|
||||
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
|
||||
var displayName = recentMap[address] ?: shortName
|
||||
if (deviceId != null && !displayName.split("_").none { it == deviceId }) {
|
||||
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
|
||||
displayName += "_$deviceId"
|
||||
}
|
||||
DeviceListEntry.Tcp(displayName, address)
|
||||
|
|
@ -107,29 +112,57 @@ constructor(
|
|||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
/** A combined list of bonded and scanned BLE devices for the UI. */
|
||||
/** A combined list of bonded BLE devices for the UI. */
|
||||
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
|
||||
combine(bondedBleDevicesFlow, scannedBleDevicesFlow) { bonded, scanned ->
|
||||
val bondedAddresses = bonded.map { it.fullAddress }.toSet()
|
||||
val uniqueScanned = scanned.filterNot { it.fullAddress in bondedAddresses }
|
||||
(bonded + uniqueScanned).sortedBy { it.name }
|
||||
combine(bondedBleDevicesFlow, nodeDb) { bonded, db ->
|
||||
bonded
|
||||
.map { entry: DeviceListEntry.Ble ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
db.values.find { node ->
|
||||
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
|
||||
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
private val usbDevicesFlow: StateFlow<List<DeviceListEntry.Usb>> =
|
||||
usbRepository.serialDevicesWithDrivers
|
||||
usbRepository.serialDevices
|
||||
.map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val mockDevice = DeviceListEntry.Mock("Demo Mode")
|
||||
|
||||
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
|
||||
/** UI StateFlow for USB devices. */
|
||||
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
|
||||
combine(usbDevicesFlow, showMockInterface) { usb, showMock ->
|
||||
usb + if (showMock) listOf(mockDevice) else emptyList()
|
||||
val all: List<DeviceListEntry> = usb + if (showMock) listOf(mockDevice) else emptyList()
|
||||
all.map { entry ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
nodeDb.value.values.find { node ->
|
||||
// Hard to match USB to node without connection, but we can try matching by name if it
|
||||
// follows Meshtastic pattern
|
||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
|
||||
suffix != null &&
|
||||
suffix.length >= suffixLength &&
|
||||
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList())
|
||||
|
||||
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
|
||||
private val filteredRecentTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
|
||||
combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) {
|
||||
recentList,
|
||||
|
|
@ -143,13 +176,47 @@ constructor(
|
|||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
private val suffixLength = 4
|
||||
|
||||
/** UI StateFlow for discovered TCP devices. */
|
||||
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
|
||||
processedDiscoveredTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
|
||||
combine(processedDiscoveredTcpDevicesFlow, networkRepository.resolvedList, nodeDb) { devices, resolved, db ->
|
||||
devices.map { entry ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
|
||||
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
|
||||
db.values.find { node ->
|
||||
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = listOf())
|
||||
|
||||
/** UI StateFlow for recently connected TCP devices that are not currently discovered. */
|
||||
val recentTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
|
||||
filteredRecentTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
|
||||
combine(filteredRecentTcpDevicesFlow, nodeDb) { devices, db ->
|
||||
devices.map { entry ->
|
||||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
// For recent TCP, we don't have the TXT records, but maybe the name contains the ID
|
||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
|
||||
db.values.find { node ->
|
||||
suffix != null &&
|
||||
suffix.length >= suffixLength &&
|
||||
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entry.copy(node = matchingNode)
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = listOf())
|
||||
|
||||
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
|
||||
|
||||
|
|
@ -158,35 +225,18 @@ constructor(
|
|||
.map { it ?: NO_DEVICE_SELECTED }
|
||||
.stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED)
|
||||
|
||||
val spinner: StateFlow<Boolean> = bluetoothRepository.isScanning
|
||||
|
||||
init {
|
||||
serviceRepository.connectionProgress.onEach { errorText.value = it }.launchIn(viewModelScope)
|
||||
Logger.d { "BTScanModel created" }
|
||||
serviceRepository.connectionProgress.onEach { _errorText.value = it }.launchIn(viewModelScope)
|
||||
Logger.d { "ScannerViewModel created" }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
bluetoothRepository.stopScan()
|
||||
Logger.d { "BTScanModel cleared" }
|
||||
Logger.d { "ScannerViewModel cleared" }
|
||||
}
|
||||
|
||||
fun setErrorText(text: String) {
|
||||
errorText.value = text
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
Logger.d { "stopping scan" }
|
||||
bluetoothRepository.stopScan()
|
||||
}
|
||||
|
||||
fun refreshPermissions() {
|
||||
bluetoothRepository.refreshState()
|
||||
}
|
||||
|
||||
fun startScan() {
|
||||
Logger.d { "starting ble scan" }
|
||||
bluetoothRepository.startScan()
|
||||
_errorText.value = text
|
||||
}
|
||||
|
||||
private fun changeDeviceAddress(address: String) {
|
||||
|
|
@ -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,248 +14,87 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.connections.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings.ACTION_BLUETOOTH_SETTINGS
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.BluetoothDisabled
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.BTScanModel
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.launch
|
||||
import com.geeksville.mesh.ui.connections.ScannerViewModel
|
||||
import no.nordicsemi.android.common.scanner.rememberFilterState
|
||||
import no.nordicsemi.android.common.scanner.view.ScannerView
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.bluetooth_available_devices
|
||||
import org.meshtastic.core.strings.bluetooth_disabled
|
||||
import org.meshtastic.core.strings.bluetooth_paired_devices
|
||||
import org.meshtastic.core.strings.grant_permissions
|
||||
import org.meshtastic.core.strings.no_ble_devices
|
||||
import org.meshtastic.core.strings.open_settings
|
||||
import org.meshtastic.core.strings.permission_missing
|
||||
import org.meshtastic.core.strings.permission_missing_31
|
||||
import org.meshtastic.core.strings.scan
|
||||
import org.meshtastic.core.strings.scanning_bluetooth
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
|
||||
/**
|
||||
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth
|
||||
* permissions using `accompanist-permissions`.
|
||||
* permissions and hardware state using Nordic Common Libraries' ScannerView.
|
||||
*
|
||||
* @param connectionState The current connection state of the MeshService.
|
||||
* @param bondedDevices List of discovered BLE devices.
|
||||
* @param availableDevices
|
||||
* @param selectedDevice The full address of the currently selected device.
|
||||
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
|
||||
* @param bluetoothEnabled
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BLEDevices(
|
||||
connectionState: ConnectionState,
|
||||
bondedDevices: List<DeviceListEntry>,
|
||||
availableDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
scanModel: BTScanModel,
|
||||
bluetoothEnabled: Boolean,
|
||||
) {
|
||||
LocalContext.current // Used implicitly by stringResource
|
||||
val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false)
|
||||
|
||||
// Define permissions needed for Bluetooth scanning based on Android version.
|
||||
val bluetoothPermissionsList = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
listOf(
|
||||
Manifest.permission.BLUETOOTH,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val permsMissing =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
stringResource(Res.string.permission_missing_31)
|
||||
} else {
|
||||
stringResource(Res.string.permission_missing)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val singlePermissionState =
|
||||
rememberPermissionState(
|
||||
permission = Manifest.permission.ACCESS_BACKGROUND_LOCATION,
|
||||
onPermissionResult = { granted ->
|
||||
scanModel.refreshPermissions()
|
||||
scanModel.startScan()
|
||||
},
|
||||
)
|
||||
|
||||
val permissionsState =
|
||||
rememberMultiplePermissionsState(
|
||||
permissions = bluetoothPermissionsList,
|
||||
onPermissionsResult = { permissions ->
|
||||
val granted = permissions.values.all { it }
|
||||
if (permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false)) {
|
||||
coroutineScope.launch { context.showToast(permsMissing) }
|
||||
singlePermissionState.launchPermissionRequest()
|
||||
}
|
||||
if (granted) {
|
||||
scanModel.refreshPermissions()
|
||||
scanModel.startScan()
|
||||
} else {
|
||||
coroutineScope.launch { context.showToast(permsMissing) }
|
||||
fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanModel: ScannerViewModel) {
|
||||
val filterState =
|
||||
rememberFilterState(
|
||||
filter = {
|
||||
Any {
|
||||
ServiceUuid(SERVICE_UUID)
|
||||
Name(Regex(BLE_NAME_PATTERN))
|
||||
}
|
||||
},
|
||||
)
|
||||
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
||||
|
||||
val settingsLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {
|
||||
scanModel.refreshPermissions()
|
||||
scanModel.startScan()
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(Res.string.bluetooth_available_devices),
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 16.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (permissionsState.allPermissionsGranted) {
|
||||
when {
|
||||
!bluetoothEnabled -> {
|
||||
val context = LocalContext.current
|
||||
EmptyStateContent(
|
||||
imageVector = Icons.Rounded.BluetoothDisabled,
|
||||
text = stringResource(Res.string.bluetooth_disabled),
|
||||
actionButton = {
|
||||
val intent = Intent(ACTION_BLUETOOTH_SETTINGS)
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
Button(onClick = { settingsLauncher.launch(intent) }) {
|
||||
Text(text = stringResource(Res.string.open_settings))
|
||||
}
|
||||
}
|
||||
},
|
||||
ScannerView(
|
||||
state = filterState,
|
||||
onScanResultSelected = { result -> scanModel.onSelected(DeviceListEntry.Ble(result.peripheral)) },
|
||||
deviceItem = { result ->
|
||||
val device =
|
||||
remember(result.peripheral.address, bleDevices) {
|
||||
bleDevices.find { it.fullAddress == "x${result.peripheral.address}" }
|
||||
?: DeviceListEntry.Ble(result.peripheral)
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
) {
|
||||
DeviceListItem(
|
||||
connectionState =
|
||||
connectionState.takeIf { device.fullAddress == selectedDevice }
|
||||
?: ConnectionState.Disconnected,
|
||||
device = device,
|
||||
onSelect = { scanModel.onSelected(device) },
|
||||
rssi = result.rssi,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val scanButton: @Composable () -> Unit = {
|
||||
Button(
|
||||
enabled = !isScanning,
|
||||
onClick = { checkPermissionsAndScan(permissionsState, scanModel, true) },
|
||||
) {
|
||||
Box {
|
||||
// Still measure for the icon and text when scanning, so the button's size doesn't jump
|
||||
// around.
|
||||
Row(modifier = Modifier.alpha(if (isScanning) 0f else 1f)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Search,
|
||||
contentDescription = stringResource(Res.string.scan),
|
||||
)
|
||||
Text(stringResource(Res.string.scan))
|
||||
}
|
||||
|
||||
if (isScanning) {
|
||||
CircularWavyProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bondedDevices.isEmpty() && availableDevices.isEmpty()) {
|
||||
EmptyStateContent(
|
||||
imageVector = Icons.Rounded.BluetoothDisabled,
|
||||
text =
|
||||
if (isScanning) {
|
||||
stringResource(Res.string.scanning_bluetooth)
|
||||
} else {
|
||||
stringResource(Res.string.no_ble_devices)
|
||||
},
|
||||
actionButton = scanButton,
|
||||
)
|
||||
} else {
|
||||
bondedDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.bluetooth_paired_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
)
|
||||
|
||||
availableDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.bluetooth_available_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
)
|
||||
|
||||
scanButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show a message and a button to grant permissions if not all granted
|
||||
EmptyStateContent(
|
||||
text =
|
||||
if (permissionsState.shouldShowRationale) {
|
||||
stringResource(Res.string.permission_missing)
|
||||
} else {
|
||||
stringResource(Res.string.permission_missing_31)
|
||||
},
|
||||
actionButton = {
|
||||
Button(onClick = { checkPermissionsAndScan(permissionsState, scanModel, bluetoothEnabled) }) {
|
||||
Text(text = stringResource(Res.string.grant_permissions))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
private fun checkPermissionsAndScan(
|
||||
permissionsState: MultiplePermissionsState,
|
||||
scanModel: BTScanModel,
|
||||
bluetoothEnabled: Boolean,
|
||||
) {
|
||||
if (permissionsState.allPermissionsGranted && bluetoothEnabled) {
|
||||
scanModel.startScan()
|
||||
} else {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
|
|
@ -49,24 +48,28 @@ fun ConnectingDeviceInfo(
|
|||
onClickDisconnect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(40.dp))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
|
||||
Column {
|
||||
Text(text = deviceName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = deviceAddress, style = MaterialTheme.typography.bodySmall)
|
||||
Text(text = stringResource(Res.string.connecting), style = MaterialTheme.typography.labelSmall)
|
||||
Text(text = deviceName, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(text = deviceAddress, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = stringResource(Res.string.connecting),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
shape = RectangleShape,
|
||||
modifier = Modifier.fillMaxWidth().height(40.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.StatusRed,
|
||||
|
|
@ -74,7 +77,7 @@ fun ConnectingDeviceInfo(
|
|||
),
|
||||
onClick = onClickDisconnect,
|
||||
) {
|
||||
Text(stringResource(Res.string.disconnect))
|
||||
Text(stringResource(Res.string.disconnect), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import co.touchlab.kermit.Logger
|
|||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.android.common.ui.view.RssiIcon
|
||||
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -51,7 +52,6 @@ import org.meshtastic.core.strings.Res
|
|||
import org.meshtastic.core.strings.disconnect
|
||||
import org.meshtastic.core.strings.firmware_version
|
||||
import org.meshtastic.core.ui.component.MaterialBatteryInfo
|
||||
import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
|
@ -60,8 +60,8 @@ import org.meshtastic.proto.Paxcount
|
|||
import org.meshtastic.proto.User
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val RSSI_DELAY = 10
|
||||
private const val RSSI_TIMEOUT = 5
|
||||
private const val RSSI_DELAY = 2
|
||||
private const val RSSI_TIMEOUT = 1
|
||||
|
||||
@Suppress("LongMethod", "LoopWithTooManyJumpStatements", "TooGenericExceptionCaught")
|
||||
@Composable
|
||||
|
|
@ -104,7 +104,7 @@ fun CurrentlyConnectedInfo(
|
|||
) {
|
||||
MaterialBatteryInfo(level = node.batteryLevel, voltage = node.voltage)
|
||||
if (bleDevice is DeviceListEntry.Ble) {
|
||||
MaterialBluetoothSignalInfo(rssi)
|
||||
RssiIcon(rssi = rssi)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
|
|||
|
|
@ -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 com.geeksville.mesh.ui.connections.components
|
||||
|
||||
import androidx.compose.foundation.Indication
|
||||
|
|
@ -22,7 +21,10 @@ import androidx.compose.foundation.LocalIndication
|
|||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching
|
||||
|
|
@ -36,15 +38,23 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import no.nordicsemi.android.common.ui.view.RssiIcon
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.Res
|
||||
|
|
@ -52,6 +62,9 @@ import org.meshtastic.core.strings.add
|
|||
import org.meshtastic.core.strings.bluetooth
|
||||
import org.meshtastic.core.strings.network
|
||||
import org.meshtastic.core.strings.serial
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
|
||||
private const val RSSI_UPDATE_RATE_MS = 2000L
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
|
|
@ -62,7 +75,22 @@ fun DeviceListItem(
|
|||
onSelect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
rssi: Int? = null,
|
||||
) {
|
||||
// Throttle the RSSI updates to match the connected device polling rate
|
||||
var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) }
|
||||
LaunchedEffect(rssi) {
|
||||
if (displayedRssi == 0) {
|
||||
displayedRssi = rssi ?: 0
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(RSSI_UPDATE_RATE_MS)
|
||||
displayedRssi = rssi ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
val icon =
|
||||
when (device) {
|
||||
is DeviceListEntry.Ble ->
|
||||
|
|
@ -91,31 +119,48 @@ fun DeviceListItem(
|
|||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val indication: Indication = LocalIndication.current
|
||||
|
||||
ListItem(
|
||||
modifier =
|
||||
if (useSelectable && onDelete != null) {
|
||||
modifier.fillMaxWidth().indication(interactionSource, indication).pointerInput(onDelete) {
|
||||
detectTapGestures(onTap = { onSelect() }, onLongPress = { onDelete() })
|
||||
}
|
||||
} else if (useSelectable) {
|
||||
modifier.fillMaxWidth().indication(interactionSource, indication).pointerInput(Unit) {
|
||||
detectTapGestures(onTap = { onSelect() })
|
||||
val clickableModifier =
|
||||
if (useSelectable) {
|
||||
Modifier.indication(interactionSource, indication).pointerInput(device.fullAddress, onDelete) {
|
||||
detectTapGestures(onTap = { onSelect() }, onLongPress = onDelete?.let { { it() } })
|
||||
}
|
||||
} else {
|
||||
modifier.fillMaxWidth()
|
||||
},
|
||||
headlineContent = { Text(device.name) },
|
||||
leadingContent = { Icon(icon, contentDescription) },
|
||||
supportingContent = {
|
||||
if (device is DeviceListEntry.Tcp) {
|
||||
Text(device.address)
|
||||
Modifier
|
||||
}
|
||||
|
||||
ListItem(
|
||||
modifier = modifier.fillMaxWidth().then(clickableModifier).padding(vertical = 4.dp),
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
device.node?.let { node -> NodeChip(node = node) }
|
||||
?: Text(text = device.name, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint =
|
||||
if (connectionState.isConnected()) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(text = device.address, style = MaterialTheme.typography.bodyLarge) },
|
||||
trailingContent = {
|
||||
if (connectionState.isConnecting()) {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else {
|
||||
RadioButton(selected = connectionState.isConnected(), onClick = null)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (rssi != null) {
|
||||
RssiIcon(rssi = displayedRssi)
|
||||
}
|
||||
|
||||
if (connectionState.isConnecting()) {
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
} else {
|
||||
RadioButton(selected = connectionState.isConnected(), onClick = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
|
|
|
|||
|
|
@ -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,14 +14,21 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.connections.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
|
||||
@Composable
|
||||
fun List<DeviceListEntry>.DeviceListSection(
|
||||
|
|
@ -33,16 +40,30 @@ fun List<DeviceListEntry>.DeviceListSection(
|
|||
onDelete: ((DeviceListEntry) -> Unit)? = null,
|
||||
) {
|
||||
if (isNotEmpty()) {
|
||||
TitledCard(title = title, modifier = modifier) {
|
||||
forEach { device ->
|
||||
DeviceListItem(
|
||||
connectionState =
|
||||
connectionState.takeIf { device.fullAddress == selectedDevice } ?: ConnectionState.Disconnected,
|
||||
device = device,
|
||||
onSelect = { onSelect(device) },
|
||||
onDelete = onDelete?.let { delete -> { delete(device) } },
|
||||
modifier = Modifier.Companion,
|
||||
)
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
this@DeviceListSection.forEach { device ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
) {
|
||||
DeviceListItem(
|
||||
connectionState =
|
||||
connectionState.takeIf { device.fullAddress == selectedDevice }
|
||||
?: ConnectionState.Disconnected,
|
||||
device = device,
|
||||
onSelect = { onSelect(device) },
|
||||
onDelete = onDelete?.let { delete -> { delete(device) } },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ import androidx.compose.ui.text.input.ImeAction
|
|||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.BTScanModel
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.ui.connections.ScannerViewModel
|
||||
import com.geeksville.mesh.ui.connections.isValidAddress
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -75,7 +75,7 @@ fun NetworkDevices(
|
|||
discoveredNetworkDevices: List<DeviceListEntry>,
|
||||
recentNetworkDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
scanModel: BTScanModel,
|
||||
scanModel: ScannerViewModel,
|
||||
) {
|
||||
val searchDialogState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
|
|
@ -108,9 +108,33 @@ fun NetworkDevices(
|
|||
}
|
||||
}
|
||||
|
||||
NetworkDevicesInternal(
|
||||
connectionState = connectionState,
|
||||
discoveredNetworkDevices = discoveredNetworkDevices,
|
||||
recentNetworkDevices = recentNetworkDevices,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
onDelete = { device ->
|
||||
deviceToDelete = device
|
||||
showDeleteDialog = true
|
||||
},
|
||||
onClickAdd = { showSearchDialog = true },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkDevicesInternal(
|
||||
connectionState: ConnectionState,
|
||||
discoveredNetworkDevices: List<DeviceListEntry>,
|
||||
recentNetworkDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
onSelect: (DeviceListEntry) -> Unit,
|
||||
onDelete: (DeviceListEntry) -> Unit,
|
||||
onClickAdd: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val addButton: @Composable () -> Unit = {
|
||||
Button(onClick = { showSearchDialog = true }) {
|
||||
Button(onClick = onClickAdd) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = stringResource(Res.string.add_network_device),
|
||||
|
|
@ -134,11 +158,8 @@ fun NetworkDevices(
|
|||
title = stringResource(Res.string.recent_network_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
onDelete = { device ->
|
||||
deviceToDelete = device
|
||||
showDeleteDialog = true
|
||||
},
|
||||
onSelect = onSelect,
|
||||
onDelete = onDelete,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +168,7 @@ fun NetworkDevices(
|
|||
title = stringResource(Res.string.discovered_network_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -263,3 +284,23 @@ private fun SearchDialogPreview() {
|
|||
private fun ConfirmDeleteDialogPreview() {
|
||||
AppTheme { ConfirmDeleteDialog(fullAddressToDelete = "", onHideDialog = {}, onConfirm = {}) }
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun NetworkDevicesPreview() {
|
||||
AppTheme {
|
||||
NetworkDevicesInternal(
|
||||
connectionState = ConnectionState.Disconnected,
|
||||
discoveredNetworkDevices = listOf(DeviceListEntry.Tcp("Meshtastic", "t192.168.1.3")),
|
||||
recentNetworkDevices =
|
||||
listOf(
|
||||
DeviceListEntry.Tcp("Home Node", "t192.168.1.100"),
|
||||
DeviceListEntry.Tcp("Office", "t192.168.1.101"),
|
||||
),
|
||||
selectedDevice = "",
|
||||
onSelect = {},
|
||||
onDelete = {},
|
||||
onClickAdd = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,67 +14,44 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.connections.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.UsbOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import com.geeksville.mesh.model.BTScanModel
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import com.geeksville.mesh.ui.connections.ScannerViewModel
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.no_usb_devices
|
||||
import org.meshtastic.core.strings.usb_devices
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun UsbDevices(
|
||||
connectionState: ConnectionState,
|
||||
usbDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
scanModel: BTScanModel,
|
||||
scanModel: ScannerViewModel,
|
||||
) {
|
||||
UsbDevicesInternal(
|
||||
connectionState = connectionState,
|
||||
usbDevices = usbDevices,
|
||||
selectedDevice = selectedDevice,
|
||||
onDeviceSelected = scanModel::onSelected,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsbDevicesInternal(
|
||||
connectionState: ConnectionState,
|
||||
usbDevices: List<DeviceListEntry>,
|
||||
selectedDevice: String,
|
||||
onDeviceSelected: (DeviceListEntry) -> Unit,
|
||||
) {
|
||||
when {
|
||||
usbDevices.isEmpty() ->
|
||||
EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(Res.string.no_usb_devices))
|
||||
|
||||
else ->
|
||||
usbDevices.DeviceListSection(
|
||||
title = stringResource(Res.string.usb_devices),
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = onDeviceSelected,
|
||||
if (usbDevices.isEmpty()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
EmptyStateContent(
|
||||
imageVector = Icons.Rounded.UsbOff,
|
||||
text = stringResource(Res.string.no_usb_devices),
|
||||
modifier = Modifier.height(160.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun UsbDevicesPreview() {
|
||||
AppTheme {
|
||||
UsbDevicesInternal(
|
||||
connectionState = ConnectionState.Connected,
|
||||
usbDevices = emptyList(),
|
||||
selectedDevice = "",
|
||||
onDeviceSelected = {},
|
||||
}
|
||||
} else {
|
||||
usbDevices.DeviceListSection(
|
||||
title = "USB",
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
onSelect = scanModel::onSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
|
|
@ -66,11 +65,11 @@ import kotlinx.coroutines.flow.collectLatest
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.model.util.formatMuteRemainingTime
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.are_you_sure
|
||||
import org.meshtastic.core.strings.cancel
|
||||
|
|
@ -109,7 +108,7 @@ import org.meshtastic.core.ui.util.showToast
|
|||
import org.meshtastic.proto.ChannelSet
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalPermissionsApi::class)
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
|
|
@ -477,34 +476,31 @@ private fun ContactListViewPaged(
|
|||
modifier: Modifier = Modifier,
|
||||
channels: ChannelSet? = null,
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
return
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
ContactListContentInternal(
|
||||
contacts = contacts,
|
||||
channelPlaceholders = channelPlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
listState = listState,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
|
||||
|
||||
ContactListContentInternal(
|
||||
contacts = contacts,
|
||||
visiblePlaceholders = visiblePlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
listState = listState,
|
||||
modifier = modifier,
|
||||
channels = channels,
|
||||
haptics = haptics,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactListContentInternal(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
visiblePlaceholders: List<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
|
|
@ -512,10 +508,23 @@ private fun ContactListContentInternal(
|
|||
onNodeChipClick: (Contact) -> Unit,
|
||||
listState: LazyListState,
|
||||
channels: ChannelSet?,
|
||||
haptics: HapticFeedback,
|
||||
haptic: HapticFeedback,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
|
||||
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
|
||||
|
||||
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
|
||||
contactListPlaceholdersItems(
|
||||
placeholders = visiblePlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
|
||||
contactListPagedItems(
|
||||
contacts = contacts,
|
||||
selectedList = selectedList,
|
||||
|
|
@ -524,53 +533,36 @@ private fun ContactListContentInternal(
|
|||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
channels = channels,
|
||||
haptics = haptics,
|
||||
haptic = haptic,
|
||||
)
|
||||
|
||||
contactListPlaceholdersItems(
|
||||
visiblePlaceholders = visiblePlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
channels = channels,
|
||||
haptics = haptics,
|
||||
)
|
||||
|
||||
contactListAppendLoadingItem(contacts = contacts)
|
||||
contactListAppendLoadingItem(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListPlaceholdersItems(
|
||||
visiblePlaceholders: List<Contact>,
|
||||
placeholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
channels: ChannelSet?,
|
||||
haptics: HapticFeedback,
|
||||
haptic: HapticFeedback,
|
||||
) {
|
||||
items(
|
||||
count = visiblePlaceholders.size,
|
||||
key = { index -> "placeholder_${visiblePlaceholders[index].contactKey}" },
|
||||
) { index ->
|
||||
val placeholder = visiblePlaceholders[index]
|
||||
val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } }
|
||||
val isActive = remember(placeholder.contactKey, activeContactKey) { placeholder.contactKey == activeContactKey }
|
||||
|
||||
items(count = placeholders.size, key = { index -> placeholders[index].contactKey }) { index ->
|
||||
val contact = placeholders[index]
|
||||
ContactItem(
|
||||
contact = placeholder,
|
||||
selected = selected,
|
||||
isActive = isActive,
|
||||
onClick = { onClick(placeholder) },
|
||||
contact = contact,
|
||||
selected = selectedList.contains(contact.contactKey),
|
||||
isActive = contact.contactKey == activeContactKey,
|
||||
onClick = { onClick(contact) },
|
||||
onLongClick = {
|
||||
onLongClick(placeholder)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(contact)
|
||||
},
|
||||
onNodeChipClick = { onNodeChipClick(contact) },
|
||||
channels = channels,
|
||||
onNodeChipClick = { onNodeChipClick(placeholder) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -583,45 +575,31 @@ private fun LazyListScope.contactListPagedItems(
|
|||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
channels: ChannelSet?,
|
||||
haptics: HapticFeedback,
|
||||
haptic: HapticFeedback,
|
||||
) {
|
||||
items(
|
||||
count = contacts.itemCount,
|
||||
key = { index ->
|
||||
val contact = contacts[index]
|
||||
contact?.let { "${it.contactKey}#$index" } ?: "contact_placeholder_$index"
|
||||
},
|
||||
) { index ->
|
||||
val contact = contacts[index]
|
||||
if (contact != null) {
|
||||
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
|
||||
val isActive = remember(contact.contactKey, activeContactKey) { contact.contactKey == activeContactKey }
|
||||
|
||||
items(count = contacts.itemCount, key = { index -> contacts[index]?.contactKey ?: index }) { index ->
|
||||
contacts[index]?.let { contact ->
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = selected,
|
||||
isActive = isActive,
|
||||
selected = selectedList.contains(contact.contactKey),
|
||||
isActive = contact.contactKey == activeContactKey,
|
||||
onClick = { onClick(contact) },
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(contact)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
channels = channels,
|
||||
onNodeChipClick = { onNodeChipClick(contact) },
|
||||
channels = channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
|
||||
contacts.apply {
|
||||
when {
|
||||
loadState.append is LoadState.Loading -> {
|
||||
item(key = "append_loading") {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
if (contacts.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -631,12 +609,7 @@ private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems
|
|||
private fun rememberVisiblePlaceholders(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
): List<Contact> {
|
||||
val contactKeys by
|
||||
remember(contacts.itemCount) {
|
||||
derivedStateOf { (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet() }
|
||||
}
|
||||
return remember(channelPlaceholders, contactKeys) {
|
||||
channelPlaceholders.filter { placeholder -> !contactKeys.contains(placeholder.contactKey) }
|
||||
}
|
||||
): List<Contact> = remember(contacts.itemCount, channelPlaceholders) {
|
||||
val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet()
|
||||
channelPlaceholders.filter { it.contactKey !in pagedKeys }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +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 com.geeksville.mesh.util
|
||||
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
object Exceptions {
|
||||
// / Set in Application.onCreate
|
||||
var reporter: ((Throwable, String?, String?) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Report an exception to our analytics provider (if installed - otherwise just log)
|
||||
*
|
||||
* After reporting return
|
||||
*/
|
||||
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
|
||||
Logger.e(exception) {
|
||||
"Exceptions.report: $tag $message"
|
||||
} // print the message to the log _before_ telling the crash reporter
|
||||
reporter?.let { r -> r(exception, tag, message) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This wraps (and discards) exceptions, but first it reports them to our bug tracking system and prints a message to
|
||||
* the log.
|
||||
*/
|
||||
fun exceptionReporter(inner: () -> Unit) {
|
||||
try {
|
||||
inner()
|
||||
} catch (ex: Throwable) {
|
||||
// DO NOT THROW users expect we have fully handled/discarded the exception
|
||||
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
|
||||
}
|
||||
}
|
||||
|
||||
/** This wraps (and discards) exceptions, but it does output a log message */
|
||||
fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
|
||||
try {
|
||||
inner()
|
||||
} catch (ex: Throwable) {
|
||||
// DO NOT THROW users expect we have fully handled/discarded the exception
|
||||
if (!silent) Logger.w(ex) { "ignoring exception" }
|
||||
}
|
||||
}
|
||||
|
||||
// / Convert any exceptions in this service call into a RemoteException that the client can
|
||||
// / then handle
|
||||
fun <T> toRemoteExceptions(inner: () -> T): T = try {
|
||||
inner()
|
||||
} catch (ex: Throwable) {
|
||||
Log.e("toRemoteExceptions", "Uncaught exception, returning to remote client", ex)
|
||||
when (ex) { // don't double wrap remote exceptions
|
||||
is RemoteException -> throw ex
|
||||
else -> throw RemoteException(ex.message)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2011 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.geeksville.mesh.util;
|
||||
|
||||
import android.text.InputFilter;
|
||||
import android.text.Spanned;
|
||||
|
||||
/**
|
||||
* This filter will constrain edits so that the text length is not
|
||||
* greater than the specified number of bytes using UTF-8 encoding.
|
||||
*/
|
||||
public class Utf8ByteLengthFilter implements InputFilter {
|
||||
private final int mMaxBytes;
|
||||
public Utf8ByteLengthFilter(int maxBytes) {
|
||||
mMaxBytes = maxBytes;
|
||||
}
|
||||
public CharSequence filter(CharSequence source, int start, int end,
|
||||
Spanned dest, int dstart, int dend) {
|
||||
int srcByteCount = 0;
|
||||
// count UTF-8 bytes in source substring
|
||||
for (int i = start; i < end; i++) {
|
||||
char c = source.charAt(i);
|
||||
srcByteCount += (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
|
||||
}
|
||||
int destLen = dest.length();
|
||||
int destByteCount = 0;
|
||||
// count UTF-8 bytes in destination excluding replaced section
|
||||
for (int i = 0; i < destLen; i++) {
|
||||
if (i < dstart || i >= dend) {
|
||||
char c = dest.charAt(i);
|
||||
destByteCount += (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
|
||||
}
|
||||
}
|
||||
int keepBytes = mMaxBytes - destByteCount;
|
||||
if (keepBytes <= 0) {
|
||||
return "";
|
||||
} else if (keepBytes >= srcByteCount) {
|
||||
return null; // use original dest string
|
||||
} else {
|
||||
// find end position of largest sequence that fits in keepBytes
|
||||
for (int i = start; i < end; i++) {
|
||||
char c = source.charAt(i);
|
||||
keepBytes -= (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
|
||||
if (keepBytes < 0) {
|
||||
return source.subSequence(start, i);
|
||||
}
|
||||
}
|
||||
// If the entire substring fits, we should have returned null
|
||||
// above, so this line should not be reached. If for some
|
||||
// reason it is, return null to use the original dest string.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,12 +10,12 @@
|
|||
<path
|
||||
android:pathData="m17.5564,11.8482 l-5.208,7.6376 -1.5217,-1.0377 5.9674,-8.7512c0.1714,-0.2513 0.4558,-0.4019 0.76,-0.4022 0.3042,-0.0003 0.5889,0.1497 0.7608,0.4008l5.9811,8.7374 -1.5199,1.0404z"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="@color/ic_splash"
|
||||
android:fillColor="@android:color/black"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m5.854,19.4956 l6.3707,-9.3423 -1.5749,-1.0739 -6.3707,9.3423z"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="@color/ic_splash"
|
||||
android:fillColor="@android:color/black"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
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/>.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_splash">#000000</color>
|
||||
</resources>
|
||||
|
|
@ -15,20 +15,16 @@
|
|||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<resources>
|
||||
|
||||
<!-- Shim in the system UI overrides -->
|
||||
<style name="SplashTheme.NightAdjusted" parent="Theme.SplashScreen">
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
|
||||
<!-- Splash screen theme using the Jetpack Splash Screen library -->
|
||||
<style name="SplashTheme" parent="Theme.SplashScreen">
|
||||
<!-- Background color for the splash screen -->
|
||||
<item name="windowSplashScreenBackground">#FFFFFF</item>
|
||||
</style>
|
||||
|
||||
<style name="SplashTheme" parent="SplashTheme.NightAdjusted">
|
||||
<!-- The splash screen icon -->
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||
<!-- The theme to switch to after the splash screen is dismissed -->
|
||||
<item name="postSplashScreenTheme">@style/Theme.Material3.DynamicColors.DayNight.NoActionBar</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar" />
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -35,25 +35,25 @@ import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
|
|||
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.core.Permission
|
||||
import org.junit.Ignore
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NordicBleInterfaceDrainTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
|
||||
|
||||
@Ignore("Flaky: relies on timing in the Nordic BLE mock library which causes intermittent CI failures")
|
||||
@Test
|
||||
fun `drainPacketQueueAndDispatch reads multiple packets until empty`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var fromRadioHandle: Int = -1
|
||||
|
|
@ -95,9 +95,9 @@ class NordicBleInterfaceDrainTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
|
|
@ -107,18 +107,18 @@ class NordicBleInterfaceDrainTest {
|
|||
)
|
||||
fromNumHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
fromRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,19 +38,20 @@ import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
|||
import no.nordicsemi.kotlin.ble.core.Permission
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import org.meshtastic.core.ble.BleError
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NordicBleInterfaceRetryTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Logger.setLogWriters(
|
||||
|
|
@ -106,10 +107,10 @@ class NordicBleInterfaceRetryTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
|
|
@ -118,17 +119,17 @@ class NordicBleInterfaceRetryTest {
|
|||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
@ -211,10 +212,10 @@ class NordicBleInterfaceRetryTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
|
|
@ -223,17 +224,17 @@ class NordicBleInterfaceRetryTest {
|
|||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,21 +37,23 @@ import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
|
|||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.core.Permission
|
||||
import no.nordicsemi.kotlin.ble.core.and
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import org.meshtastic.core.ble.BleError
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NordicBleInterfaceTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val address = "00:11:22:33:44:55"
|
||||
|
||||
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Logger.setLogWriters(
|
||||
|
|
@ -66,7 +68,8 @@ class NordicBleInterfaceTest {
|
|||
|
||||
@Test
|
||||
fun `full connection and notification flow`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var fromNumHandle: Int = -1
|
||||
|
|
@ -107,28 +110,28 @@ class NordicBleInterfaceTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
|
||||
permission = Permission.WRITE,
|
||||
)
|
||||
fromNumHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
fromRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
logRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
@ -189,7 +192,8 @@ class NordicBleInterfaceTest {
|
|||
|
||||
@Test
|
||||
fun `handleSendToRadio writes to toRadioCharacteristic`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
||||
var toRadioHandle: Int = -1
|
||||
|
|
@ -240,10 +244,10 @@ class NordicBleInterfaceTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
toRadioHandle =
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
|
|
@ -257,17 +261,17 @@ class NordicBleInterfaceTest {
|
|||
}
|
||||
// Add other required chars to avoid discovery failure
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
@ -308,7 +312,8 @@ class NordicBleInterfaceTest {
|
|||
|
||||
@Test
|
||||
fun `disconnection triggers onDisconnect`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Mock service
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
|
@ -338,9 +343,9 @@ class NordicBleInterfaceTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
|
|
@ -349,17 +354,17 @@ class NordicBleInterfaceTest {
|
|||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
@ -401,7 +406,8 @@ class NordicBleInterfaceTest {
|
|||
|
||||
@Test
|
||||
fun `discovery fails if required characteristic missing`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Mock service
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
|
@ -429,27 +435,27 @@ class NordicBleInterfaceTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
// OMIT toRadio characteristic to force failure
|
||||
/*
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.WRITE, CharacteristicProperty.WRITE_WITHOUT_RESPONSE),
|
||||
permission = Permission.WRITE
|
||||
)
|
||||
*/
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
@ -481,7 +487,8 @@ class NordicBleInterfaceTest {
|
|||
@Test
|
||||
fun `write exception triggers disconnect`() = runTest(testDispatcher) {
|
||||
val uniqueAddress = "11:22:33:44:55:66"
|
||||
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
|
||||
|
||||
// Mock service
|
||||
val service = mockk<RadioInterfaceService>(relaxed = true)
|
||||
|
|
@ -513,9 +520,9 @@ class NordicBleInterfaceTest {
|
|||
isBonded = true,
|
||||
eventHandler = eventHandler,
|
||||
cachedServices = {
|
||||
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
|
||||
Service(uuid = SERVICE_UUID) {
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = TORADIO_CHARACTERISTIC,
|
||||
properties =
|
||||
setOf(
|
||||
CharacteristicProperty.WRITE,
|
||||
|
|
@ -524,17 +531,17 @@ class NordicBleInterfaceTest {
|
|||
permission = Permission.WRITE,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMNUM_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = FROMRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.READ),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
Characteristic(
|
||||
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
|
||||
uuid = LOGRADIO_CHARACTERISTIC,
|
||||
properties = setOf(CharacteristicProperty.NOTIFY),
|
||||
permission = Permission.READ,
|
||||
)
|
||||
|
|
@ -561,8 +568,9 @@ class NordicBleInterfaceTest {
|
|||
// Trigger write which will fail
|
||||
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
|
||||
|
||||
// Wait for error propagation
|
||||
delay(500.milliseconds)
|
||||
// Wait for error propagation (retries take time!)
|
||||
// 3 attempts with 500ms delay between them = ~1000ms+
|
||||
delay(2500.milliseconds)
|
||||
|
||||
// Verify onDisconnect was called with error
|
||||
verify { service.onDisconnect(any<BleError>()) }
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ import org.meshtastic.core.service.ConnectionState
|
|||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class MeshServiceBroadcastsTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
|
|
|
|||
|
|
@ -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,12 +14,11 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import com.geeksville.mesh.model.getInitials
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.model.util.getInitials
|
||||
|
||||
class UIUnitTest {
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -96,34 +96,40 @@ internal fun Project.configureGraphTasks() {
|
|||
.map { it.split(",").toSet() }
|
||||
.orElse(setOf("api", "implementation", "baselineProfile", "testedApks"))
|
||||
|
||||
val targetProjectPath = path
|
||||
|
||||
val dumpTask = tasks.register<GraphDumpTask>("graphDump") {
|
||||
projectPath.set(this@configureGraphTasks.path)
|
||||
projectPath.set(targetProjectPath)
|
||||
|
||||
val deps = mutableMapOf<String, Set<Pair<String, String>>>()
|
||||
val projectPlugins = mutableMapOf<String, PluginType>()
|
||||
|
||||
projectPlugins[path] = PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown
|
||||
|
||||
val projectDeps = mutableSetOf<Pair<String, String>>()
|
||||
this@configureGraphTasks.configurations.forEach { config ->
|
||||
if (config.name in supportedConfigurations.get()) {
|
||||
dependenciesData.set(providers.provider {
|
||||
val deps = mutableMapOf<String, Set<Pair<String, String>>>()
|
||||
val projectDeps = mutableSetOf<Pair<String, String>>()
|
||||
configurations.filter { it.name in supportedConfigurations.get() }.forEach { config ->
|
||||
config.dependencies.withType<ProjectDependency>().forEach { dep ->
|
||||
// Fallback to simpler access or path if available.
|
||||
val depPath = dep.path
|
||||
projectDeps.add(config.name to depPath)
|
||||
projectDeps.add(config.name to dep.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
deps[path] = projectDeps
|
||||
|
||||
dependenciesData.set(deps)
|
||||
pluginsData.set(projectPlugins)
|
||||
deps[targetProjectPath] = projectDeps
|
||||
deps
|
||||
})
|
||||
|
||||
pluginsData.set(providers.provider {
|
||||
val projectPlugins = mutableMapOf<String, PluginType>()
|
||||
val type = when {
|
||||
pluginManager.hasPlugin("meshtastic.android.application") || pluginManager.hasPlugin("meshtastic.android.application.compose") -> PluginType.AndroidApplication
|
||||
targetProjectPath.startsWith(":feature:") -> PluginType.AndroidFeature
|
||||
else -> PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown
|
||||
}
|
||||
projectPlugins[targetProjectPath] = type
|
||||
projectPlugins
|
||||
})
|
||||
|
||||
output.set(layout.buildDirectory.file("mermaid/graph.txt"))
|
||||
legend.set(layout.buildDirectory.file("mermaid/legend.txt"))
|
||||
}
|
||||
|
||||
tasks.register<GraphUpdateTask>("graphUpdate") {
|
||||
projectPath.set(this@configureGraphTasks.path)
|
||||
projectPath.set(targetProjectPath)
|
||||
input.set(dumpTask.flatMap { it.output })
|
||||
legend.set(dumpTask.flatMap { it.legend })
|
||||
output.set(layout.projectDirectory.file("README.md"))
|
||||
|
|
@ -180,6 +186,7 @@ private abstract class GraphDumpTask : DefaultTask() {
|
|||
appendLine(" direction TB")
|
||||
appendLine(" L1[Application]:::android-application")
|
||||
appendLine(" L2[Library]:::android-library")
|
||||
appendLine(" L3[Feature]:::android-feature")
|
||||
appendLine(" end")
|
||||
PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") }
|
||||
}
|
||||
|
|
@ -203,7 +210,13 @@ private abstract class GraphUpdateTask : DefaultTask() {
|
|||
val readme = output.get().asFile
|
||||
if (!readme.exists()) return
|
||||
val mermaid = input.get().asFile.readText()
|
||||
// Update logic...
|
||||
readme.writeText(readme.readText().replace(Regex("<!--region graph-->.*?<!--endregion-->", DOT_MATCHES_ALL), "<!--region graph-->\n```mermaid\n$mermaid\n```\n<!--endregion-->"))
|
||||
val currentContent = readme.readText()
|
||||
val newContent = currentContent.replace(
|
||||
Regex("<!--region graph-->.*?<!--endregion-->", DOT_MATCHES_ALL),
|
||||
"<!--region graph-->\n```mermaid\n$mermaid\n```\n<!--endregion-->"
|
||||
)
|
||||
if (currentContent != newContent) {
|
||||
readme.writeText(newContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,9 +105,11 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
|
|||
freeCompilerArgs.addAll(
|
||||
// Enable experimental coroutines APIs, including Flow
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
|
||||
"-opt-in=kotlin.time.ExperimentalTime",
|
||||
"-Xcontext-parameters",
|
||||
"-Xannotation-default-target=param-property"
|
||||
"-Xannotation-default-target=param-property",
|
||||
"-Xskip-prerelease-check"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
# `: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]:::null
|
||||
: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;
|
||||
|
|
|
|||
|
|
@ -1,104 +1,66 @@
|
|||
# Meshtastic Android API
|
||||
# `:core:api` (Meshtastic Android API)
|
||||
|
||||
This module contains the stable AIDL interface and dependencies required to integrate with the Meshtastic Android app.
|
||||
## Overview
|
||||
The `:core:api` module contains the stable AIDL interface and dependencies required for third-party applications to integrate with the Meshtastic Android app.
|
||||
|
||||
## Integration
|
||||
|
||||
[](https://jitpack.io/#meshtastic/Meshtastic-Android)
|
||||
|
||||
To communicate with the Meshtastic Android service from your own application, we recommend using **JitPack**.
|
||||
|
||||
Add the JitPack repository to your root `build.gradle.kts` (or `settings.gradle.kts`):
|
||||
|
||||
```kotlin
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add the dependencies to your module's `build.gradle.kts`:
|
||||
### Dependencies
|
||||
Add the following to your `build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
// Replace 'v2.7.13' with the specific version you need
|
||||
val meshtasticVersion = "v2.7.13"
|
||||
|
||||
// The core AIDL interface and Intent constants
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:$meshtasticVersion")
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:v2.x.x")
|
||||
|
||||
// Data models (DataPacket, MeshUser, NodeInfo, etc.)
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:$meshtasticVersion")
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:v2.x.x")
|
||||
|
||||
// Protobuf definitions (PortNum, Telemetry, etc.)
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:$meshtasticVersion")
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:v2.x.x")
|
||||
}
|
||||
```
|
||||
*(Replace `v2.x.x` with the latest stable version).*
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Bind to the Service
|
||||
|
||||
Use the `IMeshService` interface to bind to the Meshtastic service. It is recommended to query the package manager to find the correct service component, as the package name may vary between build flavors (e.g., Play Store vs. F-Droid).
|
||||
Use the `IMeshService` interface to bind to the Meshtastic service.
|
||||
|
||||
```kotlin
|
||||
val intent = Intent("com.geeksville.mesh.Service")
|
||||
val resolveInfo = packageManager.queryIntentServices(intent, 0)
|
||||
|
||||
if (resolveInfo.isNotEmpty()) {
|
||||
val serviceInfo = resolveInfo[0].serviceInfo
|
||||
intent.setClassName(serviceInfo.packageName, serviceInfo.name)
|
||||
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
// ... query package manager and bind
|
||||
```
|
||||
|
||||
### 2. Interact with the API
|
||||
|
||||
Once bound, cast the `IBinder` to `IMeshService`:
|
||||
|
||||
```kotlin
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val meshService = IMeshService.Stub.asInterface(service)
|
||||
|
||||
// Example: Send a broadcast text message
|
||||
val packet = DataPacket(
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "Hello Meshtastic!".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
id = meshService.packetId,
|
||||
wantAck = true
|
||||
)
|
||||
meshService.send(packet)
|
||||
}
|
||||
```
|
||||
Once bound, cast the `IBinder` to `IMeshService`.
|
||||
|
||||
### 3. Register a BroadcastReceiver
|
||||
Use `MeshtasticIntent` constants for actions. Remember to use `RECEIVER_EXPORTED` on Android 13+.
|
||||
|
||||
To receive packets and status updates, register a `BroadcastReceiver`. Use `MeshtasticIntent` constants for the actions.
|
||||
## Key Components
|
||||
- **`IMeshService.aidl`**: The primary AIDL interface.
|
||||
- **`MeshtasticIntent.kt`**: Defines Intent actions for received messages and status changes.
|
||||
|
||||
**Important:** On Android 13+ (API 33), you **must** use `RECEIVER_EXPORTED` since you are receiving broadcasts from a different application.
|
||||
## Module dependency graph
|
||||
|
||||
```kotlin
|
||||
// Using constants from org.meshtastic.core.api.MeshtasticIntent
|
||||
val intentFilter = IntentFilter().apply {
|
||||
addAction(MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP)
|
||||
addAction(MeshtasticIntent.ACTION_NODE_CHANGE)
|
||||
addAction(MeshtasticIntent.ACTION_CONNECTION_CHANGED)
|
||||
addAction(MeshtasticIntent.ACTION_MESH_DISCONNECTED)
|
||||
}
|
||||
<!--region graph-->
|
||||
```mermaid
|
||||
graph TB
|
||||
:core:api[api]:::android-library
|
||||
:core:api --> :core:model
|
||||
|
||||
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;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(meshtasticReceiver, intentFilter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(meshtasticReceiver, intentFilter)
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
* **`core:api`**: Contains `IMeshService.aidl` and `MeshtasticIntent`.
|
||||
* **`core:model`**: Contains Parcelable data classes like `DataPacket`, `MeshUser`, `NodeInfo`.
|
||||
* **`core:proto`**: Contains the generated Protobuf code (Wire).
|
||||
<!--endregion-->
|
||||
|
|
|
|||
50
core/barcode/README.md
Normal file
50
core/barcode/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# `:core:barcode`
|
||||
|
||||
## Overview
|
||||
The `:core:barcode` module provides barcode and QR code scanning capabilities using Google ML Kit and CameraX. It is used for scanning node configuration, pairing, and contact sharing.
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. `BarcodeScanner`
|
||||
A Composable component that provides a live camera preview and detects barcodes/QR codes in real-time.
|
||||
|
||||
- **Technology:** Uses **CameraX** for camera lifecycle management and **ML Kit Barcode Scanning** for detection.
|
||||
- **Flavors:** Uses the bundled ML Kit library to ensure consistent performance across both `google` and `fdroid` flavors without depending on Google Play Services.
|
||||
|
||||
### 2. `BarcodeUtil`
|
||||
Utility functions for generating and parsing Meshtastic-specific QR codes (e.g., node URLs).
|
||||
|
||||
## Usage
|
||||
The module exposes a scanner that can be integrated into any Compose screen.
|
||||
|
||||
```kotlin
|
||||
BarcodeScanner(
|
||||
onBarcodeDetected = { barcode ->
|
||||
// Handle scanned barcode
|
||||
},
|
||||
onDismiss = {
|
||||
// Handle dismiss
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
<!--region graph-->
|
||||
```mermaid
|
||||
graph TB
|
||||
:core:barcode[barcode]:::android-library
|
||||
:core:barcode -.-> :core:strings
|
||||
|
||||
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-->
|
||||
86
core/ble/README.md
Normal file
86
core/ble/README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# `:core:ble`
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
<!--region graph-->
|
||||
```mermaid
|
||||
graph TB
|
||||
:core:ble[ble]:::android-library
|
||||
:core:ble -.-> :core:common
|
||||
:core:ble -.-> :core:di
|
||||
:core:ble -.-> :core:model
|
||||
|
||||
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-->
|
||||
|
||||
## Overview
|
||||
|
||||
The `:core:ble` module contains the foundation for Bluetooth Low Energy (BLE) communication in the Meshtastic Android app. It has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**.
|
||||
|
||||
This modernization replaces legacy callback-based implementations with robust, Coroutine-based architecture, ensuring better stability, maintainability, and standard compliance.
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. `NordicBleInterface`
|
||||
The primary implementation of `IRadioInterface` for BLE devices. It acts as the bridge between the app's `RadioInterfaceService` and the physical Bluetooth device.
|
||||
|
||||
- **Responsibility:**
|
||||
- Managing the connection lifecycle.
|
||||
- Discovering GATT services and characteristics.
|
||||
- Handling data transmission (ToRadio) and reception (FromRadio).
|
||||
- Managing MTU negotiation and connection priority.
|
||||
|
||||
### 2. `BluetoothRepository`
|
||||
A Singleton repository responsible for the global state of Bluetooth on the Android device.
|
||||
|
||||
- **Features:**
|
||||
- **State Management:** Exposes a `StateFlow<BluetoothState>` reflecting whether Bluetooth is enabled, permissions are granted, and which devices are bonded.
|
||||
- **Scanning:** Uses Nordic's `Scanner` to find devices.
|
||||
- **Bonding:** Handles the creation of bonds with peripherals.
|
||||
|
||||
### 3. `BleConnection`
|
||||
A wrapper around Nordic's `ClientBleGatt` that simplifies the connection process.
|
||||
|
||||
- **Features:**
|
||||
- **Connection & Await:** Provides suspend functions to connect and wait for a specific connection state.
|
||||
- **Service Discovery:** Helper functions to discover specific services and characteristics with timeouts and retries.
|
||||
- **Observability:** Logs connection parameters, PHY updates, and state changes.
|
||||
|
||||
### 4. `BleRetry`
|
||||
A utility for executing BLE operations with exponential backoff and retry logic. This is crucial for handling the inherent unreliability of wireless communication.
|
||||
|
||||
## Usage
|
||||
|
||||
Dependencies are managed via the version catalog (`libs.versions.toml`).
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
nordic-ble = "2.0.0-alpha15"
|
||||
nordic-common = "2.8.2"
|
||||
|
||||
[libraries]
|
||||
nordic-client-android = { module = "no.nordicsemi.kotlin.ble:client-android", version.ref = "nordic-ble" }
|
||||
# ... other nordic dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The module follows a clean architecture approach:
|
||||
|
||||
- **Repository Pattern:** `BluetoothRepository` mediates data access.
|
||||
- **Coroutines & Flow:** All asynchronous operations use Kotlin Coroutines and Flows.
|
||||
- **Dependency Injection:** Hilt is used for dependency injection.
|
||||
|
||||
## Testing
|
||||
|
||||
The module includes unit tests for key components, mocking the underlying Nordic libraries to ensure logic correctness without requiring a physical device.
|
||||
51
core/ble/build.gradle.kts
Normal file
51
core/ble/build.gradle.kts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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.hilt)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> { namespace = "org.meshtastic.core.ble" }
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
|
||||
api(libs.nordic.client.android)
|
||||
api(libs.nordic.ble.env.android)
|
||||
api(libs.nordic.ble.env.android.compose)
|
||||
api(libs.nordic.common.scanner.ble)
|
||||
api(libs.nordic.common.core)
|
||||
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.javax.inject)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.nordic.client.android.mock)
|
||||
testImplementation(libs.nordic.client.core.mock)
|
||||
testImplementation(libs.nordic.core.mock)
|
||||
testImplementation(libs.androidx.lifecycle.testing)
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* 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.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.android.common.core.simpleSharedFlow
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private const val SERVICE_DISCOVERY_TIMEOUT_MS = 10_000L
|
||||
|
||||
/**
|
||||
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
|
||||
* discovery.
|
||||
*
|
||||
* @param centralManager The Nordic [CentralManager] to use for connection.
|
||||
* @param scope The [CoroutineScope] in which to monitor connection state.
|
||||
* @param tag A tag for logging.
|
||||
*/
|
||||
class BleConnection(
|
||||
private val centralManager: CentralManager,
|
||||
private val scope: CoroutineScope,
|
||||
private val tag: String = "BLE",
|
||||
) {
|
||||
/** The currently connected [Peripheral], or null if not connected. */
|
||||
var peripheral: Peripheral? = null
|
||||
private set
|
||||
|
||||
private val _connectionState = simpleSharedFlow<ConnectionState>()
|
||||
|
||||
/** A flow of [ConnectionState] changes for the current [peripheral]. */
|
||||
val connectionState: SharedFlow<ConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
private var stateJob: Job? = null
|
||||
|
||||
/**
|
||||
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
|
||||
* Use [connectAndAwait] if you need to wait for the connection to be established.
|
||||
*
|
||||
* @param p The peripheral to connect to.
|
||||
*/
|
||||
suspend fun connect(p: Peripheral) {
|
||||
stateJob?.cancel()
|
||||
peripheral = p
|
||||
|
||||
centralManager.connect(
|
||||
peripheral = p,
|
||||
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
|
||||
stateJob =
|
||||
p.state
|
||||
.onEach { state ->
|
||||
Logger.d { "[$tag] Connection state changed to $state" }
|
||||
|
||||
if (state is ConnectionState.Connected) {
|
||||
p.requestConnectionPriority(ConnectionPriority.HIGH)
|
||||
observePeripheralDetails(p)
|
||||
}
|
||||
|
||||
_connectionState.emit(state)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the given [Peripheral] and waits for a terminal state (Connected or Disconnected).
|
||||
*
|
||||
* @param p The peripheral to connect to.
|
||||
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
|
||||
* @return The final [ConnectionState].
|
||||
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
|
||||
*/
|
||||
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long): ConnectionState {
|
||||
connect(p)
|
||||
return withTimeout(timeoutMs) {
|
||||
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
|
||||
}
|
||||
}
|
||||
|
||||
/** Discovers characteristics for a specific service with retries. */
|
||||
@Suppress("ReturnCount")
|
||||
suspend fun discoverCharacteristics(
|
||||
serviceUuid: Uuid,
|
||||
characteristicUuids: List<Uuid>,
|
||||
): Map<Uuid, RemoteCharacteristic>? = retryBleOperation(tag = tag) {
|
||||
val p = peripheral ?: return@retryBleOperation null
|
||||
val services =
|
||||
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
|
||||
val service = services.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
|
||||
|
||||
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
|
||||
for (uuid in characteristicUuids) {
|
||||
val char = service.characteristics.find { it.uuid == uuid }
|
||||
if (char != null) {
|
||||
result[uuid] = char
|
||||
}
|
||||
}
|
||||
return@retryBleOperation if (result.size == characteristicUuids.size) result else null
|
||||
}
|
||||
|
||||
private fun observePeripheralDetails(p: Peripheral) {
|
||||
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
|
||||
|
||||
p.connectionParameters
|
||||
.onEach { params -> Logger.i { "[$tag] BLE connection parameters changed to $params" } }
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
/** Disconnects from the current peripheral. */
|
||||
suspend fun disconnect() {
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
peripheral?.disconnect()
|
||||
peripheral = null
|
||||
}
|
||||
}
|
||||
|
|
@ -14,10 +14,8 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.repository.radio
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.geeksville.mesh.service.RadioNotConnectedException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.BluetoothUnavailableException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
|
||||
|
|
@ -26,6 +24,7 @@ import no.nordicsemi.kotlin.ble.client.exception.ScanningException
|
|||
import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.exception.BluetoothException
|
||||
import no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException
|
||||
import no.nordicsemi.kotlin.ble.core.exception.GattException
|
||||
import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
|
||||
|
||||
|
|
@ -130,9 +129,6 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) {
|
|||
else -> BluetoothError(exception)
|
||||
}
|
||||
}
|
||||
|
||||
is RadioNotConnectedException -> PeripheralNotFound
|
||||
is ManagerClosedException -> ManagerClosed(exception)
|
||||
else -> GenericError(exception)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,8 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.repository.bluetooth
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
|
|
@ -28,17 +27,25 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.native
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object BluetoothRepositoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCentralManager(@ApplicationContext context: Context, coroutineScope: CoroutineScope): CentralManager =
|
||||
CentralManager.native(context, coroutineScope)
|
||||
object BleModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
|
||||
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
|
||||
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBleSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
58
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
Normal file
58
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Retries a BLE operation a specified number of times with a delay between attempts.
|
||||
*
|
||||
* @param count The number of attempts to make.
|
||||
* @param delayMs The delay in milliseconds between attempts.
|
||||
* @param tag A tag for logging.
|
||||
* @param block The operation to perform.
|
||||
* @return The result of the operation.
|
||||
* @throws Exception if the operation fails after all attempts.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
suspend fun <T> retryBleOperation(
|
||||
count: Int = 3,
|
||||
delayMs: Long = 500L,
|
||||
tag: String = "BLE",
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
var currentAttempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
currentAttempt++
|
||||
if (currentAttempt >= count) {
|
||||
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
|
||||
throw e
|
||||
}
|
||||
Logger.w(e) {
|
||||
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
|
||||
}
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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.ble
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConjunctionFilterScope
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* A wrapper around [CentralManager]'s scanning capabilities to provide a consistent and easy-to-use API for BLE
|
||||
* scanning across the application.
|
||||
*
|
||||
* @param centralManager The Nordic [CentralManager] to use for scanning.
|
||||
*/
|
||||
class BleScanner @Inject constructor(private val centralManager: CentralManager) {
|
||||
|
||||
/**
|
||||
* Scans for BLE devices.
|
||||
*
|
||||
* @param timeout The duration of the scan.
|
||||
* @param filterBlock Optional filter configuration block.
|
||||
* @return A [Flow] of discovered [Peripheral]s.
|
||||
*/
|
||||
fun scan(timeout: Duration, filterBlock: (ConjunctionFilterScope.() -> Unit)? = null): Flow<Peripheral> =
|
||||
if (filterBlock != null) {
|
||||
centralManager.scan(timeout, filterBlock)
|
||||
} else {
|
||||
centralManager.scan(timeout)
|
||||
}
|
||||
.distinctByPeripheral()
|
||||
.map { it.peripheral }
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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.ble
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import dagger.Lazy
|
||||
import javax.inject.Inject
|
||||
|
||||
/** BroadcastReceiver to handle Bluetooth adapter and device state changes. */
|
||||
class BluetoothBroadcastReceiver @Inject constructor(private val bluetoothRepository: Lazy<BluetoothRepository>) :
|
||||
BroadcastReceiver() {
|
||||
|
||||
val intentFilter: IntentFilter
|
||||
get() =
|
||||
IntentFilter().apply {
|
||||
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
BluetoothAdapter.ACTION_STATE_CHANGED,
|
||||
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
|
||||
-> {
|
||||
bluetoothRepository.get().refreshState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,51 +14,37 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.repository.bluetooth
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.repository.radio.BleConstants.BLE_NAME_PATTERN
|
||||
import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID
|
||||
import com.geeksville.mesh.util.registerReceiverCompat
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
|
||||
import no.nordicsemi.kotlin.ble.core.Manager
|
||||
import org.meshtastic.core.common.hasBluetoothPermission
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.di.ProcessLifecycle
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.toKotlinUuid
|
||||
|
||||
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
|
||||
@Singleton
|
||||
class BluetoothRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val application: Application,
|
||||
private val bluetoothBroadcastReceiverLazy: Lazy<BluetoothBroadcastReceiver>,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@ProcessLifecycle private val processLifecycle: Lifecycle,
|
||||
private val centralManager: CentralManager,
|
||||
private val androidEnvironment: AndroidEnvironment,
|
||||
) {
|
||||
private val _state =
|
||||
MutableStateFlow(
|
||||
|
|
@ -70,20 +56,9 @@ constructor(
|
|||
)
|
||||
val state: StateFlow<BluetoothState> = _state.asStateFlow()
|
||||
|
||||
private val _scannedDevices = MutableStateFlow<List<Peripheral>>(emptyList())
|
||||
val scannedDevices: StateFlow<List<Peripheral>> = _scannedDevices.asStateFlow()
|
||||
|
||||
private val _isScanning = MutableStateFlow(false)
|
||||
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
|
||||
|
||||
private var scanJob: Job? = null
|
||||
|
||||
init {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
updateBluetoothState()
|
||||
bluetoothBroadcastReceiverLazy.get().let { receiver ->
|
||||
application.registerReceiverCompat(receiver, receiver.intentFilter)
|
||||
}
|
||||
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,43 +69,6 @@ constructor(
|
|||
/** @return true for a valid Bluetooth address, false otherwise */
|
||||
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
|
||||
|
||||
/** Starts a BLE scan for Meshtastic devices. The results are published to the [scannedDevices] flow. */
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScan() {
|
||||
if (isScanning.value) return
|
||||
|
||||
scanJob?.cancel()
|
||||
_scannedDevices.value = emptyList()
|
||||
|
||||
scanJob =
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
centralManager
|
||||
.scan(5.seconds) { ServiceUuid(BTM_SERVICE_UUID.toKotlinUuid()) }
|
||||
.distinctByPeripheral()
|
||||
.map { it.peripheral }
|
||||
.onStart { _isScanning.value = true }
|
||||
.onCompletion { _isScanning.value = false }
|
||||
.catch { ex ->
|
||||
Logger.w(ex) { "Bluetooth scan failed" }
|
||||
_isScanning.value = false
|
||||
}
|
||||
.collect { peripheral ->
|
||||
// Add or update the peripheral in our list
|
||||
val currentList = _scannedDevices.value
|
||||
_scannedDevices.value =
|
||||
(currentList.filterNot { it.address == peripheral.address } + peripheral)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops the currently active BLE scan. */
|
||||
fun stopScan() {
|
||||
scanJob?.cancel()
|
||||
scanJob = null
|
||||
_isScanning.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding
|
||||
* process is finished. After successful bonding, the repository's state is refreshed to include the new bonded
|
||||
|
|
@ -146,15 +84,20 @@ constructor(
|
|||
refreshState()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
internal suspend fun updateBluetoothState() {
|
||||
val hasPerms = application.hasBluetoothPermission()
|
||||
val enabled = centralManager.state.value == Manager.State.POWERED_ON
|
||||
val hasPerms =
|
||||
if (androidEnvironment.androidSdkVersion >= Build.VERSION_CODES.S) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val newState =
|
||||
BluetoothState(
|
||||
hasPermissions = hasPerms,
|
||||
enabled = enabled,
|
||||
bondedDevices = getBondedAppPeripherals(enabled),
|
||||
bondedDevices = getBondedAppPeripherals(enabled, hasPerms),
|
||||
)
|
||||
|
||||
_state.emit(newState)
|
||||
|
|
@ -162,19 +105,17 @@ constructor(
|
|||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun getBondedAppPeripherals(enabled: Boolean): List<Peripheral> =
|
||||
if (enabled && application.hasBluetoothPermission()) {
|
||||
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<Peripheral> =
|
||||
if (enabled && hasPerms) {
|
||||
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
|
||||
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
|
||||
val hasRequiredService =
|
||||
peripheral.services(listOf(BTM_SERVICE_UUID.toKotlinUuid())).value?.isNotEmpty() ?: false
|
||||
val hasRequiredService = peripheral.services(listOf(SERVICE_UUID)).value?.isNotEmpty() ?: false
|
||||
|
||||
return nameMatches || hasRequiredService
|
||||
}
|
||||
|
|
@ -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,8 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.repository.bluetooth
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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.ble
|
||||
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/** Constants for Meshtastic Bluetooth LE interaction. */
|
||||
object MeshtasticBleConstants {
|
||||
/** Pattern for Meshtastic device names (e.g., Meshtastic_1234). */
|
||||
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
|
||||
|
||||
/** The Meshtastic service UUID. */
|
||||
val SERVICE_UUID: Uuid = Uuid.parse("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
|
||||
|
||||
/** Characteristic for sending data to the radio. */
|
||||
val TORADIO_CHARACTERISTIC: Uuid = Uuid.parse("f75c76d2-129e-4dad-a1dd-7866124401e7")
|
||||
|
||||
/** Characteristic for receiving packet count notifications. */
|
||||
val FROMNUM_CHARACTERISTIC: Uuid = Uuid.parse("ed9da18c-a800-4f66-a670-aa7547e34453")
|
||||
|
||||
/** Characteristic for reading data from the radio. */
|
||||
val FROMRADIO_CHARACTERISTIC: Uuid = Uuid.parse("2c55e69e-4993-11ed-b878-0242ac120002")
|
||||
|
||||
/** Characteristic for receiving log notifications from the radio. */
|
||||
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* 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.ble
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.testing.TestLifecycleOwner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.AddressType
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BluetoothRepositoryTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, default = testDispatcher, io = testDispatcher)
|
||||
|
||||
private lateinit var mockEnvironment: MockAndroidEnvironment
|
||||
private lateinit var lifecycleOwner: TestLifecycleOwner
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockEnvironment =
|
||||
MockAndroidEnvironment.Api31(
|
||||
isBluetoothEnabled = true,
|
||||
isBluetoothScanPermissionGranted = true,
|
||||
isBluetoothConnectPermissionGranted = true,
|
||||
)
|
||||
lifecycleOwner =
|
||||
TestLifecycleOwner(initialState = Lifecycle.State.RESUMED, coroutineDispatcher = testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state reflects environment`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
|
||||
runCurrent()
|
||||
val state = repository.state.value
|
||||
assertTrue(state.enabled)
|
||||
assertTrue(state.hasPermissions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state updates when bluetooth is disabled`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
|
||||
mockEnvironment.simulatePowerOff()
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertFalse(state.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bonded devices are correctly identified`() = runTest(testDispatcher) {
|
||||
val address = "C0:00:00:00:00:03"
|
||||
val peripheral =
|
||||
PeripheralSpec.simulatePeripheral(
|
||||
identifier = address,
|
||||
addressType = AddressType.RANDOM_STATIC,
|
||||
proximity = Proximity.IMMEDIATE,
|
||||
) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Meshtastic_5678")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_5678",
|
||||
isBonded = true,
|
||||
eventHandler = object : PeripheralSpecEventHandler {},
|
||||
) {
|
||||
Service(uuid = SERVICE_UUID) {}
|
||||
}
|
||||
}
|
||||
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
centralManager.simulatePeripherals(listOf(peripheral))
|
||||
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
repository.refreshState()
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
|
||||
assertEquals(address, state.bondedDevices.first().address)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,28 @@
|
|||
# `:core:common`
|
||||
|
||||
## Overview
|
||||
The `:core:common` module contains low-level utility functions, extensions, and common data structures that do not depend on any other Meshtastic-specific modules. It is designed to be highly reusable across the project.
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. `util` package
|
||||
Contains general-purpose extensions and helpers:
|
||||
- **Coroutines**: Helpers for structured concurrency and Flow transformations.
|
||||
- **Time**: Utilities for handling timestamps and durations.
|
||||
- **Exceptions**: Standardized exception types for common error scenarios.
|
||||
|
||||
### 2. `ByteUtils.kt`
|
||||
Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
|
||||
|
||||
### 3. `BuildConfigProvider.kt`
|
||||
An interface for accessing build-time configuration in a multiplatform-friendly way.
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
<!--region graph-->
|
||||
```mermaid
|
||||
graph TB
|
||||
:core:common[common]:::null
|
||||
:core:common[common]:::kmp-library
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,19 @@ kotlin {
|
|||
android { androidResources.enable = false }
|
||||
|
||||
sourceSets {
|
||||
androidMain.dependencies { implementation(libs.androidx.core.ktx) }
|
||||
commonTest.dependencies { implementation(kotlin("test")) }
|
||||
commonMain.dependencies {
|
||||
implementation(libs.javax.inject)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.datetime)
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
api(libs.nordic.common.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 +14,12 @@
|
|||
* 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.common
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
|
|
@ -73,3 +74,18 @@ fun Context.hasLocationPermission(): Boolean {
|
|||
val perms = listOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
return perms.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension for Context to register a BroadcastReceiver in a compatible way across Android versions.
|
||||
*
|
||||
* @param receiver The receiver to register.
|
||||
* @param filter The intent filter.
|
||||
* @param flag The export flag (defaults to [ContextCompat.RECEIVER_EXPORTED]).
|
||||
*/
|
||||
fun Context.registerReceiverCompat(
|
||||
receiver: BroadcastReceiver,
|
||||
filter: IntentFilter,
|
||||
flag: Int = ContextCompat.RECEIVER_EXPORTED,
|
||||
) {
|
||||
ContextCompat.registerReceiver(this, receiver, filter, flag)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,18 +14,18 @@
|
|||
* 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.ui.timezone
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import org.meshtastic.core.model.util.toPosixString
|
||||
import java.time.ZoneId
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* Generates a POSIX time zone string from a [ZoneId].
|
||||
* A specialized [FileOutputStream] that writes data to a file in the application's external files directory. Primarily
|
||||
* used for low-level protocol debugging and packet logging.
|
||||
*
|
||||
* @deprecated Use [org.meshtastic.core.model.util.toPosixString] instead.
|
||||
* @param context The context used to locate the external files directory.
|
||||
* @param name The name of the log file.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Use org.meshtastic.core.model.util.toPosixString instead",
|
||||
replaceWith = ReplaceWith("this.toPosixString()", "org.meshtastic.core.model.util.toPosixString"),
|
||||
)
|
||||
fun ZoneId.toPosixString(): String = this.toPosixString()
|
||||
class BinaryLogFile(context: Context, name: String) :
|
||||
FileOutputStream(File(context.getExternalFilesDir(null), name), true)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,15 +14,14 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.android
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/** Created by kevinh on 1/14/16. */
|
||||
/** Utility for checking build properties, such as emulator detection. */
|
||||
object BuildUtils {
|
||||
// Are we running on the emulator?
|
||||
val isEmulator
|
||||
/** Whether the app is currently running on an emulator. */
|
||||
val isEmulator: Boolean
|
||||
get() =
|
||||
Build.FINGERPRINT.startsWith("generic") ||
|
||||
Build.FINGERPRINT.startsWith("unknown") ||
|
||||
|
|
@ -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,8 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.util
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
|
|
@ -23,25 +22,31 @@ import android.content.Intent
|
|||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
|
||||
/** Reads a [Parcelable] from a [Parcel] in a backward-compatible way. */
|
||||
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
|
||||
ParcelCompat.readParcelable(this, loader, T::class.java)
|
||||
|
||||
/** Retrieves a [Parcelable] extra from an [Intent] in a backward-compatible way. */
|
||||
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String?): T? =
|
||||
IntentCompat.getParcelableExtra(this, key, T::class.java)
|
||||
|
||||
/** Retrieves [PackageInfo] for a given package name in a backward-compatible way. */
|
||||
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
|
||||
@Suppress("DEPRECATION")
|
||||
getPackageInfo(packageName, flags)
|
||||
}
|
||||
|
||||
/** Registers a [BroadcastReceiver] using [ContextCompat] to ensure consistent behavior across Android versions. */
|
||||
fun Context.registerReceiverCompat(
|
||||
receiver: BroadcastReceiver,
|
||||
filter: IntentFilter,
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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.common.util
|
||||
|
||||
import android.os.RemoteException
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
/**
|
||||
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
|
||||
* should not crash the process but are still unexpected.
|
||||
*/
|
||||
fun exceptionReporter(inner: () -> Unit) {
|
||||
try {
|
||||
inner()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an operation and converts any thrown exceptions into [RemoteException] for safe return through an AIDL
|
||||
* interface.
|
||||
*/
|
||||
fun <T> toRemoteExceptions(inner: () -> T): T = try {
|
||||
inner()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Logger.e(ex) { "Uncaught exception in service call, returning RemoteException to client" }
|
||||
when (ex) {
|
||||
is RemoteException -> throw ex
|
||||
else -> throw RemoteException(ex.message).apply { initCause(ex) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Awaits the latch for the given [Duration].
|
||||
*
|
||||
* @param timeout The maximum time to wait.
|
||||
* @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero.
|
||||
*/
|
||||
fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
|
||||
|
||||
/** Converts this [Instant] to a legacy [Date]. */
|
||||
fun Instant.toDate(): Date = Date(this.toEpochMilliseconds())
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* 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.common.util
|
||||
|
||||
import android.text.InputFilter
|
||||
import android.text.Spanned
|
||||
|
||||
/**
|
||||
* An [InputFilter] that constrains text length based on UTF-8 byte count instead of character count. This is
|
||||
* particularly useful for fields that must be stored in byte-limited buffers, such as hardware configuration fields.
|
||||
*
|
||||
* @param maxBytes The maximum allowed length in UTF-8 bytes.
|
||||
*/
|
||||
class Utf8ByteLengthFilter(private val maxBytes: Int) : InputFilter {
|
||||
|
||||
private companion object {
|
||||
const val ONE_BYTE_LIMIT = '\u0080'
|
||||
const val TWO_BYTE_LIMIT = '\u0800'
|
||||
const val BYTES_1 = 1
|
||||
const val BYTES_2 = 2
|
||||
const val BYTES_3 = 3
|
||||
}
|
||||
|
||||
override fun filter(
|
||||
source: CharSequence,
|
||||
start: Int,
|
||||
end: Int,
|
||||
dest: Spanned,
|
||||
dstart: Int,
|
||||
dend: Int,
|
||||
): CharSequence? {
|
||||
val srcByteCount = countUtf8Bytes(source, start, end)
|
||||
|
||||
// Calculate bytes in dest excluding the range being replaced
|
||||
val destLen = dest.length
|
||||
var destByteCount = 0
|
||||
destByteCount += countUtf8Bytes(dest, 0, dstart)
|
||||
destByteCount += countUtf8Bytes(dest, dend, destLen)
|
||||
|
||||
var keepBytes = maxBytes - destByteCount
|
||||
return when {
|
||||
keepBytes <= 0 -> ""
|
||||
keepBytes >= srcByteCount -> null
|
||||
else -> {
|
||||
for (i in start until end) {
|
||||
val c = source[i]
|
||||
keepBytes -= getByteCount(c)
|
||||
if (keepBytes < 0) {
|
||||
return source.subSequence(start, i)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun countUtf8Bytes(seq: CharSequence, start: Int, end: Int): Int {
|
||||
var count = 0
|
||||
for (i in start until end) {
|
||||
count += getByteCount(seq[i])
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun getByteCount(c: Char): Int = when {
|
||||
c < ONE_BYTE_LIMIT -> BYTES_1
|
||||
c < TWO_BYTE_LIMIT -> BYTES_2
|
||||
else -> BYTES_3
|
||||
}
|
||||
}
|
||||
|
|
@ -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,33 +14,30 @@
|
|||
* 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.common.util
|
||||
|
||||
package com.geeksville.mesh.concurrent
|
||||
|
||||
import com.geeksville.mesh.util.Exceptions
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
private val errorHandler =
|
||||
CoroutineExceptionHandler { _, exception ->
|
||||
Exceptions.report(
|
||||
exception,
|
||||
"MeshService-coroutine",
|
||||
"coroutine-exception"
|
||||
)
|
||||
}
|
||||
private val errorHandler = CoroutineExceptionHandler { _, exception ->
|
||||
Exceptions.report(exception, "coroutine-exception-handler", "Uncaught coroutine exception")
|
||||
}
|
||||
|
||||
/// Wrap launch with an exception handler, FIXME, move into a utility lib
|
||||
/**
|
||||
* Launches a new coroutine with a central [CoroutineExceptionHandler] that reports errors to [Exceptions].
|
||||
*
|
||||
* @param context Additional to [CoroutineExceptionHandler] context.
|
||||
* @param start Coroutine start option.
|
||||
* @param block The coroutine code block.
|
||||
* @return The launched [Job].
|
||||
*/
|
||||
fun CoroutineScope.handledLaunch(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
) = this.launch(
|
||||
context = context + com.geeksville.mesh.concurrent.errorHandler,
|
||||
start = start,
|
||||
block = block
|
||||
)
|
||||
block: suspend CoroutineScope.() -> Unit,
|
||||
): Job = launch(context = context + errorHandler, start = start, block = block)
|
||||
|
|
@ -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,8 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.concurrent
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
|
|
@ -29,14 +28,14 @@ import co.touchlab.kermit.Logger
|
|||
class DeferredExecution {
|
||||
private val queue = mutableListOf<() -> Unit>()
|
||||
|
||||
// / Queue some new work
|
||||
/** Queues new work to be executed later. */
|
||||
fun add(fn: () -> Unit) {
|
||||
queue.add(fn)
|
||||
}
|
||||
|
||||
// / run all work in the queue and clear it to be ready to accept new work
|
||||
/** Runs all work in the queue and clears it. */
|
||||
fun run() {
|
||||
Logger.d { "Running deferred execution numjobs=${queue.size}" }
|
||||
Logger.d { "Running deferred execution, numJobs=${queue.size}" }
|
||||
queue.forEach { it() }
|
||||
queue.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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.common.util
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
object Exceptions {
|
||||
/** Set by the application to provide a custom crash reporting implementation. */
|
||||
var reporter: ((Throwable, String?, String?) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Report an exception to the configured reporter (if any) after logging it.
|
||||
*
|
||||
* @param exception The exception to report.
|
||||
* @param tag An optional tag for the report.
|
||||
* @param message An optional message providing context.
|
||||
*/
|
||||
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
|
||||
// Log locally first
|
||||
Logger.e(exception) { "Exceptions.report: $tag $message" }
|
||||
reporter?.invoke(exception, tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps and discards exceptions, optionally logging them. */
|
||||
fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
|
||||
try {
|
||||
inner()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
if (!silent) {
|
||||
Logger.w(ex) { "Ignoring exception" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,8 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.concurrent
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -23,18 +22,15 @@ import java.util.concurrent.atomic.AtomicReference
|
|||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A helper class that manages a single [Job].
|
||||
*
|
||||
* When a new job is launched, the previous one is cancelled. This is useful for ensuring that only one operation of a
|
||||
* certain type is running at a time.
|
||||
* A helper class that manages a single [Job]. When a new job is launched, the previous one is cancelled. This is useful
|
||||
* for ensuring that only one operation of a certain type is running at a time.
|
||||
*/
|
||||
class SequentialJob @Inject constructor() {
|
||||
private val job = AtomicReference<Job?>(null)
|
||||
|
||||
/**
|
||||
* Cancels the previous job (if any) and launches a new one in the given [scope].
|
||||
*
|
||||
* The new job uses [handledLaunch] to ensure exceptions are reported.
|
||||
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
|
||||
* to ensure exceptions are reported.
|
||||
*/
|
||||
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
|
||||
cancel()
|
||||
|
|
@ -14,36 +14,28 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.concurrent
|
||||
|
||||
import org.meshtastic.core.model.util.nowMillis
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/** A deferred execution object (with various possible implementations) */
|
||||
interface Continuation<in T> {
|
||||
abstract fun resume(res: Result<T>)
|
||||
|
||||
// syntactic sugar
|
||||
fun resume(res: Result<T>)
|
||||
|
||||
/** Syntactic sugar for resuming with success. */
|
||||
fun resumeSuccess(res: T) = resume(Result.success(res))
|
||||
|
||||
fun resumeWithException(ex: Throwable) = try {
|
||||
resume(Result.failure(ex))
|
||||
} catch (ex: Throwable) {
|
||||
// Logger.e { "Ignoring $ex while resuming because we are the ones who threw it" }
|
||||
throw ex
|
||||
}
|
||||
/** Syntactic sugar for resuming with failure. */
|
||||
fun resumeWithException(ex: Throwable) = resume(Result.failure(ex))
|
||||
}
|
||||
|
||||
/** An async continuation that just calls a callback when the result is available */
|
||||
/** An async continuation that calls a callback when the result is available. */
|
||||
class CallbackContinuation<in T>(private val cb: (Result<T>) -> Unit) : Continuation<T> {
|
||||
override fun resume(res: Result<T>) = cb(res)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a blocking/threaded version of coroutine Continuation
|
||||
* A blocking version of coroutine Continuation using traditional threading primitives.
|
||||
*
|
||||
* A little bit ugly, but the coroutine version has a nasty internal bug that showed up in my SyncBluetoothDevice so I
|
||||
* needed a quick workaround.
|
||||
* This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code.
|
||||
*/
|
||||
class SyncContinuation<T> : Continuation<T> {
|
||||
|
||||
|
|
@ -61,7 +53,13 @@ class SyncContinuation<T> : Continuation<T> {
|
|||
}
|
||||
}
|
||||
|
||||
// Wait for the result (or throw an exception)
|
||||
/**
|
||||
* Blocks the current thread until the result is available or the timeout expires.
|
||||
*
|
||||
* @param timeoutMsecs Maximum time to wait in milliseconds. If 0, waits indefinitely.
|
||||
* @return The result of the operation.
|
||||
* @throws IllegalStateException if a timeout occurs or if an internal error happens.
|
||||
*/
|
||||
@Suppress("NestedBlockDepth")
|
||||
fun await(timeoutMsecs: Long = 0): T {
|
||||
lock.lock()
|
||||
|
|
@ -70,10 +68,7 @@ class SyncContinuation<T> : Continuation<T> {
|
|||
while (result == null) {
|
||||
if (timeoutMsecs > 0) {
|
||||
val remaining = timeoutMsecs - (nowMillis - startT)
|
||||
if (remaining <= 0) {
|
||||
throw Exception("SyncContinuation timeout")
|
||||
}
|
||||
// await returns false if waiting time elapsed
|
||||
check(remaining > 0) { "SyncContinuation timeout" }
|
||||
condition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
} else {
|
||||
condition.await()
|
||||
|
|
@ -81,11 +76,8 @@ class SyncContinuation<T> : Continuation<T> {
|
|||
}
|
||||
|
||||
val r = result
|
||||
if (r != null) {
|
||||
return r.getOrThrow()
|
||||
} else {
|
||||
throw Exception("This shouldn't happen")
|
||||
}
|
||||
checkNotNull(r) { "Unexpected null result in SyncContinuation" }
|
||||
return r.getOrThrow()
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
|
|
@ -93,17 +85,13 @@ class SyncContinuation<T> : Continuation<T> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Calls an init function which is responsible for saving our continuation so that some other thread can call resume or
|
||||
* resume with exception.
|
||||
* Calls an [initfn] that is responsible for starting an operation and saving the [SyncContinuation]. Then blocks the
|
||||
* current thread until the operation completes or times out.
|
||||
*
|
||||
* Essentially this is a blocking version of the (buggy) coroutine suspendCoroutine
|
||||
* Essentially a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine].
|
||||
*/
|
||||
fun <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
|
||||
val cont = SyncContinuation<T>()
|
||||
|
||||
// First call the init funct
|
||||
initfn(cont)
|
||||
|
||||
// Now wait for the continuation to finish
|
||||
return cont.await(timeoutMsecs)
|
||||
}
|
||||
|
|
@ -14,24 +14,12 @@
|
|||
* 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.model.util
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Awaits the latch for the given [Duration].
|
||||
*
|
||||
* @param timeout The maximum time to wait.
|
||||
* @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero.
|
||||
*/
|
||||
fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
|
||||
|
||||
/** Accessor for the current time in milliseconds. */
|
||||
val nowMillis: Long
|
||||
get() = nowInstant.toEpochMilliseconds()
|
||||
|
|
@ -48,9 +36,6 @@ val nowSeconds: Long
|
|||
val systemTimeZone: TimeZone
|
||||
get() = TimeZone.currentSystemDefault()
|
||||
|
||||
/** Converts this [Instant] to a legacy [Date]. */
|
||||
fun Instant.toDate(): Date = Date(this.toEpochMilliseconds())
|
||||
|
||||
/** Converts these milliseconds to an [Instant]. */
|
||||
fun Long.toInstant(): Instant = Instant.fromEpochMilliseconds(this)
|
||||
|
||||
|
|
@ -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,17 +14,16 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.concurrent
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SequentialJobTest {
|
||||
|
||||
private val sequentialJob = SequentialJob()
|
||||
|
|
@ -45,7 +44,7 @@ class SequentialJobTest {
|
|||
}
|
||||
|
||||
advanceTimeBy(100)
|
||||
assertTrue("Job 1 should be active", job1Active)
|
||||
assertTrue(job1Active, "Job 1 should be active")
|
||||
|
||||
// Launch second job
|
||||
sequentialJob.launch(this) {
|
||||
|
|
@ -53,7 +52,7 @@ class SequentialJobTest {
|
|||
}
|
||||
|
||||
advanceTimeBy(100)
|
||||
assertTrue("Job 1 should be cancelled", job1Cancelled)
|
||||
assertTrue(job1Cancelled, "Job 1 should be cancelled")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -71,11 +70,11 @@ class SequentialJobTest {
|
|||
}
|
||||
|
||||
advanceTimeBy(100)
|
||||
assertTrue("Job should be active", jobActive)
|
||||
assertTrue(jobActive, "Job should be active")
|
||||
|
||||
sequentialJob.cancel()
|
||||
|
||||
advanceTimeBy(100)
|
||||
assertTrue("Job should be cancelled", jobCancelled)
|
||||
assertTrue(jobCancelled, "Job should be cancelled")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,18 +14,22 @@
|
|||
* 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.common.util
|
||||
|
||||
package com.geeksville.mesh.android
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Created by kevinh on 1/13/16.
|
||||
*/
|
||||
object DateUtils {
|
||||
fun dateUTC(year: Int, month: Int, day: Int): Date {
|
||||
val cal = GregorianCalendar(TimeZone.getTimeZone("GMT"))
|
||||
cal.set(year, month, day, 0, 0, 0);
|
||||
return Date(cal.getTime().getTime())
|
||||
class TimeUtilsTest {
|
||||
@Test
|
||||
fun testNowMillis() {
|
||||
val start = nowMillis
|
||||
// Just verify it returns something sensible (not 0)
|
||||
assertTrue(start > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNowSeconds() {
|
||||
val start = nowSeconds
|
||||
assertTrue(start > 0)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,33 @@
|
|||
# `:core:data`
|
||||
|
||||
## Overview
|
||||
The `:core:data` module implements the Repository pattern, serving as the primary data source for ViewModels in feature modules. It orchestrates data flow between the local database (`core:database`), remote services, and network repositories.
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Repositories
|
||||
- **`NodeRepository`**: High-level access to node information and mesh state.
|
||||
- **`MeshLogRepository`**: Access to historical logs and diagnostics.
|
||||
- **`FirmwareReleaseRepository`**: Manages the discovery and retrieval of firmware updates.
|
||||
|
||||
### 2. Data Sources
|
||||
Internal components that handle raw data fetching from APIs or disk.
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
<!--region graph-->
|
||||
```mermaid
|
||||
graph TB
|
||||
:core:data[data]:::null
|
||||
:core:data[data]:::android-library
|
||||
:core:data -.-> :core:analytics
|
||||
:core:data -.-> :core:common
|
||||
:core:data -.-> :core:database
|
||||
:core:data -.-> :core:datastore
|
||||
:core:data -.-> :core:di
|
||||
:core:data -.-> :core:model
|
||||
:core:data -.-> :core:network
|
||||
:core:data -.-> :core:prefs
|
||||
:core:data -.-> :core:proto
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ configure<LibraryExtension> { namespace = "org.meshtastic.core.data" }
|
|||
|
||||
dependencies {
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.datastore)
|
||||
implementation(projects.core.di)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,15 +14,14 @@
|
|||
* 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
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Serializable
|
||||
data class CustomTileProviderConfig(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val id: String = Uuid.random().toString(),
|
||||
val name: String,
|
||||
val urlTemplate: String,
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue