From 6bfa5b5f7084755e3d5a4eeaff47699bb75b8cd3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 20 Feb 2026 06:41:52 -0600 Subject: [PATCH] refactor(ble): Centralize BLE logic into a core module (#4550) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- AGENTS.md | 17 +- CONTRIBUTING.md | 29 +- README.md | 13 + app/README.md | 44 ++- app/build.gradle.kts | 23 +- app/src/main/AndroidManifest.xml | 3 +- .../java/com/geeksville/mesh/MainActivity.kt | 81 +++-- .../com/geeksville/mesh/MeshServiceClient.kt | 18 +- .../geeksville/mesh/android/DebugLogFile.kt | 53 --- .../geeksville/mesh/model/DeviceListEntry.kt | 52 ++- .../mesh/model/{UIState.kt => UIViewModel.kt} | 39 +- .../bluetooth/BluetoothBroadcastReceiver.kt | 55 --- .../repository/bluetooth/BluetoothDevice.kt | 49 --- .../mesh/repository/network/MQTTRepository.kt | 2 +- .../mesh/repository/radio/MockInterface.kt | 6 +- .../repository/radio/NordicBleInterface.kt | 339 ++++++++---------- .../radio/NordicBleInterfaceSpec.kt | 5 +- .../repository/radio/RadioInterfaceService.kt | 29 +- .../mesh/repository/radio/SerialInterface.kt | 2 +- .../repository/radio/SerialInterfaceSpec.kt | 25 +- .../mesh/repository/radio/TCPInterface.kt | 6 +- .../repository/usb/SerialConnectionImpl.kt | 7 +- .../repository/usb/UsbBroadcastReceiver.kt | 7 +- .../mesh/repository/usb/UsbManager.kt | 40 +-- .../mesh/repository/usb/UsbRepository.kt | 22 +- .../mesh/service/MarkAsReadReceiver.kt | 2 +- .../mesh/service/MeshActionHandler.kt | 6 +- .../mesh/service/MeshCommandSender.kt | 4 +- .../mesh/service/MeshConfigFlowManager.kt | 2 +- .../mesh/service/MeshConfigHandler.kt | 2 +- .../mesh/service/MeshConnectionManager.kt | 8 +- .../mesh/service/MeshDataHandler.kt | 11 +- .../mesh/service/MeshHistoryManager.kt | 2 +- .../mesh/service/MeshMessageProcessor.kt | 12 +- .../mesh/service/MeshNeighborInfoHandler.kt | 4 +- .../mesh/service/MeshNodeManager.kt | 4 +- .../geeksville/mesh/service/MeshService.kt | 6 +- .../service/MeshServiceNotificationsImpl.kt | 4 +- .../mesh/service/MeshTracerouteHandler.kt | 6 +- .../geeksville/mesh/service/PacketHandler.kt | 8 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 25 +- .../mesh/ui/connections/ConnectionsScreen.kt | 107 ++---- .../ui/connections/ConnectionsViewModel.kt | 4 - .../mesh/ui/connections/DeviceType.kt | 6 +- .../connections/ScannerViewModel.kt} | 140 +++++--- .../ui/connections/components/BLEDevices.kt | 263 +++----------- .../components/ConnectingDeviceInfo.kt | 23 +- .../components/CurrentlyConnectedInfo.kt | 8 +- .../connections/components/DeviceListItem.kt | 89 +++-- .../components/DeviceListSection.kt | 47 ++- .../connections/components/NetworkDevices.kt | 59 ++- .../ui/connections/components/UsbDevices.kt | 65 ++-- .../geeksville/mesh/ui/contact/Contacts.kt | 153 ++++---- .../com/geeksville/mesh/util/Exceptions.kt | 73 ---- .../mesh/util/Utf8ByteLengthFilter.java | 67 ---- .../main/res/drawable-anydpi/ic_splash.xml | 4 +- app/src/main/res/values/colors.xml | 19 - app/src/main/res/values/styles.xml | 18 +- .../radio/NordicBleInterfaceDrainTest.kt | 28 +- .../radio/NordicBleInterfaceRetryTest.kt | 33 +- .../radio/NordicBleInterfaceTest.kt | 84 +++-- .../mesh/service/MeshServiceBroadcastsTest.kt | 2 + .../java/com/geeksville/mesh/ui/UIUnitTest.kt | 5 +- .../kotlin/org/meshtastic/buildlogic/Graph.kt | 53 +-- .../meshtastic/buildlogic/KotlinAndroid.kt | 4 +- core/analytics/README.md | 16 +- core/api/README.md | 104 ++---- core/barcode/README.md | 50 +++ core/ble/README.md | 86 +++++ core/ble/build.gradle.kts | 51 +++ .../org/meshtastic/core/ble/BleConnection.kt | 144 ++++++++ .../org/meshtastic/core/ble}/BleError.kt | 8 +- .../org/meshtastic/core/ble/BleModule.kt | 25 +- .../org/meshtastic/core/ble/BleRetry.kt | 58 +++ .../org/meshtastic/core/ble/BleScanner.kt | 51 +++ .../core/ble/BluetoothBroadcastReceiver.kt | 48 +++ .../core/ble}/BluetoothRepository.kt | 99 ++--- .../meshtastic/core/ble}/BluetoothState.kt | 5 +- .../core/ble/MeshtasticBleConstants.kt | 40 +++ .../core/ble/BluetoothRepositoryTest.kt | 127 +++++++ core/common/README.md | 19 +- core/common/build.gradle.kts | 16 +- .../meshtastic/core/common/ContextServices.kt | 20 +- .../core/common/util/BinaryLogFile.kt} | 20 +- .../core/common/util}/BuildUtils.kt | 11 +- .../core/common}/util/CompatExtensions.kt | 15 +- .../core/common/util/ExceptionsAndroid.kt | 46 +++ .../core/common/util/TimeExtensions.kt | 34 ++ .../core/common/util/Utf8ByteLengthFilter.kt | 84 +++++ .../core/common/util}/Coroutines.kt | 35 +- .../core/common/util}/DeferredExecution.kt | 11 +- .../meshtastic/core/common/util/Exceptions.kt | 48 +++ .../core/common/util}/SequentialJob.kt | 16 +- .../core/common/util}/SyncContinuation.kt | 54 ++- .../meshtastic/core/common/util/TimeUtils.kt} | 17 +- .../core/common/util}/SequentialJobTest.kt | 19 +- .../core/common/util/TimeUtilsTest.kt | 30 +- core/data/README.md | 24 +- core/data/build.gradle.kts | 1 + .../data/model/CustomTileProviderConfig.kt | 7 +- .../repository/DeviceHardwareRepository.kt | 2 +- .../repository/FirmwareReleaseRepository.kt | 2 +- .../core/data/repository/MeshLogRepository.kt | 2 +- .../DeviceHardwareRepositoryTest.kt | 2 +- .../data/repository/MeshLogRepositoryTest.kt | 8 +- core/database/README.md | 7 +- core/database/build.gradle.kts | 1 + .../core/database/dao/PacketDaoTest.kt | 2 +- .../core/database/DatabaseManager.kt | 10 +- .../meshtastic/core/database/dao/PacketDao.kt | 2 +- .../database/entity/DeviceHardwareEntity.kt | 2 +- .../database/entity/FirmwareReleaseEntity.kt | 2 +- .../core/database/entity/NodeEntity.kt | 4 +- .../meshtastic/core/database/entity/Packet.kt | 2 +- .../core/database/dao/MigrationTest.kt | 8 +- core/datastore/README.md | 16 +- core/di/README.md | 16 +- core/model/README.md | 40 +-- core/model/build.gradle.kts | 1 + .../org/meshtastic/core/model/DataPacket.kt | 2 +- .../org/meshtastic/core/model/NodeInfo.kt | 2 +- .../core/model/util/DateTimeUtils.kt | 3 + .../meshtastic/core/model/util/Extensions.kt | 32 ++ .../core/model/util/PosixTimeZoneUtils.kt | 3 + .../core/model/util/TimeExtensionsTest.kt | 6 + core/navigation/README.md | 23 +- core/network/README.md | 16 +- core/nfc/README.md | 29 ++ core/prefs/README.md | 15 +- .../core/prefs/analytics/AnalyticsPrefs.kt | 7 +- core/proto/README.md | 36 +- core/service/README.md | 25 +- core/service/build.gradle.kts | 1 + .../meshtastic/core/service}/ServiceClient.kt | 72 ++-- .../core/service/ServiceClientTest.kt | 134 +++++++ core/strings/README.md | 21 +- .../meshtastic/core/strings/ContextExt.kt | 4 +- .../composeResources/values/strings.xml | 51 ++- core/ui/README.md | 56 ++- core/ui/build.gradle.kts | 2 + .../core/ui/component/AutoLinkText.kt | 12 +- .../core/ui/component/LastHeardInfo.kt | 2 +- .../ui/component/TimeTickWithLifecycle.kt | 46 +-- .../org/meshtastic/core/ui/util/FormatAgo.kt | 4 +- .../core/ui}/util/ModifierExtensions.kt | 9 +- .../core/ui/util/ProtoExtensions.kt | 2 +- feature/firmware/README.md | 14 +- feature/firmware/build.gradle.kts | 21 +- .../feature/firmware/ota/BleOtaTransport.kt | 104 +++--- .../firmware/ota/Esp32OtaUpdateHandler.kt | 2 +- .../feature/firmware/ota/WifiOtaTransport.kt | 2 +- .../firmware/ota/BleOtaTransportErrorTest.kt | 32 +- .../firmware/ota/BleOtaTransportMtuTest.kt | 87 +++-- .../ota/BleOtaTransportNordicMockTest.kt | 80 ++--- .../firmware/ota/BleOtaTransportTest.kt | 133 ++++--- feature/intro/README.md | 18 +- feature/intro/build.gradle.kts | 6 +- .../feature/intro/AnalyticsIntro.kt | 47 ++- .../feature/intro/AppIntroductionScreen.kt | 144 +++++--- .../feature/intro/BluetoothScreen.kt | 86 +++++ .../feature/intro/NotificationsScreen.kt | 27 -- feature/map/README.md | 34 +- .../org/meshtastic/feature/map/MapView.kt | 32 +- .../feature/map/SqlTileWriterExt.kt | 2 +- .../map/component/EditWaypointDialog.kt | 10 +- .../org/meshtastic/feature/map/MapView.kt | 4 +- .../meshtastic/feature/map/MapViewModel.kt | 6 +- .../map/component/EditWaypointDialog.kt | 6 +- .../feature/map/component/PulsingNodeChip.kt | 2 +- .../feature/map/BaseMapViewModel.kt | 4 +- feature/messaging/README.md | 30 +- .../messaging/component/MessageItemTest.kt | 2 +- .../messaging/component/MessageItem.kt | 2 +- feature/node/README.md | 31 +- feature/node/build.gradle.kts | 1 + .../feature/node/compass/CompassViewModel.kt | 4 +- .../component/CooldownOutlinedIconButton.kt | 24 +- .../feature/node/component/InfoCard.kt | 4 +- .../feature/node/component/LastHeardInfo.kt | 2 +- .../feature/node/detail/NodeDetailScreen.kt | 2 +- .../node/detail/NodeDetailViewModel.kt | 4 +- .../feature/node/detail/NodeRequestActions.kt | 2 +- .../feature/node/metrics/CommonCharts.kt | 9 +- .../feature/node/metrics/DeviceMetrics.kt | 10 +- .../node/metrics/EnvironmentMetrics.kt | 14 +- .../feature/node/metrics/HostMetricsLog.kt | 10 +- .../feature/node/metrics/MetricsViewModel.kt | 8 +- .../feature/node/metrics/NeighborInfoLog.kt | 2 +- .../feature/node/metrics/PaxMetrics.kt | 12 +- .../feature/node/metrics/PositionLog.kt | 4 +- .../feature/node/metrics/PowerMetrics.kt | 8 +- .../feature/node/metrics/SignalMetrics.kt | 8 +- .../feature/node/metrics/TracerouteLog.kt | 4 +- .../feature/node/model/TimeFrame.kt | 2 +- .../metrics/EnvironmentMetricsStateTest.kt | 2 +- feature/settings/README.md | 34 +- feature/settings/build.gradle.kts | 2 + .../settings/debugging/DebugFiltersTest.kt | 3 +- .../settings/debugging/DebugSearchTest.kt | 2 +- .../component/EditDeviceProfileDialogTest.kt | 2 +- .../component/MapReportingPreferenceTest.kt | 2 +- .../feature/settings/SettingsScreen.kt | 6 +- .../feature/settings/debugging/Debug.kt | 6 +- .../settings/debugging/DebugViewModel.kt | 6 +- .../radio/CleanNodeDatabaseViewModel.kt | 2 +- .../settings/radio/RadioConfigViewModel.kt | 2 +- .../radio/component/DeviceConfigItemList.kt | 26 +- .../radio/component/PositionConfigItemList.kt | 29 +- .../radio/component/SecurityConfigItemList.kt | 5 +- .../feature/settings/util/LanguageUtils.kt | 7 +- .../feature/settings/HomoglyphSettingTest.kt | 2 +- gradle/libs.versions.toml | 20 +- .../MeshServiceViewModel.kt | 6 +- settings.gradle.kts | 1 + 214 files changed, 3471 insertions(+), 2405 deletions(-) delete mode 100644 app/src/main/java/com/geeksville/mesh/android/DebugLogFile.kt rename app/src/main/java/com/geeksville/mesh/model/{UIState.kt => UIViewModel.kt} (87%) delete mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt rename app/src/main/java/com/geeksville/mesh/{model/BTScanModel.kt => ui/connections/ScannerViewModel.kt} (68%) delete mode 100644 app/src/main/java/com/geeksville/mesh/util/Exceptions.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/util/Utf8ByteLengthFilter.java delete mode 100644 app/src/main/res/values/colors.xml create mode 100644 core/barcode/README.md create mode 100644 core/ble/README.md create mode 100644 core/ble/build.gradle.kts create mode 100644 core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt rename {app/src/main/java/com/geeksville/mesh/repository/radio => core/ble/src/main/kotlin/org/meshtastic/core/ble}/BleError.kt (95%) rename app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt => core/ble/src/main/kotlin/org/meshtastic/core/ble/BleModule.kt (61%) create mode 100644 core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt create mode 100644 core/ble/src/main/kotlin/org/meshtastic/core/ble/BleScanner.kt create mode 100644 core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothBroadcastReceiver.kt rename {app/src/main/java/com/geeksville/mesh/repository/bluetooth => core/ble/src/main/kotlin/org/meshtastic/core/ble}/BluetoothRepository.kt (52%) rename {app/src/main/java/com/geeksville/mesh/repository/bluetooth => core/ble/src/main/kotlin/org/meshtastic/core/ble}/BluetoothState.kt (94%) create mode 100644 core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt create mode 100644 core/ble/src/test/kotlin/org/meshtastic/core/ble/BluetoothRepositoryTest.kt rename core/{ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt => common/src/androidMain/kotlin/org/meshtastic/core/common/util/BinaryLogFile.kt} (56%) rename {app/src/main/java/com/geeksville/mesh/android => core/common/src/androidMain/kotlin/org/meshtastic/core/common/util}/BuildUtils.kt (83%) rename {app/src/main/java/com/geeksville/mesh => core/common/src/androidMain/kotlin/org/meshtastic/core/common}/util/CompatExtensions.kt (74%) create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/TimeExtensions.kt create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Utf8ByteLengthFilter.kt rename {app/src/main/java/com/geeksville/mesh/concurrent => core/common/src/commonMain/kotlin/org/meshtastic/core/common/util}/Coroutines.kt (59%) rename {app/src/main/java/com/geeksville/mesh/concurrent => core/common/src/commonMain/kotlin/org/meshtastic/core/common/util}/DeferredExecution.kt (83%) create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt rename {app/src/main/java/com/geeksville/mesh/concurrent => core/common/src/commonMain/kotlin/org/meshtastic/core/common/util}/SequentialJob.kt (77%) rename {app/src/main/java/com/geeksville/mesh/concurrent => core/common/src/commonMain/kotlin/org/meshtastic/core/common/util}/SyncContinuation.kt (60%) rename core/{model/src/main/kotlin/org/meshtastic/core/model/util/TimeExtensions.kt => common/src/commonMain/kotlin/org/meshtastic/core/common/util/TimeUtils.kt} (70%) rename {app/src/test/java/com/geeksville/mesh/concurrent => core/common/src/commonTest/kotlin/org/meshtastic/core/common/util}/SequentialJobTest.kt (81%) rename app/src/main/java/com/geeksville/mesh/android/DateUtils.kt => core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/TimeUtilsTest.kt (61%) create mode 100644 core/nfc/README.md rename {app/src/main/java/com/geeksville/mesh/android => core/service/src/main/kotlin/org/meshtastic/core/service}/ServiceClient.kt (57%) create mode 100644 core/service/src/test/kotlin/org/meshtastic/core/service/ServiceClientTest.kt rename core/strings/src/androidMain/kotlin/{com => org}/meshtastic/core/strings/ContextExt.kt (85%) rename {app/src/main/java/com/geeksville/mesh => core/ui/src/main/kotlin/org/meshtastic/core/ui}/util/ModifierExtensions.kt (85%) create mode 100644 feature/intro/src/main/kotlin/org/meshtastic/feature/intro/BluetoothScreen.kt diff --git a/AGENTS.md b/AGENTS.md index ee2a47094..ae128ca37 100644 --- a/AGENTS.md +++ b/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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3efd0377..b47041ceb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 2b106e477..9eed8d9ae 100644 --- a/README.md +++ b/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). diff --git a/app/README.md b/app/README.md index e06507a83..6dd8c1ca7 100644 --- a/app/README.md +++ b/app/README.md @@ -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 ```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; diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8511eb515..96b53b543 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -141,7 +141,10 @@ configure { // 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 { } } 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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 55d495571..83a745521 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> = 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 diff --git a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt index 102980251..ca4b141a5 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt @@ -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) } } diff --git a/app/src/main/java/com/geeksville/mesh/android/DebugLogFile.kt b/app/src/main/java/com/geeksville/mesh/android/DebugLogFile.kt deleted file mode 100644 index 8a8c95ce0..000000000 --- a/app/src/main/java/com/geeksville/mesh/android/DebugLogFile.kt +++ /dev/null @@ -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 . - */ - -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) { - -} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt index 406b29e4e..6d2e4c448 100644 --- a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt @@ -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 . */ - 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) } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt similarity index 87% rename from app/src/main/java/com/geeksville/mesh/model/UIState.kt rename to app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt index de6740bd8..87e051932 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt @@ -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 - get() = serviceRepository.neighborInfoResponse.asLiveData() + val neighborInfoResponse: StateFlow = serviceRepository.neighborInfoResponse fun clearNeighborInfoResponse() { serviceRepository.clearNeighborInfoResponse() diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt deleted file mode 100644 index 6c43f2f25..000000000 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt +++ /dev/null @@ -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 . - */ - -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) -} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt deleted file mode 100644 index 614327c06..000000000 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt +++ /dev/null @@ -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 . - */ - -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 = 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) } -} diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt index 5c90f0aeb..7ad3b4d69 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt index 338764bd2..5b67d694f 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt index 6b1b03586..8cff1e088 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt @@ -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() + val logRadioReady = CompletableDeferred() + + 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 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") } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt index 98698206b..49f989452 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt @@ -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 . */ - 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 diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index e9fd3c543..7d1ebfbd5 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -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.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private val _receivedData = MutableSharedFlow() + private val _receivedData = simpleSharedFlow() val receivedData: SharedFlow = _receivedData - private val _connectionError = MutableSharedFlow() + private val _connectionError = simpleSharedFlow() val connectionError: SharedFlow = _connectionError.asSharedFlow() // Thread-safe StateFlow for tracking device address changes @@ -371,12 +371,7 @@ constructor( serviceScope.handledLaunch { handleSendToRadio(a) } } - private val _meshActivity = - MutableSharedFlow( - 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() val meshActivity: SharedFlow = _meshActivity.asSharedFlow() private fun emitSendActivity() { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt index 15c7929ac..4ebaf85d5 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt @@ -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 */ diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt index 2cc550742..294e5eb1d 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt @@ -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 . */ - 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, private val usbRepository: UsbRepository, ) : InterfaceSpec { - 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 { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt index 6ea9e95de..e2eeefa4c 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt index 42c805b6c..44aed0ba2 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt @@ -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, @@ -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) } } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt index 3f324ec05..255abb308 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt @@ -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 . */ - 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. */ diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt index d727ad535..9bdac49e2 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt @@ -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 . */ - 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 = 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 = 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) diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt index cf01422d3..71ba5d04b 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt @@ -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 . */ - 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()) - @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() diff --git a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt index 7cce9c1c2..3f1a85ec3 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt index 2c2ff5831..5ac1ee1cf 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt index 155398c1a..48497a762 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt index 3107a93f7..2b0f8faef 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt index fb0568e9c..616529d14 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt index 1086a7350..4512b7a7d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index 8c0d1c03e..bc880d411 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -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 = diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt index 230fb5028..b084433b4 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt index 2633e74c4..f1da54dd7 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt @@ -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(), diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt index ebaaeb9c0..37694ada0 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt index 421630865..314b823d4 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index fb2a9f015..34e1adf4d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt index 8d2e4ef6f..62fe766e1 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt index 446bb6a9b..d03c3042a 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt index d0ab953b6..2de292491 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -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(), diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 0b448f5cf..170926b7c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -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() }) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index adc8b06df..6b2873d5b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt index e17e52204..88e9391f5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt @@ -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 = nodeRepository.ourNodeInfo - val bluetoothState = bluetoothRepository.state - private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning) val hasShownNotPairedWarning: StateFlow = _hasShownNotPairedWarning.asStateFlow() diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/DeviceType.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/DeviceType.kt index 4c588dac3..102f209e5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/DeviceType.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/DeviceType.kt @@ -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 . */ - 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, diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt similarity index 68% rename from app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt rename to app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt index 3b90dfbb8..f694a3bf8 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt @@ -14,18 +14,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -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 - get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() + val showMockInterface: StateFlow = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() + + private val _errorText = MutableStateFlow(null) + val errorText: StateFlow = _errorText.asStateFlow() + + private val nodeDb: StateFlow> = nodeRepository.nodeDBbyNum - val errorText = MutableLiveData(null) private val bondedBleDevicesFlow: StateFlow> = bluetoothRepository.state .map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - private val scannedBleDevicesFlow: StateFlow> = - 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> = 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> = - 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> = - 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> = combine(usbDevicesFlow, showMockInterface) { usb, showMock -> - usb + if (showMock) listOf(mockDevice) else emptyList() + val all: List = 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> = 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> = - 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> = - 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 = radioInterfaceService.currentDeviceAddressFlow @@ -158,35 +225,18 @@ constructor( .map { it ?: NO_DEVICE_SELECTED } .stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED) - val spinner: StateFlow = 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) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index 6d9adf3a7..8747eca70 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -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 . */ - 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, - availableDevices: List, - 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() + }, + ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectingDeviceInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectingDeviceInfo.kt index a21c06c8a..f26aa2e05 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectingDeviceInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectingDeviceInfo.kt @@ -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) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt index 014c70e42..f95fe05ba 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt @@ -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)) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt index 11304ba60..92b0e762d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt @@ -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 . */ - 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), diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt index fee07da0c..1915cfff3 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt @@ -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 . */ - 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.DeviceListSection( @@ -33,16 +40,30 @@ fun List.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, + ) + } } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt index bfa7e60bb..499c1cd95 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt @@ -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, recentNetworkDevices: List, 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, + recentNetworkDevices: List, + 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 = {}, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt index 182735b6f..abcea444e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt @@ -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 . */ - 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, selectedDevice: String, - scanModel: BTScanModel, + scanModel: ScannerViewModel, ) { - UsbDevicesInternal( - connectionState = connectionState, - usbDevices = usbDevices, - selectedDevice = selectedDevice, - onDeviceSelected = scanModel::onSelected, - ) -} - -@Composable -private fun UsbDevicesInternal( - connectionState: ConnectionState, - usbDevices: List, - 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, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index 07b921108..cdd170359 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -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, - visiblePlaceholders: List, + channelPlaceholders: List, selectedList: List, 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, + placeholders: List, selectedList: List, 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) { - 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, channelPlaceholders: List, -): List { - 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 = remember(contacts.itemCount, channelPlaceholders) { + val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet() + channelPlaceholders.filter { it.contactKey !in pagedKeys } } diff --git a/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt b/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt deleted file mode 100644 index b76d85da7..000000000 --- a/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt +++ /dev/null @@ -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 . - */ -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 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) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/util/Utf8ByteLengthFilter.java b/app/src/main/java/com/geeksville/mesh/util/Utf8ByteLengthFilter.java deleted file mode 100644 index 08185fd5a..000000000 --- a/app/src/main/java/com/geeksville/mesh/util/Utf8ByteLengthFilter.java +++ /dev/null @@ -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; - } - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_splash.xml b/app/src/main/res/drawable-anydpi/ic_splash.xml index f5f632822..58bcfa526 100644 --- a/app/src/main/res/drawable-anydpi/ic_splash.xml +++ b/app/src/main/res/drawable-anydpi/ic_splash.xml @@ -10,12 +10,12 @@ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index ee9ec22a9..000000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - #000000 - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 1a668ba6d..9cc1fbb34 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -15,20 +15,16 @@ along with this program. If not, see . --> - + - - - - -