From 25657e8f8f73b476a8ed33d05e5144fe1587d6fe Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:01:12 -0600 Subject: [PATCH] feat(wire): migrate from protobuf -> wire (#4401) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .github/workflows/merge-queue.yml | 42 +- .github/workflows/pull-request.yml | 53 +- .github/workflows/release.yml | 8 +- .github/workflows/reusable-android-build.yml | 144 --- .github/workflows/reusable-android-test.yml | 169 ---- .github/workflows/reusable-check.yml | 196 ++++ .github/workflows/reusable-lint.yml | 54 - app/build.gradle.kts | 3 +- .../googleReleaseRuntimeClasspath.txt | 415 ++++++++ app/detekt-baseline.xml | 37 +- .../java/com/geeksville/mesh/model/UIState.kt | 23 +- .../mesh/repository/network/MQTTRepository.kt | 37 +- .../mesh/repository/radio/MockInterface.kt | 496 +++++----- .../repository/radio/RadioInterfaceService.kt | 10 +- .../mesh/repository/radio/TCPInterface.kt | 9 +- .../mesh/service/FromRadioPacketHandler.kt | 68 +- .../mesh/service/MeshActionHandler.kt | 153 +-- .../mesh/service/MeshCommandSender.kt | 324 +++--- .../mesh/service/MeshConfigFlowManager.kt | 43 +- .../mesh/service/MeshConfigHandler.kt | 34 +- .../mesh/service/MeshConnectionManager.kt | 33 +- .../mesh/service/MeshDataHandler.kt | 347 +++---- .../geeksville/mesh/service/MeshDataMapper.kt | 37 +- .../mesh/service/MeshHistoryManager.kt | 52 +- .../mesh/service/MeshLocationManager.kt | 30 +- .../mesh/service/MeshMessageProcessor.kt | 104 +- .../mesh/service/MeshMqttManager.kt | 30 +- .../mesh/service/MeshNeighborInfoHandler.kt | 20 +- .../mesh/service/MeshNodeManager.kt | 130 +-- .../geeksville/mesh/service/MeshService.kt | 9 +- .../service/MeshServiceNotificationsImpl.kt | 134 ++- .../mesh/service/MeshTracerouteHandler.kt | 14 +- .../geeksville/mesh/service/PacketHandler.kt | 34 +- .../mesh/service/ReactionReceiver.kt | 10 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 111 +-- .../mesh/ui/connections/ConnectionsScreen.kt | 9 +- .../ui/connections/ConnectionsViewModel.kt | 7 +- .../components/CurrentlyConnectedInfo.kt | 23 +- .../geeksville/mesh/ui/contact/ContactItem.kt | 9 +- .../geeksville/mesh/ui/contact/Contacts.kt | 14 +- .../mesh/ui/contact/ContactsViewModel.kt | 21 +- .../com/geeksville/mesh/ui/sharing/Channel.kt | 67 +- .../mesh/ui/sharing/ChannelViewModel.kt | 55 +- .../mesh/repository/radio/TCPInterfaceTest.kt | 73 +- .../java/com/geeksville/mesh/service/Fakes.kt | 67 +- .../service/FromRadioPacketHandlerTest.kt | 66 +- .../service/MeshCommandSenderHopLimitTest.kt | 37 +- .../mesh/service/MeshCommandSenderTest.kt | 4 +- .../mesh/service/MeshConnectionManagerTest.kt | 22 +- .../mesh/service/MeshDataMapperTest.kt | 57 +- .../mesh/service/MeshMessageProcessorTest.kt | 20 +- .../mesh/service/MeshNodeManagerTest.kt | 55 +- .../mesh/service/PacketHandlerTest.kt | 32 +- .../service/StoreForwardHistoryRequestTest.kt | 20 +- .../mesh/ui/metrics/EnvironmentMetricsTest.kt | 51 +- build-logic/convention/build.gradle.kts | 1 + .../AndroidApplicationConventionPlugin.kt | 3 + .../kotlin/AndroidLibraryConventionPlugin.kt | 1 + .../kotlin/org/meshtastic/buildlogic/Dokka.kt | 3 + .../kotlin/org/meshtastic/buildlogic/Graph.kt | 294 ++---- .../kotlin/org/meshtastic/buildlogic/Kover.kt | 6 +- .../buildlogic/ProjectExtensions.kt | 14 + .../org/meshtastic/buildlogic/Spotless.kt | 1 + build-logic/gradle.properties | 37 +- build.gradle.kts | 6 +- core/api/build.gradle.kts | 23 +- core/data/detekt-baseline.xml | 8 +- .../core/data/repository/MeshLogRepository.kt | 97 +- .../core/data/repository/NodeRepository.kt | 41 +- .../core/data/repository/PacketRepository.kt | 27 +- .../data/repository/RadioConfigRepository.kt | 44 +- .../TracerouteSnapshotRepository.kt | 9 +- .../data/repository/MeshLogRepositoryTest.kt | 43 +- core/database/detekt-baseline.xml | 8 + .../core/database/dao/NodeInfoDaoTest.kt | 110 +-- .../core/database/dao/PacketDaoTest.kt | 66 +- .../meshtastic/core/database/Converters.kt | 77 +- .../core/database/dao/NodeInfoDao.kt | 29 +- .../meshtastic/core/database/dao/PacketDao.kt | 7 +- .../core/database/entity/MeshLog.kt | 70 +- .../core/database/entity/NodeEntity.kt | 101 +- .../meshtastic/core/database/entity/Packet.kt | 9 +- .../entity/TracerouteNodePositionEntity.kt | 7 +- .../meshtastic/core/database/model/Message.kt | 38 +- .../meshtastic/core/database/model/Node.kt | 97 +- .../core/database/dao/MigrationTest.kt | 108 +- core/datastore/detekt-baseline.xml | 7 + .../core/datastore/ChannelSetDataSource.kt | 31 +- .../core/datastore/LocalConfigDataSource.kt | 32 +- .../core/datastore/ModuleConfigDataSource.kt | 41 +- .../core/datastore/di/DataStoreModule.kt | 16 +- .../serializer/ChannelSetSerializer.kt | 15 +- .../serializer/LocalConfigSerializer.kt | 15 +- .../serializer/ModuleConfigSerializer.kt | 16 +- core/model/build.gradle.kts | 24 +- core/model/detekt-baseline.xml | 6 +- .../org/meshtastic/core/model/ChannelTest.kt | 19 +- .../org/meshtastic/core/model/Channel.kt | 37 +- .../meshtastic/core/model/ChannelOption.kt | 14 +- .../org/meshtastic/core/model/DataPacket.kt | 245 ++--- .../org/meshtastic/core/model/NeighborInfo.kt | 52 +- .../org/meshtastic/core/model/NodeInfo.kt | 76 +- .../meshtastic/core/model/RouteDiscovery.kt | 75 +- .../core/model/util/ByteStringExtensions.kt | 16 +- .../core/model/util/ByteStringSerializer.kt | 49 + .../meshtastic/core/model/util/ChannelSet.kt | 29 +- .../core/model/util/DistanceExtensions.kt | 23 +- .../meshtastic/core/model/util/Extensions.kt | 29 +- .../core/model/util/WireExtensions.kt | 128 +++ .../core/model/ChannelOptionTest.kt | 15 +- .../core/model/DataPacketParcelTest.kt | 144 +++ .../meshtastic/core/model/DataPacketTest.kt | 26 +- .../org/meshtastic/core/model/NodeInfoTest.kt | 17 +- .../core/model/util/WireExtensionsTest.kt | 332 +++++++ core/network/build.gradle.kts | 1 - core/proto/build.gradle.kts | 75 +- core/proto/consumer-rules.pro | 49 +- .../google/protobuf/descriptor.proto | 921 ++++++++++++++++++ core/service/detekt-baseline.xml | 6 +- .../core/service/MeshServiceNotifications.kt | 10 +- .../meshtastic/core/service/ServiceAction.kt | 6 +- .../core/service/ServiceRepository.kt | 10 +- .../composeResources/values/strings.xml | 1 + core/ui/detekt-baseline.xml | 3 +- .../core/ui/component/ContactSharing.kt | 118 +-- .../core/ui/component/DropDownPreference.kt | 17 +- .../core/ui/component/EditBase64Preference.kt | 11 +- .../core/ui/component/EditListPreference.kt | 38 +- .../core/ui/component/ElevationInfo.kt | 2 +- .../meshtastic/core/ui/component/NodeChip.kt | 20 +- .../core/ui/component/NodeKeyStatusIcon.kt | 2 +- .../core/ui/component/SecurityIcon.kt | 22 +- .../core/ui/component/SignalInfo.kt | 4 +- .../preview/NodePreviewParameterProvider.kt | 143 ++- .../core/ui/component/preview/PreviewUtils.kt | 16 +- .../core/ui/qr/ScannedQrCodeDialog.kt | 107 +- .../core/ui/qr/ScannedQrCodeViewModel.kt | 36 +- .../core/ui/share/SharedContactDialog.kt | 18 +- .../core/ui/share/SharedContactViewModel.kt | 7 +- .../core/ui/util/ProtoExtensions.kt | 48 +- feature/firmware/detekt-baseline.xml | 5 +- .../firmware/FirmwareUpdateViewModel.kt | 5 +- .../org/meshtastic/feature/map/MapView.kt | 65 +- .../feature/map/MapViewExtensions.kt | 11 +- .../meshtastic/feature/map/MapViewModel.kt | 5 +- .../map/component/EditWaypointDialog.kt | 60 +- .../feature/map/node/NodeMapScreen.kt | 5 +- .../org/meshtastic/feature/map/MapView.kt | 92 +- .../meshtastic/feature/map/MapViewModel.kt | 8 +- .../map/component/EditWaypointDialog.kt | 62 +- .../feature/map/component/WaypointMarkers.kt | 20 +- .../feature/map/model/NodeClusterItem.kt | 5 +- .../feature/map/node/NodeMapScreen.kt | 5 +- .../feature/map/BaseMapViewModel.kt | 15 +- .../feature/map/node/NodeMapViewModel.kt | 11 +- .../messaging/component/MessageItemTest.kt | 2 +- .../meshtastic/feature/messaging/Message.kt | 8 +- .../feature/messaging/MessageListPaged.kt | 2 +- .../feature/messaging/MessageViewModel.kt | 17 +- .../messaging/component/MessageItem.kt | 6 +- .../feature/messaging/component/Reaction.kt | 12 +- feature/node/detekt-baseline.xml | 11 +- .../feature/node/component/InlineMap.kt | 5 +- .../feature/node/compass/CompassUiState.kt | 7 +- .../feature/node/compass/CompassViewModel.kt | 37 +- .../node/component/AdministrationSection.kt | 8 +- .../feature/node/component/ElevationInfo.kt | 9 +- .../node/component/EnvironmentMetrics.kt | 114 +-- .../node/component/LinkedCoordinatesItem.kt | 9 +- .../node/component/NodeDetailsSection.kt | 14 +- .../feature/node/component/NodeItem.kt | 73 +- .../feature/node/component/NodeMenu.kt | 11 +- .../feature/node/component/PositionSection.kt | 4 +- .../feature/node/component/PowerMetrics.kt | 18 +- .../feature/node/detail/NodeDetailActions.kt | 15 +- .../feature/node/detail/NodeDetailScreen.kt | 2 +- .../node/detail/NodeDetailViewModel.kt | 116 ++- .../feature/node/list/NodeListScreen.kt | 5 +- .../feature/node/list/NodeListViewModel.kt | 20 +- .../feature/node/metrics/BaseMetricChart.kt | 2 +- .../feature/node/metrics/DeviceMetrics.kt | 143 +-- .../feature/node/metrics/EnvironmentCharts.kt | 6 +- .../node/metrics/EnvironmentMetrics.kt | 120 ++- .../node/metrics/EnvironmentMetricsState.kt | 49 +- .../node/metrics/HardwareModelExtensions.kt | 9 +- .../feature/node/metrics/HostMetricsLog.kt | 163 ++-- .../feature/node/metrics/MetricsViewModel.kt | 99 +- .../feature/node/metrics/NeighborInfoLog.kt | 9 +- .../feature/node/metrics/PaxMetrics.kt | 51 +- .../feature/node/metrics/PositionLog.kt | 44 +- .../feature/node/metrics/PowerMetrics.kt | 61 +- .../feature/node/metrics/SignalMetrics.kt | 24 +- .../feature/node/metrics/TracerouteLog.kt | 40 +- .../node/metrics/TracerouteMapScreen.kt | 15 +- .../node/model/IsEffectivelyUnmessageable.kt | 10 +- .../feature/node/model/MetricsState.kt | 23 +- .../feature/node/model/NodeDetailAction.kt | 7 +- .../metrics/EnvironmentMetricsStateTest.kt | 27 +- feature/settings/detekt-baseline.xml | 57 +- .../component/EditDeviceProfileDialogTest.kt | 25 +- .../feature/settings/SettingsScreen.kt | 310 +++--- .../feature/settings/SettingsViewModel.kt | 55 +- .../settings/debugging/DebugViewModel.kt | 152 +-- .../settings/navigation/ConfigRoute.kt | 69 +- .../settings/navigation/ModuleRoute.kt | 42 +- .../settings/radio/RadioConfigViewModel.kt | 392 ++++---- .../radio/channel/ChannelConfigScreen.kt | 34 +- .../radio/channel/component/ChannelCard.kt | 23 +- .../channel/component/EditChannelDialog.kt | 63 +- .../AmbientLightingConfigItemList.kt | 30 +- .../radio/component/AudioConfigItemList.kt | 44 +- .../component/BluetoothConfigItemList.kt | 27 +- .../component/CannedMessageConfigItemList.kt | 71 +- .../settings/radio/component/ConfigState.kt | 22 +- .../DetectionSensorConfigItemList.kt | 48 +- .../radio/component/DeviceConfigItemList.kt | 105 +- .../radio/component/DisplayConfigItemList.kt | 76 +- .../component/EditDeviceProfileDialog.kt | 102 +- .../ExternalNotificationConfigItemList.kt | 79 +- .../radio/component/LoRaConfigItemList.kt | 84 +- .../radio/component/MQTTConfigItemList.kt | 91 +- .../component/NeighborInfoConfigItemList.kt | 22 +- .../radio/component/NetworkConfigItemList.kt | 120 +-- .../component/PaxcounterConfigItemList.kt | 26 +- .../radio/component/PositionConfigItemList.kt | 103 +- .../radio/component/PowerConfigItemList.kt | 44 +- .../radio/component/RadioConfigScreenList.kt | 7 +- .../component/RangeTestConfigItemList.kt | 22 +- .../component/RemoteHardwareConfigItemList.kt | 28 +- .../radio/component/SecurityConfigItemList.kt | 99 +- .../radio/component/SerialConfigItemList.kt | 53 +- .../component/ShutdownConfirmationDialog.kt | 13 +- .../component/StatusMessageConfigItemList.kt | 11 +- .../component/StoreForwardConfigItemList.kt | 34 +- .../component/TelemetryConfigItemList.kt | 54 +- .../radio/component/UserConfigItemList.kt | 30 +- gradle.properties | 67 +- gradle/libs.versions.toml | 16 +- .../MeshServiceViewModel.kt | 15 +- 239 files changed, 7149 insertions(+), 6144 deletions(-) delete mode 100644 .github/workflows/reusable-android-build.yml delete mode 100644 .github/workflows/reusable-android-test.yml create mode 100644 .github/workflows/reusable-check.yml delete mode 100644 .github/workflows/reusable-lint.yml create mode 100644 app/dependencies/googleReleaseRuntimeClasspath.txt create mode 100644 core/database/detekt-baseline.xml create mode 100644 core/datastore/detekt-baseline.xml create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt create mode 100644 core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt create mode 100644 core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt create mode 100644 core/proto/src/main/wire-includes/google/protobuf/descriptor.proto diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml index 9f4f77d70..27e532a26 100644 --- a/.github/workflows/merge-queue.yml +++ b/.github/workflows/merge-queue.yml @@ -9,46 +9,26 @@ concurrency: cancel-in-progress: true jobs: - lint: + android-check: if: github.repository == 'meshtastic/Meshtastic-Android' - uses: ./.github/workflows/reusable-lint.yml - secrets: inherit - - build: - needs: lint - if: github.repository == 'meshtastic/Meshtastic-Android' - uses: ./.github/workflows/reusable-android-build.yml + uses: ./.github/workflows/reusable-check.yml with: - upload_artifacts: false - secrets: inherit - - androidTest: - needs: lint - if: github.repository == 'meshtastic/Meshtastic-Android' - uses: ./.github/workflows/reusable-android-test.yml - with: - api_levels: '[26, 35]' # Run on both API 26 and 35 for merge queue - test_flavors: 'both' # Run both flavors for merge queue (comprehensive) + api_levels: '[26, 35]' # Comprehensive testing for Merge Queue + flavors: '["google", "fdroid"]' upload_artifacts: false secrets: inherit check-workflow-status: - name: Check Workflow Status # Matches another in pull-request, and is required for merge to main. + name: Check Workflow Status runs-on: ubuntu-latest needs: - - lint - - build - - androidTest + - android-check if: always() steps: - name: Check Workflow Status run: | - exit_on_result() { - if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then - echo "Job '$1' failed or was cancelled." - exit 1 - fi - } - exit_on_result "lint" "${{ needs.lint.result }}" - exit_on_result "build" "${{ needs.build.result }}" - exit_on_result "androidTest" "${{ needs.androidTest.result }}" + if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then + echo "::error::Android Check failed" + exit 1 + fi + echo "All jobs passed successfully" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index dc9d9b447..aafb3a0c4 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -32,35 +32,15 @@ jobs: - '**/src/**' - '.github/workflows/**' - lint: + android-check: needs: check-changes if: needs.check-changes.outputs.code_changed == 'true' - uses: ./.github/workflows/reusable-lint.yml - secrets: inherit - - build: - needs: - - check-changes - - lint - if: ${{ needs.check-changes.outputs.code_changed == 'true' && !cancelled() && !failure() }} - uses: ./.github/workflows/reusable-android-build.yml + uses: ./.github/workflows/reusable-check.yml with: - test_flavors: 'google' + api_levels: '[35]' # Only test latest API on PRs for speed + flavors: '["google"]' secrets: inherit - androidTest: - needs: - - check-changes - - lint - if: ${{ needs.check-changes.outputs.code_changed == 'true' && !cancelled() && !failure() }} - uses: ./.github/workflows/reusable-android-test.yml - with: - api_levels: '[35]' # Run only on API 35 for PRs - test_flavors: 'google' # Run only Google flavor for PRs (faster) - num_shards: 1 # Run tests in parallel across 1 emulator - secrets: inherit - - # This job handles the case when no code changes are detected (docs-only PRs) skip-notice: needs: check-changes if: needs.check-changes.outputs.code_changed != 'true' @@ -74,34 +54,19 @@ jobs: runs-on: ubuntu-latest needs: - check-changes - - lint - - build - - androidTest + - android-check if: always() steps: - name: Check Workflow Status run: | - # If no code changed, all jobs are expected to be skipped - that's success if [[ "${{ needs.check-changes.outputs.code_changed }}" != "true" ]]; then echo "No code changes - CI jobs skipped as expected" exit 0 fi - # Code changed - check that all jobs succeeded - check_result() { - local job_name=$1 - local result=$2 - if [[ "$result" == "failure" ]]; then - echo "::error::Job '$job_name' failed" - exit 1 - elif [[ "$result" == "cancelled" ]]; then - echo "::error::Job '$job_name' was cancelled" - exit 1 - fi - } - - check_result "lint" "${{ needs.lint.result }}" - check_result "build" "${{ needs.build.result }}" - check_result "androidTest" "${{ needs.androidTest.result }}" + if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then + echo "::error::Android Check failed" + exit 1 + fi echo "All jobs passed successfully" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58c8f87da..5d4faae49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,9 +57,13 @@ permissions: jobs: run-lint: - uses: ./.github/workflows/reusable-lint.yml + uses: ./.github/workflows/reusable-check.yml with: - ref: ${{ inputs.commit_sha || inputs.tag_name }} + run_lint: true + run_unit_tests: false + run_instrumented_tests: false + flavors: '["google"]' + upload_artifacts: false secrets: inherit prepare-build-info: diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml deleted file mode 100644 index 9820b1b5e..000000000 --- a/.github/workflows/reusable-android-build.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Reusable Android Build and Test - -on: - workflow_call: - secrets: - GRADLE_ENCRYPTION_KEY: - required: false - DATADOG_APPLICATION_ID: - required: false - DATADOG_CLIENT_TOKEN: - required: false - CODECOV_TOKEN: - required: false - GRADLE_CACHE_URL: - required: false - GRADLE_CACHE_USERNAME: - required: false - GRADLE_CACHE_PASSWORD: - required: false - inputs: - upload_artifacts: - description: 'Whether to upload build and Detekt artifacts' - required: false - type: boolean - default: true - test_flavors: - description: 'Which flavors to build and test: "google", "fdroid", or "both"' - required: false - type: string - default: 'both' - -jobs: - build: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - attestations: write - timeout-minutes: 35 - env: - DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }} - DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }} - MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - - steps: - - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: 'recursive' - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'jetbrains' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - build-scan-publish: true - build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service' - build-scan-terms-of-use-agree: 'yes' - add-job-summary: always - - - name: Calculate Version Code - id: calculate_version_code - uses: ./.github/actions/calculate-version-code - - - name: Expose Version Code as Environment Variable - run: echo "VERSION_CODE=${{ steps.calculate_version_code.outputs.versionCode }}" >> $GITHUB_ENV - - - name: Load secrets - if: env.DATADOG_APPLICATION_ID != '' && env.DATADOG_CLIENT_TOKEN != '' - run: | - echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties - echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties - - - name: Determine build tasks - id: build-tasks - run: | - FLAVOR="${{ inputs.test_flavors }}" - if [ "$FLAVOR" = "google" ]; then - echo "tasks=assembleGoogleDebug testGoogleDebugUnitTest" >> $GITHUB_OUTPUT - elif [ "$FLAVOR" = "fdroid" ]; then - echo "tasks=assembleFdroidDebug testFdroidDebugUnitTest" >> $GITHUB_OUTPUT - else - echo "tasks=assembleDebug testGoogleDebugUnitTest testFdroidDebugUnitTest" >> $GITHUB_OUTPUT - fi - - - name: Build and Run Unit Tests - run: ./gradlew ${{ steps.build-tasks.outputs.tasks }} koverXmlReport -Pci=true --continue --scan - env: - VERSION_CODE: ${{ env.VERSION_CODE }} - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: meshtastic/Meshtastic-Android - report_type: coverage - directory: . - files: "**/build/reports/kover/report.xml" - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - report_type: test_results - directory: . - files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml" - - - name: Upload F-Droid debug artifact - if: ${{ inputs.upload_artifacts && (inputs.test_flavors == 'fdroid' || inputs.test_flavors == 'both') }} - uses: actions/upload-artifact@v6 - with: - name: fdroidDebug - path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk - retention-days: 14 - - name: Upload Google debug artifact - if: ${{ inputs.upload_artifacts && (inputs.test_flavors == 'google' || inputs.test_flavors == 'both') }} - uses: actions/upload-artifact@v6 - with: - name: googleDebug - path: app/build/outputs/apk/google/debug/app-google-debug.apk - retention-days: 14 - - - name: Upload reports - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v6 - with: - name: upload-reports - path: | - build/reports - **/build/reports - retention-days: 14 diff --git a/.github/workflows/reusable-android-test.yml b/.github/workflows/reusable-android-test.yml deleted file mode 100644 index 220c972fe..000000000 --- a/.github/workflows/reusable-android-test.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Reusable Android Instrumented Tests - -on: - workflow_call: - inputs: - upload_artifacts: - description: 'Whether to upload Android test reports' - required: false - type: boolean - default: true - api_levels: - description: 'JSON array string of API levels to run tests on (e.g., `[35]` or `[26, 34, 35]`)' - required: false - type: string - default: '[26, 35]' - test_flavors: - description: 'Which flavors to test: "google", "fdroid", or "both"' - required: false - type: string - default: 'both' - num_shards: - description: 'Number of shards to split tests into' - required: false - type: number - default: 1 - secrets: - GRADLE_ENCRYPTION_KEY: - required: false - CODECOV_TOKEN: - required: true - GRADLE_CACHE_URL: - required: false - GRADLE_CACHE_USERNAME: - required: false - GRADLE_CACHE_PASSWORD: - required: false - -jobs: - setup-matrix: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - id: set-matrix - run: | - API_LEVELS='${{ inputs.api_levels }}' - FLAVORS='${{ inputs.test_flavors }}' - NUM_SHARDS=${{ inputs.num_shards }} - - if [ "$FLAVORS" = "both" ]; then - FLAVORS_JSON='["google", "fdroid"]' - else - FLAVORS_JSON="[\"$FLAVORS\"]" - fi - - SHARDS_JSON=$(seq 0 $((NUM_SHARDS - 1)) | jq -R . | jq -s -c .) - - echo "matrix={\"api_level\":$API_LEVELS,\"flavor\":$FLAVORS_JSON,\"shard\":$SHARDS_JSON}" >> $GITHUB_OUTPUT - - androidTest: - needs: setup-matrix - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - GRADLE_OPTS: "-Dorg.gradle.daemon=false" - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - submodules: 'recursive' - fetch-depth: 1 - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'jetbrains' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Enable KVM group perms - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - build-scan-publish: true - build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service' - build-scan-terms-of-use-agree: 'yes' - add-job-summary: always - - - name: AVD cache - uses: actions/cache@v5 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ matrix.api_level }} - - - name: create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api_level }} - arch: x86_64 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." - - - name: Determine test tasks - id: test-tasks - run: | - if [ "${{ matrix.flavor }}" = "google" ]; then - echo "tasks=connectedGoogleDebugAndroidTest" >> $GITHUB_OUTPUT - else - echo "tasks=connectedFdroidDebugAndroidTest" >> $GITHUB_OUTPUT - fi - - - name: Run Sharded Android Instrumented Tests - uses: reactivecircus/android-emulator-runner@v2 - env: - ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: 60 - with: - api-level: ${{ matrix.api_level }} - arch: x86_64 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: ./gradlew ${{ steps.test-tasks.outputs.tasks }} koverXmlReport -Pci=true -Pandroid.testInstrumentationRunnerArguments.numShards=${{ inputs.num_shards }} -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }} --continue --scan && ( killall -INT crashpad_handler || true ) - - - name: Upload coverage reports to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - report_type: coverage - slug: meshtastic/Meshtastic-Android - files: "**/build/reports/kover/report.xml" - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - report_type: test_results - directory: . - files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml" - - - name: Upload Test Results - if: ${{ always() && inputs.upload_artifacts }} - uses: actions/upload-artifact@v6 - with: - name: android-test-reports-api-${{ matrix.api_level }}-${{ matrix.flavor }}-shard-${{ matrix.shard }} - path: | - **/build/outputs/androidTest-results/connected/** - **/build/reports/androidTests/connected/** - retention-days: 14 diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml new file mode 100644 index 000000000..ab590e227 --- /dev/null +++ b/.github/workflows/reusable-check.yml @@ -0,0 +1,196 @@ +name: Reusable Android Check + +on: + workflow_call: + inputs: + run_lint: + type: boolean + default: true + run_unit_tests: + type: boolean + default: true + run_instrumented_tests: + type: boolean + default: true + flavors: + type: string + default: '["google"]' + api_levels: + type: string + default: '[35]' + num_shards: + type: number + default: 1 + upload_artifacts: + type: boolean + default: true + secrets: + GRADLE_ENCRYPTION_KEY: + required: false + CODECOV_TOKEN: + required: false + DATADOG_APPLICATION_ID: + required: false + DATADOG_CLIENT_TOKEN: + required: false + GOOGLE_MAPS_API_KEY: + required: false + GRADLE_CACHE_URL: + required: false + GRADLE_CACHE_USERNAME: + required: false + GRADLE_CACHE_PASSWORD: + required: false + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: true + matrix: + api_level: ${{ fromJson(inputs.api_levels) }} + flavor: ${{ fromJson(inputs.flavors) }} + env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }} + DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }} + MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} + GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} + GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} + GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'jetbrains' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + build-scan-publish: true + build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service' + build-scan-terms-of-use-agree: 'yes' + add-job-summary: always + + - name: Calculate Version Code + id: calculate_version_code + uses: ./.github/actions/calculate-version-code + + - name: Determine Tasks + id: tasks + run: | + TASKS="" + # Only run Lint and Unit Tests on the first API level and first flavor in the matrix to save time and resources + IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}') + IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"') + + if [ "${{ inputs.run_lint }}" = "true" ] && [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then + TASKS="$TASKS spotlessCheck detekt " + fi + + FLAVOR="${{ matrix.flavor }}" + if [ "$IS_FIRST_API" = "true" ]; then + if [ "$FLAVOR" = "google" ]; then + TASKS="$TASKS assembleGoogleDebug " + [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testGoogleDebugUnitTest " + elif [ "$FLAVOR" = "fdroid" ]; then + TASKS="$TASKS assembleFdroidDebug " + [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testFdroidDebugUnitTest " + fi + fi + + # Instrumented Test Tasks + if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then + if [ "$FLAVOR" = "google" ]; then + TASKS="$TASKS connectedGoogleDebugAndroidTest " + elif [ "$FLAVOR" = "fdroid" ]; then + TASKS="$TASKS connectedFdroidDebugAndroidTest " + fi + fi + + # Run coverage report if any tests were executed + if [[ $TASKS == *"test"* ]]; then + TASKS="$TASKS koverXmlReport" + fi + + echo "tasks=$TASKS" >> $GITHUB_OUTPUT + echo "is_first_api=$IS_FIRST_API" >> $GITHUB_OUTPUT + + - name: Enable KVM group perms + if: inputs.run_instrumented_tests == true + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Check (with Emulator) + if: inputs.run_instrumented_tests == true + uses: reactivecircus/android-emulator-runner@v2 + env: + VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} + with: + api-level: ${{ matrix.api_level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan + + - name: Run Check (no Emulator) + if: inputs.run_instrumented_tests == false + env: + VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} + run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan + + - name: Upload coverage results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: meshtastic/Meshtastic-Android + files: "**/build/reports/kover/report.xml" + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results + files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml" + + - name: Upload debug artifact + if: ${{ steps.tasks.outputs.is_first_api == 'true' && inputs.upload_artifacts }} + uses: actions/upload-artifact@v6 + with: + name: ${{ matrix.flavor }}Debug + path: app/build/outputs/apk/${{ matrix.flavor }}/debug/app-${{ matrix.flavor }}-debug.apk + retention-days: 14 + + - name: Report App Size + if: always() && steps.tasks.outputs.is_first_api == 'true' + run: | + echo "### 📦 App Size Report" >> $GITHUB_STEP_SUMMARY + echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY + echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY + find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY + + - name: Upload reports + if: ${{ always() && inputs.upload_artifacts }} + uses: actions/upload-artifact@v6 + with: + name: reports-${{ matrix.flavor }}-api-${{ matrix.api_level }} + path: | + **/build/reports + **/build/test-results + **/build/outputs/androidTest-results + retention-days: 7 diff --git a/.github/workflows/reusable-lint.yml b/.github/workflows/reusable-lint.yml deleted file mode 100644 index ce08932ed..000000000 --- a/.github/workflows/reusable-lint.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Reusable Lint and Format Check - -on: - workflow_call: - inputs: - ref: - description: 'The branch, tag or SHA to checkout' - required: false - type: string - secrets: - GRADLE_ENCRYPTION_KEY: - required: false - GRADLE_CACHE_URL: - required: false - GRADLE_CACHE_USERNAME: - required: false - GRADLE_CACHE_PASSWORD: - required: false - -jobs: - lint: - runs-on: ubuntu-latest # Lint is fast, doesn't need large runner - timeout-minutes: 10 - env: - GRADLE_OPTS: "-Dorg.gradle.daemon=false" - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ inputs.ref || '' }} - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'jetbrains' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - build-scan-publish: true - build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service' - build-scan-terms-of-use-agree: 'yes' - add-job-summary: always - - - name: Run Spotless and Detekt - run: ./gradlew spotlessCheck detekt -Pci=true --scan diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9a7d563d..c3a453433 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -134,6 +134,8 @@ configure { // Disables dependency metadata when building Android App Bundles (for Google Play) includeInBundle = false } + + testInstrumentationRunner = "com.geeksville.mesh.TestRunner" } // Configure existing product flavors (defined by convention plugin) @@ -231,7 +233,6 @@ dependencies { implementation(libs.androidx.core.splashscreen) implementation(libs.kotlinx.serialization.json) implementation(libs.org.eclipse.paho.client.mqttv3) - implementation(libs.streamsupport.minifuture) implementation(libs.usb.serial.android) implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.hilt.work) diff --git a/app/dependencies/googleReleaseRuntimeClasspath.txt b/app/dependencies/googleReleaseRuntimeClasspath.txt new file mode 100644 index 000000000..c6b9b1427 --- /dev/null +++ b/app/dependencies/googleReleaseRuntimeClasspath.txt @@ -0,0 +1,415 @@ +androidx.activity:activity-compose:1.12.3 +androidx.activity:activity-ktx:1.12.3 +androidx.activity:activity:1.12.3 +androidx.annotation:annotation-experimental:1.5.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.appcompat:appcompat-resources:1.7.1 +androidx.appcompat:appcompat:1.7.1 +androidx.arch.core:core-common:2.2.0 +androidx.arch.core:core-runtime:2.2.0 +androidx.autofill:autofill:1.0.0 +androidx.cardview:cardview:1.0.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.animation:animation-android:1.11.0-alpha04 +androidx.compose.animation:animation-core-android:1.11.0-alpha04 +androidx.compose.animation:animation-core:1.11.0-alpha04 +androidx.compose.animation:animation:1.11.0-alpha04 +androidx.compose.foundation:foundation-android:1.11.0-alpha04 +androidx.compose.foundation:foundation-layout-android:1.11.0-alpha04 +androidx.compose.foundation:foundation-layout:1.11.0-alpha04 +androidx.compose.foundation:foundation:1.11.0-alpha04 +androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha07 +androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha07 +androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha07 +androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha07 +androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha07 +androidx.compose.material3.adaptive:adaptive:1.3.0-alpha07 +androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha13 +androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha13 +androidx.compose.material3:material3-android:1.5.0-alpha13 +androidx.compose.material3:material3:1.5.0-alpha13 +androidx.compose.material:material-android:1.11.0-alpha04 +androidx.compose.material:material-icons-core-android:1.7.8 +androidx.compose.material:material-icons-core:1.7.8 +androidx.compose.material:material-icons-extended-android:1.7.8 +androidx.compose.material:material-icons-extended:1.7.8 +androidx.compose.material:material-ripple-android:1.11.0-alpha04 +androidx.compose.material:material-ripple:1.11.0-alpha04 +androidx.compose.material:material:1.11.0-alpha04 +androidx.compose.runtime:runtime-android:1.11.0-alpha04 +androidx.compose.runtime:runtime-annotation-android:1.11.0-alpha04 +androidx.compose.runtime:runtime-annotation:1.11.0-alpha04 +androidx.compose.runtime:runtime-livedata:1.11.0-alpha04 +androidx.compose.runtime:runtime-retain-android:1.11.0-alpha04 +androidx.compose.runtime:runtime-retain:1.11.0-alpha04 +androidx.compose.runtime:runtime-saveable-android:1.11.0-alpha04 +androidx.compose.runtime:runtime-saveable:1.11.0-alpha04 +androidx.compose.runtime:runtime-tracing:1.11.0-alpha04 +androidx.compose.runtime:runtime:1.11.0-alpha04 +androidx.compose.ui:ui-android:1.11.0-alpha04 +androidx.compose.ui:ui-geometry-android:1.11.0-alpha04 +androidx.compose.ui:ui-geometry:1.11.0-alpha04 +androidx.compose.ui:ui-graphics-android:1.11.0-alpha04 +androidx.compose.ui:ui-graphics:1.11.0-alpha04 +androidx.compose.ui:ui-text-android:1.11.0-alpha04 +androidx.compose.ui:ui-text:1.11.0-alpha04 +androidx.compose.ui:ui-tooling-android:1.11.0-alpha04 +androidx.compose.ui:ui-tooling-data-android:1.11.0-alpha04 +androidx.compose.ui:ui-tooling-data:1.11.0-alpha04 +androidx.compose.ui:ui-tooling-preview-android:1.11.0-alpha04 +androidx.compose.ui:ui-tooling-preview:1.11.0-alpha04 +androidx.compose.ui:ui-tooling:1.11.0-alpha04 +androidx.compose.ui:ui-unit-android:1.11.0-alpha04 +androidx.compose.ui:ui-unit:1.11.0-alpha04 +androidx.compose.ui:ui-util-android:1.11.0-alpha04 +androidx.compose.ui:ui-util:1.11.0-alpha04 +androidx.compose.ui:ui:1.11.0-alpha04 +androidx.compose:compose-bom-alpha:2026.01.01 +androidx.compose:compose-bom:2026.01.00 +androidx.concurrent:concurrent-futures-ktx:1.1.0 +androidx.concurrent:concurrent-futures:1.1.0 +androidx.constraintlayout:constraintlayout-core:1.0.0 +androidx.constraintlayout:constraintlayout:2.1.0 +androidx.coordinatorlayout:coordinatorlayout:1.1.0 +androidx.core:core-ktx:1.17.0 +androidx.core:core-location-altitude-external-protobuf:1.0.0-beta01 +androidx.core:core-location-altitude-proto:1.0.0-beta01 +androidx.core:core-location-altitude:1.0.0-beta01 +androidx.core:core-splashscreen:1.2.0 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.17.0 +androidx.cursoradapter:cursoradapter:1.0.0 +androidx.customview:customview-poolingcontainer:1.0.0 +androidx.customview:customview:1.1.0 +androidx.databinding:viewbinding:8.13.2 +androidx.datastore:datastore-android:1.2.0 +androidx.datastore:datastore-core-android:1.2.0 +androidx.datastore:datastore-core-okio-jvm:1.2.0 +androidx.datastore:datastore-core-okio:1.2.0 +androidx.datastore:datastore-core:1.2.0 +androidx.datastore:datastore-preferences-android:1.2.0 +androidx.datastore:datastore-preferences-core-android:1.2.0 +androidx.datastore:datastore-preferences-core:1.2.0 +androidx.datastore:datastore-preferences-external-protobuf:1.2.0 +androidx.datastore:datastore-preferences-proto:1.2.0 +androidx.datastore:datastore-preferences:1.2.0 +androidx.datastore:datastore:1.2.0 +androidx.documentfile:documentfile:1.0.0 +androidx.drawerlayout:drawerlayout:1.1.1 +androidx.dynamicanimation:dynamicanimation:1.1.0 +androidx.emoji2:emoji2-emojipicker:1.6.0 +androidx.emoji2:emoji2-views-helper:1.6.0 +androidx.emoji2:emoji2:1.6.0 +androidx.exifinterface:exifinterface:1.4.1 +androidx.fragment:fragment-ktx:1.6.2 +androidx.fragment:fragment:1.6.2 +androidx.graphics:graphics-path:1.0.1 +androidx.graphics:graphics-shapes-android:1.0.1 +androidx.graphics:graphics-shapes:1.0.1 +androidx.hilt:hilt-common:1.3.0 +androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0 +androidx.hilt:hilt-lifecycle-viewmodel:1.3.0 +androidx.hilt:hilt-work:1.3.0 +androidx.interpolator:interpolator:1.0.0 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.10.0 +androidx.lifecycle:lifecycle-common-jvm:2.10.0 +androidx.lifecycle:lifecycle-common:2.10.0 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0 +androidx.lifecycle:lifecycle-livedata-core:2.10.0 +androidx.lifecycle:lifecycle-livedata-ktx:2.10.0 +androidx.lifecycle:lifecycle-livedata:2.10.0 +androidx.lifecycle:lifecycle-process:2.10.0 +androidx.lifecycle:lifecycle-runtime-android:2.10.0 +androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0 +androidx.lifecycle:lifecycle-runtime-compose:2.10.0 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0 +androidx.lifecycle:lifecycle-runtime-ktx:2.10.0 +androidx.lifecycle:lifecycle-runtime:2.10.0 +androidx.lifecycle:lifecycle-service:2.10.0 +androidx.lifecycle:lifecycle-viewmodel-android:2.10.0 +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0 +androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0 +androidx.lifecycle:lifecycle-viewmodel:2.10.0 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.1.0 +androidx.metrics:metrics-performance:1.0.0-beta03 +androidx.navigation3:navigation3-runtime-android:1.0.0 +androidx.navigation3:navigation3-runtime:1.0.0 +androidx.navigation3:navigation3-ui-android:1.0.0 +androidx.navigation3:navigation3-ui:1.0.0 +androidx.navigation:navigation-common-android:2.9.7 +androidx.navigation:navigation-common:2.9.7 +androidx.navigation:navigation-compose-android:2.9.7 +androidx.navigation:navigation-compose:2.9.7 +androidx.navigation:navigation-fragment:2.9.7 +androidx.navigation:navigation-runtime-android:2.9.7 +androidx.navigation:navigation-runtime:2.9.7 +androidx.navigationevent:navigationevent-android:1.0.2 +androidx.navigationevent:navigationevent-compose-android:1.0.2 +androidx.navigationevent:navigationevent-compose:1.0.2 +androidx.navigationevent:navigationevent:1.0.2 +androidx.paging:paging-common-android:3.4.0 +androidx.paging:paging-common:3.4.0 +androidx.paging:paging-compose-android:3.4.0 +androidx.paging:paging-compose:3.4.0 +androidx.print:print:1.0.0 +androidx.privacysandbox.ads:ads-adservices-java:1.1.0-beta11 +androidx.privacysandbox.ads:ads-adservices:1.1.0-beta11 +androidx.profileinstaller:profileinstaller:1.4.1 +androidx.recyclerview:recyclerview:1.3.2 +androidx.resourceinspection:resourceinspection-annotation:1.0.1 +androidx.room:room-common-jvm:2.8.4 +androidx.room:room-common:2.8.4 +androidx.room:room-paging-android:2.8.4 +androidx.room:room-paging:2.8.4 +androidx.room:room-runtime-android:2.8.4 +androidx.room:room-runtime:2.8.4 +androidx.savedstate:savedstate-android:1.4.0 +androidx.savedstate:savedstate-compose-android:1.4.0 +androidx.savedstate:savedstate-compose:1.4.0 +androidx.savedstate:savedstate-ktx:1.4.0 +androidx.savedstate:savedstate:1.4.0 +androidx.slidingpanelayout:slidingpanelayout:1.2.0 +androidx.sqlite:sqlite-android:2.6.2 +androidx.sqlite:sqlite-framework-android:2.6.2 +androidx.sqlite:sqlite-framework:2.6.2 +androidx.sqlite:sqlite:2.6.2 +androidx.startup:startup-runtime:1.2.0 +androidx.tracing:tracing-ktx:1.2.0 +androidx.tracing:tracing-perfetto:1.0.1 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.6.0 +androidx.vectordrawable:vectordrawable-animated:1.1.0 +androidx.vectordrawable:vectordrawable:1.1.0 +androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.viewpager2:viewpager2:1.1.0-beta02 +androidx.viewpager:viewpager:1.0.0 +androidx.window:window-core-android:1.5.0 +androidx.window:window-core:1.5.0 +androidx.window:window:1.5.0 +androidx.work:work-runtime-ktx:2.11.1 +androidx.work:work-runtime:2.11.1 +co.touchlab:kermit-android:2.0.8 +co.touchlab:kermit-core-android:2.0.8 +co.touchlab:kermit-core:2.0.8 +co.touchlab:kermit:2.0.8 +com.caverock:androidsvg-aar:1.4 +com.datadoghq:dd-sdk-android-compose:3.6.0 +com.datadoghq:dd-sdk-android-core:3.6.0 +com.datadoghq:dd-sdk-android-internal:3.6.0 +com.datadoghq:dd-sdk-android-logs:3.6.0 +com.datadoghq:dd-sdk-android-okhttp:3.6.0 +com.datadoghq:dd-sdk-android-rum:3.6.0 +com.datadoghq:dd-sdk-android-session-replay-compose:3.6.0 +com.datadoghq:dd-sdk-android-session-replay:3.6.0 +com.datadoghq:dd-sdk-android-timber:3.6.0 +com.datadoghq:dd-sdk-android-trace-api:3.6.0 +com.datadoghq:dd-sdk-android-trace-internal:3.6.0 +com.datadoghq:dd-sdk-android-trace-otel:3.6.0 +com.datadoghq:dd-sdk-android-trace:3.6.0 +com.github.mik3y:usb-serial-for-android:3.10.0 +com.google.accompanist:accompanist-drawablepainter:0.37.3 +com.google.accompanist:accompanist-permissions:0.37.3 +com.google.android.datatransport:transport-api:3.2.0 +com.google.android.datatransport:transport-backend-cct:3.3.0 +com.google.android.datatransport:transport-runtime:3.3.0 +com.google.android.gms:play-services-ads-identifier:18.0.0 +com.google.android.gms:play-services-base:18.5.0 +com.google.android.gms:play-services-basement:18.9.0 +com.google.android.gms:play-services-location:21.3.0 +com.google.android.gms:play-services-maps:20.0.0 +com.google.android.gms:play-services-measurement-api:23.0.0 +com.google.android.gms:play-services-measurement-base:23.0.0 +com.google.android.gms:play-services-measurement-impl:23.0.0 +com.google.android.gms:play-services-measurement-sdk-api:23.0.0 +com.google.android.gms:play-services-measurement-sdk:23.0.0 +com.google.android.gms:play-services-measurement:23.0.0 +com.google.android.gms:play-services-stats:17.0.2 +com.google.android.gms:play-services-tasks:18.4.0 +com.google.android.material:material:1.13.0 +com.google.auto.value:auto-value-annotations:1.6.3 +com.google.code.findbugs:jsr305:3.0.2 +com.google.code.gson:gson:2.13.2 +com.google.dagger:dagger-lint-aar:2.59 +com.google.dagger:dagger:2.59 +com.google.dagger:hilt-android:2.59 +com.google.dagger:hilt-core:2.59 +com.google.errorprone:error_prone_annotations:2.41.0 +com.google.firebase:firebase-analytics:23.0.0 +com.google.firebase:firebase-annotations:17.0.0 +com.google.firebase:firebase-bom:34.8.0 +com.google.firebase:firebase-common:22.0.1 +com.google.firebase:firebase-components:19.0.0 +com.google.firebase:firebase-config-interop:16.0.1 +com.google.firebase:firebase-crashlytics:20.0.4 +com.google.firebase:firebase-datatransport:19.0.0 +com.google.firebase:firebase-encoders-json:18.0.1 +com.google.firebase:firebase-encoders-proto:16.0.0 +com.google.firebase:firebase-encoders:17.0.0 +com.google.firebase:firebase-installations-interop:17.2.0 +com.google.firebase:firebase-installations:19.0.1 +com.google.firebase:firebase-measurement-connector:20.0.1 +com.google.firebase:firebase-sessions:3.0.4 +com.google.guava:failureaccess:1.0.3 +com.google.guava:guava:33.5.0-android +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava +com.google.j2objc:j2objc-annotations:3.1 +com.google.maps.android:android-maps-utils:4.0.0 +com.google.maps.android:maps-compose-utils:8.0.0 +com.google.maps.android:maps-compose-widgets:8.0.0 +com.google.maps.android:maps-compose:8.0.0 +com.google.maps.android:maps-ktx:6.0.0 +com.google.maps.android:maps-utils-ktx:6.0.0 +com.google.re2j:re2j:1.7 +com.google.zxing:core:3.5.4 +com.jakewharton.timber:timber:5.0.1 +com.journeyapps:zxing-android-embedded:4.3.0 +com.lyft.kronos:kronos-android:0.0.1-alpha11 +com.lyft.kronos:kronos-java:0.0.1-alpha11 +com.mikepenz:aboutlibraries-compose-core-android:13.2.1 +com.mikepenz:aboutlibraries-compose-core:13.2.1 +com.mikepenz:aboutlibraries-compose-m3-android:13.2.1 +com.mikepenz:aboutlibraries-compose-m3:13.2.1 +com.mikepenz:aboutlibraries-core-android:13.2.1 +com.mikepenz:aboutlibraries-core:13.2.1 +com.mikepenz:multiplatform-markdown-renderer-android:0.39.2 +com.mikepenz:multiplatform-markdown-renderer-m3-android:0.39.2 +com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2 +com.mikepenz:multiplatform-markdown-renderer:0.39.2 +com.patrykandpatrick.vico:compose-android:3.0.0-beta.3 +com.patrykandpatrick.vico:compose-m2-android:3.0.0-beta.3 +com.patrykandpatrick.vico:compose-m2:3.0.0-beta.3 +com.patrykandpatrick.vico:compose-m3-android:3.0.0-beta.3 +com.patrykandpatrick.vico:compose-m3:3.0.0-beta.3 +com.patrykandpatrick.vico:compose:3.0.0-beta.3 +com.squareup.okhttp3:logging-interceptor:5.3.2 +com.squareup.okhttp3:okhttp-android:5.3.2 +com.squareup.okhttp3:okhttp:5.3.2 +com.squareup.okio:okio-jvm:3.16.4 +com.squareup.okio:okio:3.16.4 +com.squareup.wire:wire-runtime-jvm:5.2.1 +com.squareup.wire:wire-runtime:5.2.1 +io.coil-kt.coil3:coil-android:3.3.0 +io.coil-kt.coil3:coil-compose-android:3.3.0 +io.coil-kt.coil3:coil-compose-core-android:3.3.0 +io.coil-kt.coil3:coil-compose-core:3.3.0 +io.coil-kt.coil3:coil-compose:3.3.0 +io.coil-kt.coil3:coil-core-android:3.3.0 +io.coil-kt.coil3:coil-core:3.3.0 +io.coil-kt.coil3:coil-network-core-android:3.3.0 +io.coil-kt.coil3:coil-network-core:3.3.0 +io.coil-kt.coil3:coil-network-okhttp-jvm:3.3.0 +io.coil-kt.coil3:coil-network-okhttp:3.3.0 +io.coil-kt.coil3:coil-svg-android:3.3.0 +io.coil-kt.coil3:coil-svg:3.3.0 +io.coil-kt.coil3:coil:3.3.0 +io.ktor:ktor-client-content-negotiation-jvm:3.4.0 +io.ktor:ktor-client-content-negotiation:3.4.0 +io.ktor:ktor-client-core-jvm:3.4.0 +io.ktor:ktor-client-core:3.4.0 +io.ktor:ktor-client-okhttp-jvm:3.4.0 +io.ktor:ktor-client-okhttp:3.4.0 +io.ktor:ktor-events-jvm:3.4.0 +io.ktor:ktor-events:3.4.0 +io.ktor:ktor-http-cio-jvm:3.4.0 +io.ktor:ktor-http-cio:3.4.0 +io.ktor:ktor-http-jvm:3.4.0 +io.ktor:ktor-http:3.4.0 +io.ktor:ktor-io-jvm:3.4.0 +io.ktor:ktor-io:3.4.0 +io.ktor:ktor-network-jvm:3.4.0 +io.ktor:ktor-network:3.4.0 +io.ktor:ktor-serialization-jvm:3.4.0 +io.ktor:ktor-serialization-kotlinx-json-jvm:3.4.0 +io.ktor:ktor-serialization-kotlinx-json:3.4.0 +io.ktor:ktor-serialization-kotlinx-jvm:3.4.0 +io.ktor:ktor-serialization-kotlinx:3.4.0 +io.ktor:ktor-serialization:3.4.0 +io.ktor:ktor-sse-jvm:3.4.0 +io.ktor:ktor-sse:3.4.0 +io.ktor:ktor-utils-jvm:3.4.0 +io.ktor:ktor-utils:3.4.0 +io.ktor:ktor-websocket-serialization-jvm:3.4.0 +io.ktor:ktor-websocket-serialization:3.4.0 +io.ktor:ktor-websockets-jvm:3.4.0 +io.ktor:ktor-websockets:3.4.0 +io.opentelemetry:opentelemetry-api:1.40.0 +io.opentelemetry:opentelemetry-context:1.40.0 +jakarta.inject:jakarta.inject-api:2.0.1 +javax.inject:javax.inject:1 +no.nordicsemi.android:dfu:2.10.1 +no.nordicsemi.kotlin.ble:client-android:2.0.0-alpha12 +no.nordicsemi.kotlin.ble:client-core-android:2.0.0-alpha12 +no.nordicsemi.kotlin.ble:client-core:2.0.0-alpha12 +no.nordicsemi.kotlin.ble:core:2.0.0-alpha12 +org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5 +org.jctools:jctools-core:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6 +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6 +org.jetbrains.androidx.savedstate:savedstate:1.3.6 +org.jetbrains.compose.animation:animation-core:1.10.0 +org.jetbrains.compose.animation:animation:1.10.0 +org.jetbrains.compose.annotation-internal:annotation:1.10.0 +org.jetbrains.compose.collection-internal:collection:1.10.0 +org.jetbrains.compose.components:components-resources-android:1.10.0 +org.jetbrains.compose.components:components-resources:1.10.0 +org.jetbrains.compose.foundation:foundation-layout:1.10.0 +org.jetbrains.compose.foundation:foundation:1.10.0 +org.jetbrains.compose.material3:material3:1.9.0 +org.jetbrains.compose.material:material-ripple:1.10.0 +org.jetbrains.compose.material:material:1.10.0 +org.jetbrains.compose.runtime:runtime-saveable:1.10.0 +org.jetbrains.compose.runtime:runtime:1.10.0 +org.jetbrains.compose.ui:ui-backhandler-android:1.9.1 +org.jetbrains.compose.ui:ui-backhandler:1.9.1 +org.jetbrains.compose.ui:ui-geometry:1.10.0 +org.jetbrains.compose.ui:ui-graphics:1.10.0 +org.jetbrains.compose.ui:ui-text:1.10.0 +org.jetbrains.compose.ui:ui-tooling-preview:1.10.0-rc02 +org.jetbrains.compose.ui:ui-unit:1.10.0 +org.jetbrains.compose.ui:ui-util:1.10.0 +org.jetbrains.compose.ui:ui:1.10.0 +org.jetbrains.kotlin:kotlin-bom:1.8.22 +org.jetbrains.kotlin:kotlin-parcelize-runtime:2.3.0 +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21 +org.jetbrains.kotlin:kotlin-stdlib:2.3.0 +org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0 +org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0 +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2 +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1-0.6.x-compat +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.2 +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.2 +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.2 +org.jetbrains.kotlinx:kotlinx-io-core:0.8.2 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.10.0 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.10.0 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.10.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.10.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.10.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0 +org.jetbrains:annotations:23.0.0 +org.jetbrains:markdown-jvm:0.7.3 +org.jetbrains:markdown:0.7.3 +org.jspecify:jspecify:1.0.0 +org.slf4j:slf4j-api:2.0.17 diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 0227b52b2..801f6c2f2 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -1,17 +1,14 @@ - + - CommentSpacing:BLEException.kt$BLEConnectionClosing$/// Our interface is being shut down - CommentSpacing:Constants.kt$/// a bool true means we expect this condition to continue until, false means device might come back CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib CyclomaticComplexMethod:BleError.kt$BleError.Companion$fun from(exception: Throwable): BleError - CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) + CyclomaticComplexMethod:MeshMessageProcessor.kt$MeshMessageProcessor$private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) CyclomaticComplexMethod:SettingsNavigation.kt$@Suppress("LongMethod") fun NavGraphBuilder.settingsGraph(navController: NavHostController) EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ } EmptyFunctionBlock:NopInterface.kt$NopInterface${ } EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${} - FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt @@ -21,27 +18,15 @@ FinalNewline:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt FinalNewline:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt FinalNewline:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt - FinalNewline:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt FinalNewline:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt FinalNewline:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt - LargeClass:MeshService.kt$MeshService : Service - LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect() MagicNumber:Contacts.kt$7 MagicNumber:Contacts.kt$8 MagicNumber:MQTTRepository.kt$MQTTRepository$512 - MagicNumber:MeshService.kt$MeshService$0xffffffff - MagicNumber:MeshService.kt$MeshService$1000 - MagicNumber:MeshService.kt$MeshService$1000.0 - MagicNumber:MeshService.kt$MeshService$1000L - MagicNumber:MeshService.kt$MeshService$16 - MagicNumber:MeshService.kt$MeshService$30 - MagicNumber:MeshService.kt$MeshService$32 - MagicNumber:MeshService.kt$MeshService$60000 - MagicNumber:MeshService.kt$MeshService$8 MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972 MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809 MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790 @@ -55,17 +40,10 @@ MagicNumber:StreamInterface.kt$StreamInterface$8 MagicNumber:TCPInterface.kt$TCPInterface$1000 MagicNumber:UIState.kt$4 - MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]" - MaxLineLength:MeshService.kt$MeshService$"Neighbor info response filtered: ToUs=$isAddressedToUs, isRecentRequest=$isRecentRequest" - MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable" - MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})" MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}" MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}" MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}" MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}" - NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) - NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) - NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt @@ -75,7 +53,6 @@ NewLineAtEndOfFile:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt NewLineAtEndOfFile:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt NewLineAtEndOfFile:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt - NewLineAtEndOfFile:RadioNotConnectedException.kt$com.geeksville.mesh.service.RadioNotConnectedException.kt NewLineAtEndOfFile:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt NewLineAtEndOfFile:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt NewLineAtEndOfFile:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt @@ -83,37 +60,33 @@ NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt NoBlankLineBeforeRbrace:DebugLogFile.kt$BinaryLogFile$ NoBlankLineBeforeRbrace:NopInterface.kt$NopInterface$ - NoConsecutiveBlankLines:Constants.kt$ NoConsecutiveBlankLines:DebugLogFile.kt$ NoEmptyClassBody:DebugLogFile.kt$BinaryLogFile${ } NoSemicolons:DateUtils.kt$DateUtils$; OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex + ReturnCount:MeshDataHandler.kt$MeshDataHandler$@Suppress("LongMethod") private fun handleStoreForwardPlusPlus(packet: MeshPacket) + ReturnCount:MeshDataHandler.kt$MeshDataHandler$private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean SwallowedException:Exceptions.kt$ex: Throwable SwallowedException:NsdManager.kt$ex: IllegalArgumentException SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException TooGenericExceptionCaught:Exceptions.kt$ex: Throwable TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception + TooGenericExceptionCaught:MeshDataHandler.kt$MeshDataHandler$e: Exception TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception - TooGenericExceptionCaught:MeshService.kt$MeshService.<no name provided>$ex: Exception TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable - TooGenericExceptionThrown:MeshService.kt$MeshService$throw Exception("Can't set user without a NodeInfo") - TooGenericExceptionThrown:MeshService.kt$MeshService.<no name provided>$throw Exception("Port numbers must be non-zero!") TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Haven't called connect") TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Service not bound") TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("SyncContinuation timeout") TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen") - TooManyFunctions:MeshService.kt$MeshService : Service - TooManyFunctions:MeshService.kt$MeshService$<no name provided> : Stub TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : IRadioInterface TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService TooManyFunctions:UIState.kt$UIViewModel : ViewModel - TopLevelPropertyNaming:Constants.kt$const val prefix = "com.geeksville.mesh" UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 880810aec..334547cf9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.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.app.Application @@ -65,9 +64,9 @@ import org.meshtastic.core.strings.client_notification import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.toSharedContact import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.AppOnlyProtos -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.ChannelSet +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 @@ -119,11 +118,11 @@ constructor( val theme: StateFlow = uiPreferencesDataSource.theme - val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition } + val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition } - val clientNotification: StateFlow = serviceRepository.clientNotification + val clientNotification: StateFlow = serviceRepository.clientNotification - fun clearClientNotification(notification: MeshProtos.ClientNotification) { + fun clearClientNotification(notification: ClientNotification) { serviceRepository.clearClientNotification() meshServiceNotifications.clearClientNotification(notification) } @@ -215,8 +214,8 @@ constructor( Logger.d { "ViewModel created" } } - private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null) - val sharedContactRequested: StateFlow + private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null) + val sharedContactRequested: StateFlow get() = _sharedContactRequested.asStateFlow() fun setSharedContactRequested(url: Uri, onFailure: () -> Unit) { @@ -236,8 +235,8 @@ constructor( val connectionState get() = serviceRepository.connectionState - private val _requestChannelSet = MutableStateFlow(null) - val requestChannelSet: StateFlow + private val _requestChannelSet = MutableStateFlow(null) + val requestChannelSet: StateFlow get() = _requestChannelSet fun requestChannelUrl(url: Uri, onFailure: () -> Unit) = 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 d1b29cf80..d0186da8b 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 @@ -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,16 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.repository.network import co.touchlab.kermit.Logger import com.geeksville.mesh.util.ignoreException -import com.google.protobuf.ByteString import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.first +import okio.ByteString.Companion.toByteString import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken import org.eclipse.paho.client.mqttv3.MqttAsyncClient @@ -35,8 +34,7 @@ import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.model.util.subscribeList -import org.meshtastic.proto.MeshProtos.MqttClientProxyMessage -import org.meshtastic.proto.mqttClientProxyMessage +import org.meshtastic.proto.MqttClientProxyMessage import java.net.URI import java.security.SecureRandom import javax.inject.Inject @@ -87,14 +85,14 @@ constructor( // Create a custom SSLContext that trusts all certificates sslContext.init(null, arrayOf(TrustAllX509TrustManager()), SecureRandom()) - val rootTopic = mqttConfig.root.ifEmpty { DEFAULT_TOPIC_ROOT } + val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT } val connectOptions = MqttConnectOptions().apply { - userName = mqttConfig.username - password = mqttConfig.password.toCharArray() + userName = mqttConfig?.username + password = mqttConfig?.password?.toCharArray() isAutomaticReconnect = true - if (mqttConfig.tlsEnabled) { + if (mqttConfig?.tls_enabled == true) { socketFactory = sslContext.socketFactory } } @@ -117,7 +115,7 @@ constructor( } .forEach { globalId -> subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+") - if (mqttConfig.jsonEnabled) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+") + if (mqttConfig?.json_enabled == true) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+") } subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+") } @@ -129,11 +127,11 @@ constructor( override fun messageArrived(topic: String, message: MqttMessage) { trySend( - mqttClientProxyMessage { - this.topic = topic - data = ByteString.copyFrom(message.payload) - retained = message.isRetained - }, + MqttClientProxyMessage( + topic = topic, + data_ = message.payload.toByteString(), + retained = message.isRetained, + ), ) } @@ -142,12 +140,11 @@ constructor( } } - val scheme = if (mqttConfig.tlsEnabled) "ssl" else "tcp" + val scheme = if (mqttConfig?.tls_enabled == true) "ssl" else "tcp" val (host, port) = - mqttConfig.address - .ifEmpty { DEFAULT_SERVER_ADDRESS } - .split(":", limit = 2) - .let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1) } + (mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let { + it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1) + } mqttClient = MqttAsyncClient(URI(scheme, null, host, port, "", "", "").toString(), ownerId, MemoryPersistence()).apply { 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 67ab2d5b9..79ad4b5ff 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 @@ -19,39 +19,40 @@ package com.geeksville.mesh.repository.radio import co.touchlab.kermit.Logger import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.model.getInitials -import com.google.protobuf.ByteString 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.model.Channel import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Position -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigKt -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.ModuleConfigProtos -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.channel -import org.meshtastic.proto.config -import org.meshtastic.proto.deviceMetadata -import org.meshtastic.proto.fromRadio -import org.meshtastic.proto.moduleConfig -import org.meshtastic.proto.queueStatus +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Config +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.Neighbor +import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.QueueStatus +import org.meshtastic.proto.Routing +import org.meshtastic.proto.StatusMessage +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.ToRadio +import org.meshtastic.proto.User import kotlin.random.Random +import org.meshtastic.proto.Channel as ProtoChannel +import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo +import org.meshtastic.proto.Position as ProtoPosition -private val defaultLoRaConfig = - ConfigKt.loRaConfig { - usePreset = true - region = ConfigProtos.Config.LoRaConfig.RegionCode.TW - } +private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.TW) -private val defaultChannel = channel { - settings = Channel.default.settings - role = ChannelProtos.Channel.Role.PRIMARY -} +private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY) /** A simulated interface that is used for testing in the simulator */ @Suppress("detekt:TooManyFunctions", "detekt:MagicNumber") @@ -77,46 +78,57 @@ constructor( } override fun handleSendToRadio(p: ByteArray) { - val pr = MeshProtos.ToRadio.parseFrom(p) - sendQueueStatus(pr.packet.id) + val pr = ToRadio.ADAPTER.decode(p) + val packet = pr.packet + if (packet != null) { + sendQueueStatus(packet.id) + } - val data = if (pr.hasPacket()) pr.packet.decoded else null + val data = packet?.decoded when { - pr.wantConfigId != 0 -> sendConfigResponse(pr.wantConfigId) - data != null && data.portnum == Portnums.PortNum.ADMIN_APP -> - handleAdminPacket(pr, AdminProtos.AdminMessage.parseFrom(data.payload)) - pr.hasPacket() && pr.packet.wantAck -> sendFakeAck(pr) + (pr.want_config_id ?: 0) != 0 -> sendConfigResponse(pr.want_config_id ?: 0) + data != null && data.portnum == PortNum.ADMIN_APP -> + handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload)) + packet != null && packet.want_ack == true -> sendFakeAck(pr) else -> Logger.i { "Ignoring data sent to mock interface $pr" } } } - private fun handleAdminPacket(pr: MeshProtos.ToRadio, d: AdminProtos.AdminMessage) { + private fun handleAdminPacket(pr: ToRadio, d: AdminMessage) { + val packet = pr.packet ?: return when { - d.getConfigRequest == AdminProtos.AdminMessage.ConfigType.LORA_CONFIG -> - sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { - getConfigResponse = config { lora = defaultLoRaConfig } + d.get_config_request == AdminMessage.ConfigType.LORA_CONFIG -> + sendAdmin(packet.to, packet.from, packet.id) { + copy(get_config_response = Config(lora = defaultLoRaConfig)) } - d.getChannelRequest != 0 -> - sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { - getChannelResponse = channel { - index = d.getChannelRequest - 1 // 0 based on the response - if (d.getChannelRequest == 1) { - settings = Channel.default.settings - role = ChannelProtos.Channel.Role.PRIMARY - } - } + (d.get_channel_request ?: 0) != 0 -> + sendAdmin(packet.to, packet.from, packet.id) { + copy( + get_channel_response = + ProtoChannel( + index = (d.get_channel_request ?: 0) - 1, // 0 based on the response + settings = if (d.get_channel_request == 1) Channel.default.settings else null, + role = + if (d.get_channel_request == 1) { + ProtoChannel.Role.PRIMARY + } else { + ProtoChannel.Role.DISABLED + }, + ), + ) } - d.getModuleConfigRequest == AdminProtos.AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG -> - sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { - getModuleConfigResponse = moduleConfig { - statusmessage = - ModuleConfigProtos.ModuleConfig.StatusMessageConfig.newBuilder() - .setNodeStatus("Going to the farm.. to grow wheat.") - .build() - } + d.get_module_config_request == AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG -> + sendAdmin(packet.to, packet.from, packet.id) { + copy( + get_module_config_response = + ModuleConfig( + statusmessage = + ModuleConfig.StatusMessageConfig(node_status = "Going to the farm.. to grow wheat."), + ), + ) } else -> Logger.i { "Ignoring admin sent to mock interface $d" } @@ -128,207 +140,169 @@ constructor( } // / Generate a fake text message from a node - private fun makeTextMessage(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply { + private fun makeTextMessage(numIn: Int) = FromRadio( packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = packetIdSequence.next() - from = numIn - to = 0xffffffff.toInt() // ugly way of saying broadcast - rxTime = (System.currentTimeMillis() / 1000).toInt() - rxSnr = 1.5f - decoded = - MeshProtos.Data.newBuilder() - .apply { - portnum = Portnums.PortNum.TEXT_MESSAGE_APP - payload = ByteString.copyFromUtf8("This simulated node sends Hi!") - } - .build() - } - .build() - } + MeshPacket( + id = packetIdSequence.next(), + from = numIn, + to = 0xffffffff.toInt(), // broadcast + rx_time = (System.currentTimeMillis() / 1000).toInt(), + rx_snr = 1.5f, + decoded = + Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "This simulated node sends Hi!".encodeUtf8(), + ), + ), + ) - private fun makeNeighborInfo(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply { + private fun makeNeighborInfo(numIn: Int) = FromRadio( packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = packetIdSequence.next() - from = numIn - to = 0xffffffff.toInt() // broadcast - rxTime = (System.currentTimeMillis() / 1000).toInt() - rxSnr = 1.5f - decoded = - MeshProtos.Data.newBuilder() - .apply { - portnum = Portnums.PortNum.NEIGHBORINFO_APP - payload = - MeshProtos.NeighborInfo.newBuilder() - .setNodeId(numIn) - .setLastSentById(numIn) - .setNodeBroadcastIntervalSecs(60) - .addNeighbors( - MeshProtos.Neighbor.newBuilder() - .setNodeId(numIn + 1) - .setSnr(10.0f) - .setLastRxTime((System.currentTimeMillis() / 1000).toInt()) - .setNodeBroadcastIntervalSecs(60) - .build(), - ) - .addNeighbors( - MeshProtos.Neighbor.newBuilder() - .setNodeId(numIn + 2) - .setSnr(12.0f) - .setLastRxTime((System.currentTimeMillis() / 1000).toInt()) - .setNodeBroadcastIntervalSecs(60) - .build(), - ) - .build() - .toByteString() - } - .build() - } - .build() - } + MeshPacket( + id = packetIdSequence.next(), + from = numIn, + to = 0xffffffff.toInt(), // broadcast + rx_time = (System.currentTimeMillis() / 1000).toInt(), + rx_snr = 1.5f, + decoded = + Data( + portnum = PortNum.NEIGHBORINFO_APP, + payload = + NeighborInfo( + node_id = numIn, + last_sent_by_id = numIn, + node_broadcast_interval_secs = 60, + neighbors = + listOf( + Neighbor( + node_id = numIn + 1, + snr = 10.0f, + last_rx_time = (System.currentTimeMillis() / 1000).toInt(), + node_broadcast_interval_secs = 60, + ), + Neighbor( + node_id = numIn + 2, + snr = 12.0f, + last_rx_time = (System.currentTimeMillis() / 1000).toInt(), + node_broadcast_interval_secs = 60, + ), + ), + ) + .encode() + .toByteString(), + ), + ), + ) - private fun makePosition(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply { + private fun makePosition(numIn: Int) = FromRadio( packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = packetIdSequence.next() - from = numIn - to = 0xffffffff.toInt() // ugly way of saying broadcast - rxTime = (System.currentTimeMillis() / 1000).toInt() - rxSnr = 1.5f - decoded = - MeshProtos.Data.newBuilder() - .apply { - portnum = Portnums.PortNum.POSITION_APP - payload = - MeshProtos.Position.newBuilder() - .setLatitudeI(Position.degI(32.776665)) - .setLongitudeI(Position.degI(-96.796989)) - .setAltitude(150) - .setTime((System.currentTimeMillis() / 1000).toInt()) - .setPrecisionBits(15) - .build() - .toByteString() - } - .build() - } - .build() - } + MeshPacket( + id = packetIdSequence.next(), + from = numIn, + to = 0xffffffff.toInt(), // broadcast + rx_time = (System.currentTimeMillis() / 1000).toInt(), + rx_snr = 1.5f, + decoded = + Data( + portnum = PortNum.POSITION_APP, + payload = + ProtoPosition( + latitude_i = org.meshtastic.core.model.Position.degI(32.776665), + longitude_i = org.meshtastic.core.model.Position.degI(-96.796989), + altitude = 150, + time = (System.currentTimeMillis() / 1000).toInt(), + precision_bits = 15, + ) + .encode() + .toByteString(), + ), + ), + ) - private fun makeTelemetry(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply { + private fun makeTelemetry(numIn: Int) = FromRadio( packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = packetIdSequence.next() - from = numIn - to = 0xffffffff.toInt() // broadcast - rxTime = (System.currentTimeMillis() / 1000).toInt() - rxSnr = 1.5f - decoded = - MeshProtos.Data.newBuilder() - .apply { - portnum = Portnums.PortNum.TELEMETRY_APP - payload = - TelemetryProtos.Telemetry.newBuilder() - .setTime((System.currentTimeMillis() / 1000).toInt()) - .setDeviceMetrics( - TelemetryProtos.DeviceMetrics.newBuilder() - .setBatteryLevel(85) - .setVoltage(4.1f) - .setChannelUtilization(0.12f) - .setAirUtilTx(0.05f) - .setUptimeSeconds(123456) - .build(), - ) - .build() - .toByteString() - } - .build() - } - .build() - } + MeshPacket( + id = packetIdSequence.next(), + from = numIn, + to = 0xffffffff.toInt(), // broadcast + rx_time = (System.currentTimeMillis() / 1000).toInt(), + rx_snr = 1.5f, + decoded = + Data( + portnum = PortNum.TELEMETRY_APP, + payload = + Telemetry( + time = (System.currentTimeMillis() / 1000).toInt(), + device_metrics = + DeviceMetrics( + battery_level = 85, + voltage = 4.1f, + channel_utilization = 0.12f, + air_util_tx = 0.05f, + uptime_seconds = 123456, + ), + ) + .encode() + .toByteString(), + ), + ), + ) - private fun makeNodeStatus(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply { + private fun makeNodeStatus(numIn: Int) = FromRadio( packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = packetIdSequence.next() - from = numIn - to = 0xffffffff.toInt() // broadcast - rxTime = (System.currentTimeMillis() / 1000).toInt() - rxSnr = 1.5f - decoded = - MeshProtos.Data.newBuilder() - .apply { - portnum = Portnums.PortNum.NODE_STATUS_APP - payload = - MeshProtos.StatusMessage.newBuilder() - .setStatus("Going to the farm.. to grow wheat.") - .build() - .toByteString() - } - .build() - } - .build() - } + MeshPacket( + id = packetIdSequence.next(), + from = numIn, + to = 0xffffffff.toInt(), // broadcast + rx_time = (System.currentTimeMillis() / 1000).toInt(), + rx_snr = 1.5f, + decoded = + Data( + portnum = PortNum.NODE_STATUS_APP, + payload = + StatusMessage(status = "Going to the farm.. to grow wheat.").encode().toByteString(), + ), + ), + ) - private fun makeDataPacket(fromIn: Int, toIn: Int, data: MeshProtos.Data.Builder) = - MeshProtos.FromRadio.newBuilder().apply { - packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = packetIdSequence.next() - from = fromIn - to = toIn - rxTime = (System.currentTimeMillis() / 1000).toInt() - rxSnr = 1.5f - decoded = data.build() - } - .build() - } + private fun makeDataPacket(fromIn: Int, toIn: Int, data: Data) = FromRadio( + packet = + MeshPacket( + id = packetIdSequence.next(), + from = fromIn, + to = toIn, + rx_time = (System.currentTimeMillis() / 1000).toInt(), + rx_snr = 1.5f, + decoded = data, + ), + ) private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = makeDataPacket( fromIn, toIn, - MeshProtos.Data.newBuilder().apply { - portnum = Portnums.PortNum.ROUTING_APP - payload = MeshProtos.Routing.newBuilder().apply {}.build().toByteString() - requestId = msgId - }, + Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId), ) private fun sendQueueStatus(msgId: Int) = service.handleFromRadio( - fromRadio { - queueStatus = queueStatus { - res = 0 - free = 16 - meshPacketId = msgId - } - } - .toByteArray(), + FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(), ) - private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminProtos.AdminMessage.Builder.() -> Unit) { + private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) { + val adminMsg = AdminMessage().initFn() val p = makeDataPacket( fromIn, toIn, - MeshProtos.Data.newBuilder().apply { - portnum = Portnums.PortNum.ADMIN_APP - payload = AdminProtos.AdminMessage.newBuilder().also { initFn(it) }.build().toByteString() - requestId = reqId - }, + Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId), ) - service.handleFromRadio(p.build().toByteArray()) + service.handleFromRadio(p.encode()) } // / Send a fake ack packet back if the sender asked for want_ack - private fun sendFakeAck(pr: MeshProtos.ToRadio) = service.serviceScope.handledLaunch { + private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch { + val packet = pr.packet ?: return@handledLaunch delay(2000) - service.handleFromRadio(makeAck(MY_NODE + 1, pr.packet.from, pr.packet.id).build().toByteArray()) + service.handleFromRadio(makeAck(MY_NODE + 1, packet.from ?: 0, packet.id).encode()) } private fun sendConfigResponse(configId: Int) { @@ -336,57 +310,45 @@ constructor( // / Generate a fake node info entry @Suppress("MagicNumber") - fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = MeshProtos.FromRadio.newBuilder().apply { - nodeInfo = - MeshProtos.NodeInfo.newBuilder() - .apply { - num = numIn - user = - MeshProtos.User.newBuilder() - .apply { - id = DataPacket.nodeNumToDefaultId(numIn) - longName = "Sim " + Integer.toHexString(num) - shortName = getInitials(longName) - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - } - .build() - position = - MeshProtos.Position.newBuilder() - .apply { - latitudeI = Position.degI(lat) - longitudeI = Position.degI(lon) - altitude = 35 - time = (System.currentTimeMillis() / 1000).toInt() - precisionBits = Random.nextInt(10, 19) - } - .build() - } - .build() - } + fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio( + node_info = + NodeInfo( + num = numIn, + user = + User( + id = DataPacket.nodeNumToDefaultId(numIn), + long_name = "Sim " + Integer.toHexString(numIn), + short_name = getInitials("Sim " + Integer.toHexString(numIn)), + hw_model = HardwareModel.ANDROID_SIM, + ), + position = + ProtoPosition( + latitude_i = org.meshtastic.core.model.Position.degI(lat), + longitude_i = org.meshtastic.core.model.Position.degI(lon), + altitude = 35, + time = (System.currentTimeMillis() / 1000).toInt(), + precision_bits = Random.nextInt(10, 19), + ), + ), + ) // Simulated network data to feed to our app val packets = arrayOf( // MyNodeInfo - MeshProtos.FromRadio.newBuilder().apply { - myInfo = MeshProtos.MyNodeInfo.newBuilder().apply { myNodeNum = MY_NODE }.build() - }, - MeshProtos.FromRadio.newBuilder().apply { - metadata = deviceMetadata { - firmwareVersion = "9.9.9.abcdefg" - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - } - }, + FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)), + FromRadio( + metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM), + ), // Fake NodeDB makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson - MeshProtos.FromRadio.newBuilder().apply { config = config { lora = defaultLoRaConfig } }, - MeshProtos.FromRadio.newBuilder().apply { channel = defaultChannel }, - MeshProtos.FromRadio.newBuilder().apply { configCompleteId = configId }, + FromRadio(config = Config(lora = defaultLoRaConfig)), + FromRadio(channel = defaultChannel), + FromRadio(config_complete_id = configId), // Done with config response, now pretend to receive some text messages - makeTextMessage(MY_NODE + 1), makeNeighborInfo(MY_NODE + 1), makePosition(MY_NODE + 1), @@ -394,6 +356,6 @@ constructor( makeNodeStatus(MY_NODE + 1), ) - packets.forEach { p -> service.handleFromRadio(p.build().toByteArray()) } + packets.forEach { p -> service.handleFromRadio(p.encode()) } } } 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 d4ef20107..3bbbfe4b2 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 @@ -49,7 +49,8 @@ import org.meshtastic.core.di.ProcessLifecycle import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.prefs.radio.RadioPrefs import org.meshtastic.core.service.ConnectionState -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio import javax.inject.Inject import javax.inject.Singleton @@ -149,9 +150,8 @@ constructor( if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) { if (radioIf is SerialInterface) { Logger.i { "Sending ToRadio heartbeat" } - val heartbeat = - MeshProtos.ToRadio.newBuilder().setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance()).build() - handleSendToRadio(heartbeat.toByteArray()) + val heartbeat = ToRadio(heartbeat = Heartbeat()) + handleSendToRadio(heartbeat.encode()) } else { // For BLE and TCP this will check if the connection is still alive radioIf.keepAlive() @@ -234,8 +234,6 @@ constructor( } } - // ignoreException { Logger.d { "FromRadio: ${MeshProtos.FromRadio.parseFrom(p }}" } } - try { processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) } emitReceiveActivity() 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 997bb9e5c..de7955f15 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 @@ -25,6 +25,8 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.IOException @@ -166,11 +168,8 @@ constructor( override fun keepAlive() { Logger.d { "[$address] TCP keepAlive" } - val heartbeat = - org.meshtastic.proto.MeshProtos.ToRadio.newBuilder() - .setHeartbeat(org.meshtastic.proto.MeshProtos.Heartbeat.getDefaultInstance()) - .build() - handleSendToRadio(heartbeat.toByteArray()) + val heartbeat = ToRadio(heartbeat = Heartbeat()) + handleSendToRadio(heartbeat.encode()) } // Create a socket to make the connection with the server diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt index 4ef3ff1db..7b4ba2c07 100644 --- a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt @@ -19,13 +19,13 @@ package com.geeksville.mesh.service import co.touchlab.kermit.Logger import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.FromRadio import javax.inject.Inject import javax.inject.Singleton /** - * Dispatches non-packet [MeshProtos.FromRadio] variants to their respective handlers. This class is stateless and - * handles routing for config, metadata, and specialized system messages. + * Dispatches non-packet [FromRadio] variants to their respective handlers. This class is stateless and handles routing + * for config, metadata, and specialized system messages. */ @Singleton class FromRadioPacketHandler @@ -38,41 +38,47 @@ constructor( private val serviceNotifications: MeshServiceNotifications, ) { @Suppress("CyclomaticComplexMethod") - fun handleFromRadio(proto: MeshProtos.FromRadio) { - when (proto.payloadVariantCase) { - MeshProtos.FromRadio.PayloadVariantCase.MY_INFO -> router.configFlowManager.handleMyInfo(proto.myInfo) - MeshProtos.FromRadio.PayloadVariantCase.METADATA -> - router.configFlowManager.handleLocalMetadata(proto.metadata) - MeshProtos.FromRadio.PayloadVariantCase.NODE_INFO -> { - router.configFlowManager.handleNodeInfo(proto.nodeInfo) + fun handleFromRadio(proto: FromRadio) { + val myInfo = proto.my_info + val metadata = proto.metadata + val nodeInfo = proto.node_info + val configCompleteId = proto.config_complete_id + val mqttProxyMessage = proto.mqttClientProxyMessage + val queueStatus = proto.queueStatus + val config = proto.config + val moduleConfig = proto.moduleConfig + val channel = proto.channel + val clientNotification = proto.clientNotification + + when { + myInfo != null -> router.configFlowManager.handleMyInfo(myInfo) + metadata != null -> router.configFlowManager.handleLocalMetadata(metadata) + nodeInfo != null -> { + router.configFlowManager.handleNodeInfo(nodeInfo) serviceRepository.setStatusMessage("Nodes (${router.configFlowManager.newNodeCount})") } - MeshProtos.FromRadio.PayloadVariantCase.CONFIG_COMPLETE_ID -> - router.configFlowManager.handleConfigComplete(proto.configCompleteId) - MeshProtos.FromRadio.PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> - mqttManager.handleMqttProxyMessage(proto.mqttClientProxyMessage) - MeshProtos.FromRadio.PayloadVariantCase.QUEUESTATUS -> packetHandler.handleQueueStatus(proto.queueStatus) - MeshProtos.FromRadio.PayloadVariantCase.CONFIG -> router.configHandler.handleDeviceConfig(proto.config) - MeshProtos.FromRadio.PayloadVariantCase.MODULECONFIG -> - router.configHandler.handleModuleConfig(proto.moduleConfig) - MeshProtos.FromRadio.PayloadVariantCase.CHANNEL -> router.configHandler.handleChannel(proto.channel) - MeshProtos.FromRadio.PayloadVariantCase.CLIENTNOTIFICATION -> { - serviceRepository.setClientNotification(proto.clientNotification) - serviceNotifications.showClientNotification(proto.clientNotification) - packetHandler.removeResponse(proto.clientNotification.replyId, complete = false) + configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId) + mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage) + queueStatus != null -> packetHandler.handleQueueStatus(queueStatus) + config != null -> router.configHandler.handleDeviceConfig(config) + moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig) + channel != null -> router.configHandler.handleChannel(channel) + clientNotification != null -> { + serviceRepository.setClientNotification(clientNotification) + serviceNotifications.showClientNotification(clientNotification) + packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false) } // Logging-only variants are handled by MeshMessageProcessor before dispatching here - MeshProtos.FromRadio.PayloadVariantCase.PACKET, - MeshProtos.FromRadio.PayloadVariantCase.LOG_RECORD, - MeshProtos.FromRadio.PayloadVariantCase.REBOOTED, - MeshProtos.FromRadio.PayloadVariantCase.XMODEMPACKET, - MeshProtos.FromRadio.PayloadVariantCase.DEVICEUICONFIG, - MeshProtos.FromRadio.PayloadVariantCase.FILEINFO, - -> { + proto.packet != null || + proto.log_record != null || + proto.rebooted != null || + proto.xmodemPacket != null || + proto.deviceuiConfig != null || + proto.fileInfo != null -> { /* No specialized routing needed here */ } - else -> Logger.d { "Dispatcher ignoring ${proto.payloadVariantCase}" } + else -> Logger.d { "Dispatcher ignoring FromRadio variant" } } } } 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 bae7ad40d..9e82c3cae 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -18,11 +18,11 @@ package com.geeksville.mesh.service import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.util.ignoreException -import com.google.protobuf.ByteString import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import okio.ByteString.Companion.toByteString import org.meshtastic.core.analytics.DataPair import org.meshtastic.core.analytics.platform.PlatformAnalytics import org.meshtastic.core.data.repository.PacketRepository @@ -34,17 +34,17 @@ import org.meshtastic.core.model.Position import org.meshtastic.core.prefs.mesh.MeshPrefs import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.ServiceAction -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.ModuleConfigProtos -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.user +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.OTAMode +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.User import javax.inject.Inject import javax.inject.Singleton -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") @Singleton class MeshActionHandler @Inject @@ -80,10 +80,12 @@ constructor( is ServiceAction.Reaction -> handleReaction(action, myNodeNum) is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum) is ServiceAction.SendContact -> { - commandSender.sendAdmin(myNodeNum) { addContact = action.contact } + commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = action.contact) } } is ServiceAction.GetDeviceMetadata -> { - commandSender.sendAdmin(action.destNum, wantResponse = true) { getDeviceMetadataRequest = true } + commandSender.sendAdmin(action.destNum, wantResponse = true) { + AdminMessage(get_device_metadata_request = true) + } } } } @@ -92,7 +94,11 @@ constructor( private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) { val node = action.node commandSender.sendAdmin(myNodeNum) { - if (node.isFavorite) removeFavoriteNode = node.num else setFavoriteNode = node.num + if (node.isFavorite) { + AdminMessage(remove_favorite_node = node.num) + } else { + AdminMessage(set_favorite_node = node.num) + } } nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite } } @@ -100,14 +106,18 @@ constructor( private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) { val node = action.node commandSender.sendAdmin(myNodeNum) { - if (node.isIgnored) removeIgnoredNode = node.num else setIgnoredNode = node.num + if (node.isIgnored) { + AdminMessage(remove_ignored_node = node.num) + } else { + AdminMessage(set_ignored_node = node.num) + } } nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored } } private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) { val node = action.node - commandSender.sendAdmin(myNodeNum) { toggleMutedNode = node.num } + commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) } nodeManager.updateNodeInfo(node.num) { it.isMuted = !node.isMuted } } @@ -118,8 +128,8 @@ constructor( org.meshtastic.core.model .DataPacket( to = destId, - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - bytes = action.emoji.encodeToByteArray(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + bytes = action.emoji.encodeToByteArray().toByteString(), channel = channel, replyId = action.replyId, wantAck = true, @@ -131,9 +141,13 @@ constructor( } private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) { - val verifiedContact = action.contact.toBuilder().setManuallyVerified(true).build() - commandSender.sendAdmin(myNodeNum) { addContact = verifiedContact } - nodeManager.handleReceivedUser(verifiedContact.nodeNum, verifiedContact.user, manuallyVerified = true) + val verifiedContact = action.contact.copy(manually_verified = true) + commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) } + nodeManager.handleReceivedUser( + verifiedContact.node_num ?: 0, + verifiedContact.user ?: User(), + manuallyVerified = true, + ) } private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) { @@ -158,30 +172,16 @@ constructor( } fun handleSetOwner(u: org.meshtastic.core.model.MeshUser, myNodeNum: Int) { - commandSender.sendAdmin(myNodeNum) { - setOwner = user { - id = u.id - longName = u.longName - shortName = u.shortName - isLicensed = u.isLicensed - } - } - nodeManager.handleReceivedUser( - myNodeNum, - user { - id = u.id - longName = u.longName - shortName = u.shortName - isLicensed = u.isLicensed - }, - ) + val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed) + commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) } + nodeManager.handleReceivedUser(myNodeNum, newUser) } fun handleSend(p: DataPacket, myNodeNum: Int) { commandSender.sendData(p) serviceBroadcasts.broadcastMessageStatus(p) dataHandler.rememberDataPacket(p, myNodeNum, false) - val bytes = p.bytes ?: ByteArray(0) + val bytes = p.bytes ?: okio.ByteString.EMPTY analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) } @@ -200,79 +200,83 @@ constructor( fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) { nodeManager.removeByNodenum(nodeNum) - commandSender.sendAdmin(myNodeNum, requestId) { removeByNodenum = nodeNum } + commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) } } fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) { - val u = MeshProtos.User.parseFrom(payload) - commandSender.sendAdmin(destNum, id) { setOwner = u } + val u = User.ADAPTER.decode(payload) + commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) } } fun handleGetRemoteOwner(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { getOwnerRequest = true } + commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) } } fun handleSetConfig(payload: ByteArray, myNodeNum: Int) { - val c = ConfigProtos.Config.parseFrom(payload) - commandSender.sendAdmin(myNodeNum) { setConfig = c } + val c = Config.ADAPTER.decode(payload) + commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) } } fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) { - val c = ConfigProtos.Config.parseFrom(payload) - commandSender.sendAdmin(destNum, id) { setConfig = c } + val c = Config.ADAPTER.decode(payload) + commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) } } fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { commandSender.sendAdmin(destNum, id, wantResponse = true) { - if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { - getDeviceMetadataRequest = true + if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) { + AdminMessage(get_device_metadata_request = true) } else { - getConfigRequestValue = config + AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config)) } } } fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) { - val c = ModuleConfigProtos.ModuleConfig.parseFrom(payload) - commandSender.sendAdmin(destNum, id) { setModuleConfig = c } + val c = ModuleConfig.ADAPTER.decode(payload) + commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) } } fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { getModuleConfigRequestValue = config } + commandSender.sendAdmin(destNum, id, wantResponse = true) { + AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config)) + } } fun handleSetRingtone(destNum: Int, ringtone: String) { - commandSender.sendAdmin(destNum) { setRingtoneMessage = ringtone } + commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) } } fun handleGetRingtone(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { getRingtoneRequest = true } + commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) } } fun handleSetCannedMessages(destNum: Int, messages: String) { - commandSender.sendAdmin(destNum) { setCannedMessageModuleMessages = messages } + commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) } } fun handleGetCannedMessages(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { getCannedMessageModuleMessagesRequest = true } + commandSender.sendAdmin(destNum, id, wantResponse = true) { + AdminMessage(get_canned_message_module_messages_request = true) + } } fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) { if (payload != null) { - val c = ChannelProtos.Channel.parseFrom(payload) - commandSender.sendAdmin(myNodeNum) { setChannel = c } + val c = Channel.ADAPTER.decode(payload) + commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) } } } fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) { if (payload != null) { - val c = ChannelProtos.Channel.parseFrom(payload) - commandSender.sendAdmin(destNum, id) { setChannel = c } + val c = Channel.ADAPTER.decode(payload) + commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) } } } fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { getChannelRequest = index + 1 } + commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) } } fun handleRequestNeighborInfo(requestId: Int, destNum: Int) { @@ -280,15 +284,15 @@ constructor( } fun handleBeginEditSettings(destNum: Int) { - commandSender.sendAdmin(destNum) { beginEditSettings = true } + commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) } } fun handleCommitEditSettings(destNum: Int) { - commandSender.sendAdmin(destNum) { commitEditSettings = true } + commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) } } fun handleRebootToDfu(destNum: Int) { - commandSender.sendAdmin(destNum) { enterDfuModeRequest = true } + commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) } } fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) { @@ -296,33 +300,32 @@ constructor( } fun handleRequestShutdown(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId) { shutdownSeconds = DEFAULT_REBOOT_DELAY } + commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) } } fun handleRequestReboot(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId) { rebootSeconds = DEFAULT_REBOOT_DELAY } + commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) } } fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - val otaMode = AdminProtos.OTAMode.forNumber(mode) ?: AdminProtos.OTAMode.NO_REBOOT_OTA - val otaEventBuilder = AdminProtos.AdminMessage.OTAEvent.newBuilder() - otaEventBuilder.rebootOtaMode = otaMode - if (hash != null) { - otaEventBuilder.otaHash = ByteString.copyFrom(hash) - } - commandSender.sendAdmin(destNum, requestId) { otaRequest = otaEventBuilder.build() } + val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA + val otaEvent = + AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY) + commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) } } fun handleRequestFactoryReset(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId) { factoryResetDevice = 1 } + commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) } } fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) { - commandSender.sendAdmin(destNum, requestId) { nodedbReset = preserveFavorites } + commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) } } fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId, wantResponse = true) { getDeviceConnectionStatusRequest = true } + commandSender.sendAdmin(destNum, requestId, wantResponse = true) { + AdminMessage(get_device_connection_status_request = true) + } } fun handleUpdateLastAddress(deviceAddr: String?) { 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 7f9246eb3..df45ab9a6 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -19,29 +19,31 @@ package com.geeksville.mesh.service import android.os.RemoteException import androidx.annotation.VisibleForTesting import co.touchlab.kermit.Logger -import com.google.protobuf.ByteString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import okio.ByteString +import okio.ByteString.Companion.toByteString 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.service.ConnectionState -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.AppOnlyProtos.ChannelSet -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.paxcount -import org.meshtastic.proto.position -import org.meshtastic.proto.telemetry +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.Constants +import org.meshtastic.proto.Data +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.Neighbor +import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicLong @@ -68,17 +70,13 @@ constructor( val tracerouteStartTimes = ConcurrentHashMap() val neighborInfoStartTimes = ConcurrentHashMap() - private val localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) - private val channelSet = MutableStateFlow(ChannelSet.getDefaultInstance()) + private val localConfig = MutableStateFlow(LocalConfig()) + private val channelSet = MutableStateFlow(ChannelSet()) - @Volatile var lastNeighborInfo: MeshProtos.NeighborInfo? = null + @Volatile var lastNeighborInfo: NeighborInfo? = null private val rememberDataType = - setOf( - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - Portnums.PortNum.ALERT_APP_VALUE, - Portnums.PortNum.WAYPOINT_APP_VALUE, - ) + setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.ALERT_APP.value, PortNum.WAYPOINT_APP.value) fun start(scope: CoroutineScope) { this.scope = scope @@ -100,7 +98,7 @@ constructor( sessionPasskey.set(key) } - private fun computeHopLimit(): Int = localConfig.value.lora.hopLimit.takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT + private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT private fun getAdminChannelIndex(toNum: Int): Int { val myNum = nodeManager?.myNodeNum ?: return 0 @@ -112,7 +110,7 @@ constructor( myNum == toNum -> 0 myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX else -> - channelSet.value.settingsList + channelSet.value.settings .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) } .coerceAtLeast(0) } @@ -121,11 +119,22 @@ constructor( fun sendData(p: DataPacket) { if (p.id == 0) p.id = generatePacketId() - val bytes = p.bytes ?: ByteArray(0) + val bytes = p.bytes ?: ByteString.EMPTY require(p.dataType != 0) { "Port numbers must be non-zero!" } - if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN_VALUE) { + + // Use Wire extension for accurate size validation + val data = + Data( + portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP, + payload = bytes, + reply_id = p.replyId ?: 0, + emoji = p.emoji, + ) + + if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) { + val actualSize = Data.ADAPTER.encodedSize(data) p.status = MessageStatus.ERROR - throw RemoteException("Message too long") + throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})") } else { p.status = MessageStatus.QUEUED } @@ -144,17 +153,20 @@ constructor( private fun sendNow(p: DataPacket) { val meshPacket = - newMeshPacketTo(p.to ?: DataPacket.ID_BROADCAST).buildMeshPacket( + buildMeshPacket( + to = resolveNodeNum(p.to ?: DataPacket.ID_BROADCAST), id = p.id, wantAck = p.wantAck, hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(), channel = p.channel, - ) { - portnumValue = p.dataType - payload = ByteString.copyFrom(p.bytes ?: ByteArray(0)) - p.replyId?.let { if (it != 0) replyId = it } - if (p.emoji != 0) emoji = p.emoji - } + decoded = + Data( + portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP, + payload = p.bytes ?: ByteString.EMPTY, + reply_id = p.replyId ?: 0, + emoji = p.emoji, + ), + ) p.time = System.currentTimeMillis() packetHandler?.sendToRadio(meshPacket) } @@ -182,64 +194,73 @@ constructor( destNum: Int, requestId: Int = generatePacketId(), wantResponse: Boolean = false, - initFn: AdminProtos.AdminMessage.Builder.() -> Unit, + initFn: () -> AdminMessage, ) { + val adminMsg = initFn().copy(session_passkey = sessionPasskey.get()) val packet = - newMeshPacketTo(destNum).buildAdminPacket(id = requestId, wantResponse = wantResponse, initFn = initFn) + buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg) packetHandler?.sendToRadio(packet) } - fun sendPosition(pos: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) { + fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) { val myNum = nodeManager?.myNodeNum ?: return val idNum = destNum ?: myNum - Logger.d { "Sending our position/time to=$idNum ${Position(pos)}" } + Logger.d { "Sending our position/time to=$idNum $pos" } - if (!localConfig.value.position.fixedPosition) { + if (localConfig.value.position?.fixed_position != true) { nodeManager.handleReceivedPosition(myNum, myNum, pos) } packetHandler?.sendToRadio( - newMeshPacketTo(idNum).buildMeshPacket( + buildMeshPacket( + to = idNum, channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, priority = MeshPacket.Priority.BACKGROUND, - ) { - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = pos.toByteString() - this.wantResponse = wantResponse - }, + decoded = + Data( + portnum = PortNum.POSITION_APP, + payload = pos.encode().toByteString(), + want_response = wantResponse, + ), + ), ) } fun requestPosition(destNum: Int, currentPosition: Position) { - val meshPosition = position { - latitudeI = Position.degI(currentPosition.latitude) - longitudeI = Position.degI(currentPosition.longitude) - altitude = currentPosition.altitude - time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt() - } + val meshPosition = + org.meshtastic.proto.Position( + latitude_i = Position.degI(currentPosition.latitude), + longitude_i = Position.degI(currentPosition.longitude), + altitude = currentPosition.altitude, + time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt(), + ) packetHandler?.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( + buildMeshPacket( + to = destNum, channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, priority = MeshPacket.Priority.BACKGROUND, - ) { - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = meshPosition.toByteString() - wantResponse = true - }, + decoded = + Data( + portnum = PortNum.POSITION_APP, + payload = meshPosition.encode().toByteString(), + want_response = true, + ), + ), ) } fun setFixedPosition(destNum: Int, pos: Position) { - val meshPos = position { - latitudeI = Position.degI(pos.latitude) - longitudeI = Position.degI(pos.longitude) - altitude = pos.altitude - } + val meshPos = + org.meshtastic.proto.Position( + latitude_i = Position.degI(pos.latitude), + longitude_i = Position.degI(pos.longitude), + altitude = pos.altitude, + ) sendAdmin(destNum) { if (pos != Position(0.0, 0.0, 0)) { - setFixedPosition = meshPos + AdminMessage(set_fixed_position = meshPos) } else { - removeFixedPosition = true + AdminMessage(remove_fixed_position = true) } } nodeManager?.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos) @@ -249,64 +270,67 @@ constructor( val myNum = nodeManager?.myNodeNum ?: return val myNode = nodeManager.getOrCreateNodeInfo(myNum) packetHandler?.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket(channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0) { - portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE - wantResponse = true - payload = myNode.user.toByteString() - }, + buildMeshPacket( + to = destNum, + channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, + decoded = + Data( + portnum = PortNum.NODEINFO_APP, + want_response = true, + payload = myNode.user.encode().toByteString(), + ), + ), ) } fun requestTraceroute(requestId: Int, destNum: Int) { tracerouteStartTimes[requestId] = System.currentTimeMillis() packetHandler?.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( + buildMeshPacket( + to = destNum, wantAck = true, id = requestId, channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, - ) { - portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE - wantResponse = true - }, + decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true), + ), ) } fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE - val portNum: Portnums.PortNum + val portNum: PortNum val payloadBytes: ByteString if (type == TelemetryType.PAX) { - portNum = Portnums.PortNum.PAXCOUNTER_APP - payloadBytes = paxcount {}.toByteString() + portNum = PortNum.PAXCOUNTER_APP + payloadBytes = org.meshtastic.proto.Paxcount().encode().toByteString() } else { - portNum = Portnums.PortNum.TELEMETRY_APP + portNum = PortNum.TELEMETRY_APP payloadBytes = - telemetry { - when (type) { - TelemetryType.ENVIRONMENT -> - environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance() - TelemetryType.AIR_QUALITY -> - airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance() - TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance() - TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance() - TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance() - TelemetryType.HOST -> hostMetrics = TelemetryProtos.HostMetrics.getDefaultInstance() - } - } + Telemetry( + device_metrics = + if (type == TelemetryType.DEVICE) org.meshtastic.proto.DeviceMetrics() else null, + environment_metrics = + if (type == TelemetryType.ENVIRONMENT) org.meshtastic.proto.EnvironmentMetrics() else null, + air_quality_metrics = + if (type == TelemetryType.AIR_QUALITY) org.meshtastic.proto.AirQualityMetrics() else null, + power_metrics = if (type == TelemetryType.POWER) org.meshtastic.proto.PowerMetrics() else null, + local_stats = + if (type == TelemetryType.LOCAL_STATS) org.meshtastic.proto.LocalStats() else null, + host_metrics = if (type == TelemetryType.HOST) org.meshtastic.proto.HostMetrics() else null, + ) + .encode() .toByteString() } packetHandler?.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( + buildMeshPacket( + to = destNum, id = requestId, channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, - ) { - portnumValue = portNum.number - payload = payloadBytes - wantResponse = true - }, + decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true), + ), ) } @@ -319,44 +343,47 @@ constructor( ?: run { val oneHour = 1.hours.inWholeMinutes.toInt() Logger.d { "No stored neighbor info from connected radio, sending dummy data" } - MeshProtos.NeighborInfo.newBuilder() - .setNodeId(myNum) - .setLastSentById(myNum) - .setNodeBroadcastIntervalSecs(oneHour) - .addNeighbors( - MeshProtos.Neighbor.newBuilder() - .setNodeId(0) // Dummy node ID that can be intercepted - .setSnr(0f) - .setLastRxTime((System.currentTimeMillis() / TIME_MS_TO_S).toInt()) - .setNodeBroadcastIntervalSecs(oneHour) - .build(), - ) - .build() + NeighborInfo( + node_id = myNum, + last_sent_by_id = myNum, + node_broadcast_interval_secs = oneHour, + neighbors = + listOf( + Neighbor( + node_id = 0, // Dummy node ID that can be intercepted + snr = 0f, + last_rx_time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt(), + node_broadcast_interval_secs = oneHour, + ), + ), + ) } // Send the neighbor info from our connected radio to ourselves (simulated) packetHandler?.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( + buildMeshPacket( + to = destNum, wantAck = true, id = requestId, channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, - ) { - portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE - payload = neighborInfoToSend.toByteString() - wantResponse = true - }, + decoded = + Data( + portnum = PortNum.NEIGHBORINFO_APP, + payload = neighborInfoToSend.encode().toByteString(), + want_response = true, + ), + ), ) } else { // Send request to remote packetHandler?.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( + buildMeshPacket( + to = destNum, wantAck = true, id = requestId, channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, - ) { - portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE - wantResponse = true - }, + decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true), + ), ) } } @@ -377,59 +404,60 @@ constructor( } } - private fun newMeshPacketTo(toId: String): MeshPacket.Builder { - val destNum = resolveNodeNum(toId) - return newMeshPacketTo(destNum) - } - - private fun newMeshPacketTo(destNum: Int): MeshPacket.Builder = MeshPacket.newBuilder().apply { to = destNum } - - private fun MeshPacket.Builder.buildMeshPacket( + private fun buildMeshPacket( + to: Int, wantAck: Boolean = false, id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one hopLimit: Int = 0, channel: Int = 0, priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, - initFn: MeshProtos.Data.Builder.() -> Unit, + decoded: Data, ): MeshPacket { - this.id = id - this.wantAck = wantAck val actualHopLimit = if (hopLimit > 0) hopLimit else computeHopLimit() - this.hopLimit = actualHopLimit - this.hopStart = actualHopLimit - this.priority = priority + + var pkiEncrypted = false + var publicKey: ByteString = ByteString.EMPTY + var actualChannel = channel if (channel == DataPacket.PKC_CHANNEL_INDEX) { pkiEncrypted = true - nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.publicKey?.let { publicKey = it } - } else { - this.channel = channel + publicKey = nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.public_key ?: ByteString.EMPTY + actualChannel = 0 } - this.decoded = MeshProtos.Data.newBuilder().apply(initFn).build() - return build() + return MeshPacket( + to = to, + id = id, + want_ack = wantAck, + hop_limit = actualHopLimit, + hop_start = actualHopLimit, + priority = priority, + pki_encrypted = pkiEncrypted, + public_key = publicKey, + channel = actualChannel, + decoded = decoded, + ) } - private fun MeshPacket.Builder.buildAdminPacket( + private fun buildAdminPacket( + to: Int, id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one wantResponse: Boolean = false, - initFn: AdminProtos.AdminMessage.Builder.() -> Unit, + adminMessage: AdminMessage, ): MeshPacket = buildMeshPacket( + to = to, id = id, wantAck = true, channel = getAdminChannelIndex(to), priority = MeshPacket.Priority.RELIABLE, - ) { - this.wantResponse = wantResponse - portnumValue = Portnums.PortNum.ADMIN_APP_VALUE - payload = - AdminProtos.AdminMessage.newBuilder() - .apply(initFn) - .setSessionPasskey(sessionPasskey.get()) - .build() - .toByteString() - } + decoded = + Data( + want_response = wantResponse, + portnum = PortNum.ADMIN_APP, + payload = adminMessage.encode().toByteString(), + ), + ) companion object { private const val PACKET_ID_MASK = 0xffffffffL 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 af4305928..9e4f2936e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt @@ -28,7 +28,12 @@ import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.service.ConnectionState -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.ToRadio import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -57,11 +62,11 @@ constructor( this.scope = scope } - private val newNodes = mutableListOf() + private val newNodes = mutableListOf() val newNodeCount: Int get() = newNodes.size - private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null + private var rawMyNodeInfo: MyNodeInfo? = null private var newMyNodeInfo: MyNodeEntity? = null private var myNodeInfo: MyNodeEntity? = null @@ -92,9 +97,7 @@ constructor( private fun sendHeartbeat() { try { - packetHandler.sendToRadio( - MeshProtos.ToRadio.newBuilder().apply { heartbeat = MeshProtos.Heartbeat.getDefaultInstance() }, - ) + packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat())) Logger.d { "Heartbeat sent between nonce stages" } } catch (ex: IOException) { Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" } @@ -127,10 +130,10 @@ constructor( analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown") } - fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { - Logger.i { "MyNodeInfo received: ${myInfo.myNodeNum}" } + fun handleMyInfo(myInfo: MyNodeInfo) { + Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" } rawMyNodeInfo = myInfo - nodeManager.myNodeNum = myInfo.myNodeNum + nodeManager.myNodeNum = myInfo.my_node_num ?: 0 regenMyNodeInfo() scope.handledLaunch { @@ -140,42 +143,42 @@ constructor( } } - fun handleLocalMetadata(metadata: MeshProtos.DeviceMetadata) { + fun handleLocalMetadata(metadata: DeviceMetadata) { Logger.i { "Local Metadata received" } regenMyNodeInfo(metadata) } - fun handleNodeInfo(info: MeshProtos.NodeInfo) { + fun handleNodeInfo(info: NodeInfo) { newNodes.add(info) } - private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata? = MeshProtos.DeviceMetadata.getDefaultInstance()) { + private fun regenMyNodeInfo(metadata: DeviceMetadata? = DeviceMetadata()) { val myInfo = rawMyNodeInfo if (myInfo != null) { val mi = with(myInfo) { MyNodeEntity( - myNodeNum = myNodeNum, + myNodeNum = my_node_num ?: 0, model = - when (val hwModel = metadata?.hwModel) { + when (val hwModel = metadata?.hw_model) { null, - MeshProtos.HardwareModel.UNSET, + HardwareModel.UNSET, -> null else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() }, - firmwareVersion = metadata?.firmwareVersion, + firmwareVersion = metadata?.firmware_version, couldUpdate = false, shouldUpdate = false, currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL, messageTimeoutMsec = 300000, - minAppVersion = minAppVersion, + minAppVersion = min_app_version ?: 0, maxChannels = 8, hasWifi = metadata?.hasWifi == true, - deviceId = deviceId.toStringUtf8(), - pioEnv = if (myInfo.pioEnv.isNullOrEmpty()) null else myInfo.pioEnv, + deviceId = device_id?.utf8() ?: "", + pioEnv = if (myInfo.pio_env.isNullOrEmpty()) null else myInfo.pio_env, ) } - if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) { + if (metadata != null && metadata != DeviceMetadata()) { scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) } } newMyNodeInfo = mi 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 ea6fac511..3332a221d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt @@ -26,11 +26,11 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig -import org.meshtastic.proto.ModuleConfigProtos +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig import javax.inject.Inject import javax.inject.Singleton @@ -44,15 +44,12 @@ constructor( ) { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) + private val _localConfig = MutableStateFlow(LocalConfig()) val localConfig = _localConfig.asStateFlow() - private val _moduleConfig = MutableStateFlow(LocalModuleConfig.getDefaultInstance()) + private val _moduleConfig = MutableStateFlow(LocalModuleConfig()) val moduleConfig = _moduleConfig.asStateFlow() - private val configTotal = ConfigProtos.Config.getDescriptor().fields.size - private val moduleTotal = ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size - fun start(scope: CoroutineScope) { this.scope = scope radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope) @@ -60,28 +57,27 @@ constructor( radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope) } - fun handleDeviceConfig(config: ConfigProtos.Config) { + fun handleDeviceConfig(config: Config) { scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } - val configCount = _localConfig.value.allFields.size - serviceRepository.setStatusMessage("Device config ($configCount / $configTotal)") + serviceRepository.setStatusMessage("Device config received") } - fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { + fun handleModuleConfig(config: ModuleConfig) { scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } - val moduleCount = _moduleConfig.value.allFields.size - serviceRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)") + serviceRepository.setStatusMessage("Module config received") } - fun handleChannel(ch: ChannelProtos.Channel) { + fun handleChannel(ch: Channel) { // We always want to save channel settings we receive from the radio scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } // Update status message if we have node info, otherwise use a generic one val mi = nodeManager.getMyNodeInfo() + val index = ch.index ?: 0 if (mi != null) { - serviceRepository.setStatusMessage("Channels (${ch.index + 1} / ${mi.maxChannels})") + serviceRepository.setStatusMessage("Channels (${index + 1} / ${mi.maxChannels})") } else { - serviceRepository.setStatusMessage("Channels (${ch.index + 1})") + serviceRepository.setStatusMessage("Channels (${index + 1})") } } } 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 926db854b..d5a6d017d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt @@ -42,9 +42,10 @@ 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.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos.ToRadio -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Config +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.ToRadio import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -101,8 +102,8 @@ constructor( private fun onRadioConnectionState(newState: ConnectionState) { scope.handledLaunch { val localConfig = radioConfigRepository.localConfigFlow.first() - val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER - val lsEnabled = localConfig.power.isPowerSaving || isRouter + val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER + val lsEnabled = localConfig.power?.is_power_saving == true || isRouter val effectiveState = when (newState) { @@ -161,7 +162,7 @@ constructor( scope.handledLaunch { try { val localConfig = radioConfigRepository.localConfigFlow.first() - val timeout = (localConfig.power?.lsSecs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS + val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS Logger.d { "Waiting for sleeping device, timeout=$timeout secs" } delay(timeout.seconds) Logger.w { "Device timeout out, setting disconnected" } @@ -191,11 +192,11 @@ constructor( } fun startConfigOnly() { - packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = CONFIG_ONLY_NONCE }) + packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) } fun startNodeInfoOnly() { - packetHandler.sendToRadio(ToRadio.newBuilder().apply { wantConfigId = NODE_INFO_NONCE }) + packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) } fun onHasSettings() { @@ -204,7 +205,11 @@ constructor( // Start MQTT if enabled scope.handledLaunch { val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - mqttManager.start(scope, moduleConfig.mqtt.enabled, moduleConfig.mqtt.proxyToClientEnabled) + mqttManager.start( + scope, + moduleConfig.mqtt?.enabled == true, + moduleConfig.mqtt?.proxy_to_client_enabled == true, + ) } reportConnection() @@ -213,12 +218,14 @@ constructor( // Request history scope.handledLaunch { val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - historyManager.requestHistoryReplay("onHasSettings", myNodeNum, moduleConfig.storeForward, "Unknown") + moduleConfig.store_forward?.let { + historyManager.requestHistoryReplay("onHasSettings", myNodeNum, it, "Unknown") + } } // Set time commandSender.sendAdmin(myNodeNum) { - setTimeOnly = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt() + AdminMessage(set_time_only = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()) } updateStatusNotification() } @@ -234,11 +241,11 @@ constructor( ) } - fun updateTelemetry(telemetry: TelemetryProtos.Telemetry) { + fun updateTelemetry(telemetry: Telemetry) { updateStatusNotification(telemetry) } - fun updateStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification { + fun updateStatusNotification(telemetry: Telemetry? = null): Notification { val summary = when (connectionStateHolder.connectionState.value) { is ConnectionState.Connected -> 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 e6f0a2204..5f6d3ca35 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -21,7 +21,6 @@ import co.touchlab.kermit.Logger import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.repository.radio.InterfaceId -import com.google.protobuf.InvalidProtocolBufferException import com.meshtastic.core.strings.getString import dagger.Lazy import kotlinx.coroutines.CoroutineScope @@ -29,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob 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.data.repository.PacketRepository @@ -38,6 +38,8 @@ import org.meshtastic.core.database.entity.ReactionEntity 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.toOneLiner import org.meshtastic.core.prefs.mesh.MeshPrefs import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.RetryEvent @@ -48,20 +50,25 @@ import org.meshtastic.core.strings.critical_alert import org.meshtastic.core.strings.error_duty_cycle import org.meshtastic.core.strings.unknown_username import org.meshtastic.core.strings.waypoint_received -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.StoreAndForwardProtos -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.copy +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.Routing +import org.meshtastic.proto.StatusMessage +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import org.meshtastic.proto.Waypoint +import java.io.IOException import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds -@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod") @Singleton class MeshDataHandler @Inject @@ -93,10 +100,10 @@ constructor( private val rememberDataType = setOf( - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - Portnums.PortNum.ALERT_APP_VALUE, - Portnums.PortNum.WAYPOINT_APP_VALUE, - Portnums.PortNum.NODE_STATUS_APP_VALUE, + PortNum.TEXT_MESSAGE_APP.value, + PortNum.ALERT_APP.value, + PortNum.WAYPOINT_APP.value, + PortNum.NODE_STATUS_APP.value, ) fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) { @@ -121,14 +128,15 @@ constructor( logInsertJob: Job?, ): Boolean { var shouldBroadcast = !fromUs - when (packet.decoded.portnumValue) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleTextMessage(packet, dataPacket, myNodeNum) - Portnums.PortNum.NODE_STATUS_APP_VALUE -> handleNodeStatus(packet, dataPacket, myNodeNum) - Portnums.PortNum.ALERT_APP_VALUE -> rememberDataPacket(dataPacket, myNodeNum) - Portnums.PortNum.WAYPOINT_APP_VALUE -> handleWaypoint(packet, dataPacket, myNodeNum) - Portnums.PortNum.POSITION_APP_VALUE -> handlePosition(packet, dataPacket, myNodeNum) - Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromUs) handleNodeInfo(packet) - Portnums.PortNum.TELEMETRY_APP_VALUE -> handleTelemetry(packet, dataPacket, myNodeNum) + val decoded = packet.decoded ?: return shouldBroadcast + when (decoded.portnum) { + PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum) + PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum) + PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum) + PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum) + PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum) + PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet) + PortNum.TELEMETRY_APP -> handleTelemetry(packet, dataPacket, myNodeNum) else -> shouldBroadcast = handleSpecializedDataPacket(packet, dataPacket, myNodeNum, logUuid, logInsertJob) } return shouldBroadcast @@ -142,189 +150,192 @@ constructor( logInsertJob: Job?, ): Boolean { var shouldBroadcast = false - when (packet.decoded.portnumValue) { - Portnums.PortNum.TRACEROUTE_APP_VALUE -> { + val decoded = packet.decoded ?: return shouldBroadcast + when (decoded.portnum) { + PortNum.TRACEROUTE_APP -> { tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob) - shouldBroadcast = false } - Portnums.PortNum.ROUTING_APP_VALUE -> { + PortNum.ROUTING_APP -> { handleRouting(packet, dataPacket) shouldBroadcast = true } - Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { + PortNum.PAXCOUNTER_APP -> { handlePaxCounter(packet) - shouldBroadcast = false } - Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { + PortNum.STORE_FORWARD_APP -> { handleStoreAndForward(packet, dataPacket, myNodeNum) - shouldBroadcast = false } - Portnums.PortNum.STORE_FORWARD_PLUSPLUS_APP_VALUE -> { + PortNum.STORE_FORWARD_PLUSPLUS_APP -> { handleStoreForwardPlusPlus(packet) - shouldBroadcast = false } - Portnums.PortNum.ADMIN_APP_VALUE -> { + PortNum.ADMIN_APP -> { handleAdminMessage(packet, myNodeNum) - shouldBroadcast = false } - Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> { + PortNum.NEIGHBORINFO_APP -> { neighborInfoHandler.handleNeighborInfo(packet) shouldBroadcast = true } - Portnums.PortNum.RANGE_TEST_APP_VALUE, - Portnums.PortNum.DETECTION_SENSOR_APP_VALUE, + PortNum.RANGE_TEST_APP, + PortNum.DETECTION_SENSOR_APP, -> { handleRangeTest(dataPacket, myNodeNum) - shouldBroadcast = false } + else -> {} } return shouldBroadcast } private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) { - val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + val u = dataPacket.copy(dataType = PortNum.TEXT_MESSAGE_APP.value) rememberDataPacket(u, myNodeNum) } private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val u = StoreAndForwardProtos.StoreAndForward.parseFrom(packet.decoded.payload) + val payload = packet.decoded?.payload ?: return + val u = StoreAndForward.ADAPTER.decode(payload) handleReceivedStoreAndForward(dataPacket, u, myNodeNum) } @Suppress("LongMethod") private fun handleStoreForwardPlusPlus(packet: MeshPacket) { + val payload = packet.decoded?.payload ?: return val sfpp = try { - MeshProtos.StoreForwardPlusPlus.parseFrom(packet.decoded.payload) - } catch (e: InvalidProtocolBufferException) { + StoreForwardPlusPlus.ADAPTER.decode(payload) + } catch (e: IOException) { Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" } return } Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" } - when (sfpp.sfppMessageType) { - MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, - MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, - MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + when (sfpp.sfpp_message_type) { + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, -> { - val isFragment = sfpp.sfppMessageType != MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE + val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE // If it has a commit hash, it's already on the chain (Confirmed) // Otherwise it's still being routed via SF++ (Routing) - val status = if (sfpp.commitHash.isEmpty) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED + val status = + if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED // Prefer a full 16-byte hash calculated from the message bytes if available // But only if it's NOT a fragment, otherwise the calculated hash would be wrong val hash = when { - !sfpp.messageHash.isEmpty -> sfpp.messageHash.toByteArray() - !isFragment && !sfpp.message.isEmpty -> { + sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray() + !isFragment && sfpp.message.size != 0 -> { SfppHasher.computeMessageHash( encryptedPayload = sfpp.message.toByteArray(), // Map 0 back to NODENUM_BROADCAST to match firmware hash calculation to = - if (sfpp.encapsulatedTo == 0) DataPacket.NODENUM_BROADCAST else sfpp.encapsulatedTo, - from = sfpp.encapsulatedFrom, - id = sfpp.encapsulatedId, + if (sfpp.encapsulated_to == 0) { + DataPacket.NODENUM_BROADCAST + } else { + sfpp.encapsulated_to + }, + from = sfpp.encapsulated_from, + id = sfpp.encapsulated_id, ) } else -> null } ?: return Logger.d { - "SFPP updateStatus: packetId=${sfpp.encapsulatedId} from=${sfpp.encapsulatedFrom} " + - "to=${sfpp.encapsulatedTo} myNodeNum=${nodeManager.myNodeNum} status=$status" + "SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " + + "to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status" } scope.handledLaunch { packetRepository .get() .updateSFPPStatus( - packetId = sfpp.encapsulatedId, - from = sfpp.encapsulatedFrom, - to = sfpp.encapsulatedTo, + packetId = sfpp.encapsulated_id, + from = sfpp.encapsulated_from, + to = sfpp.encapsulated_to, hash = hash, status = status, - rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL, - myNodeNum = nodeManager.myNodeNum, + rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, + myNodeNum = nodeManager.myNodeNum ?: 0, ) - serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulatedId, status) + serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status) } } - MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> { + StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> { scope.handledLaunch { - packetRepository - .get() - .updateSFPPStatusByHash( - hash = sfpp.messageHash.toByteArray(), - status = MessageStatus.SFPP_CONFIRMED, - rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL, - ) + sfpp.message_hash.let { + packetRepository + .get() + .updateSFPPStatusByHash( + hash = it.toByteArray(), + status = MessageStatus.SFPP_CONFIRMED, + rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, + ) + } } } - MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> { + StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> { Logger.i { "SF++: Node ${packet.from} is querying chain status" } } - MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> { + StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> { Logger.i { "SF++: Node ${packet.from} is requesting links" } } - - else -> {} } } private fun handlePaxCounter(packet: MeshPacket) { - val p = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload) + val payload = packet.decoded?.payload ?: return + val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return nodeManager.handleReceivedPaxcounter(packet.from, p) } private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val p = MeshProtos.Position.parseFrom(packet.decoded.payload) + val payload = packet.decoded?.payload ?: return + val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return + Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" } nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time) } private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val u = MeshProtos.Waypoint.parseFrom(packet.decoded.payload) - if (u.lockedTo != 0 && u.lockedTo != packet.from) return + val payload = packet.decoded?.payload ?: return + val u = Waypoint.ADAPTER.decode(payload) + if (u.locked_to != 0 && u.locked_to != packet.from) return val currentSecond = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt() rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond) } private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) { - val u = AdminProtos.AdminMessage.parseFrom(packet.decoded.payload) - commandSender.setSessionPasskey(u.sessionPasskey) + val payload = packet.decoded?.payload ?: return + val u = AdminMessage.ADAPTER.decode(payload) + u.session_passkey.let { commandSender.setSessionPasskey(it) } - if (packet.from == myNodeNum) { - when (u.payloadVariantCase) { - AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> - configHandler.handleDeviceConfig(u.getConfigResponse) - - AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> - configHandler.handleChannel(u.getChannelResponse) - - else -> {} - } + val fromNum = packet.from + if (fromNum == myNodeNum) { + u.get_config_response?.let { configHandler.handleDeviceConfig(it) } + u.get_channel_response?.let { configHandler.handleChannel(it) } } - if (u.payloadVariantCase == AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE) { - if (packet.from == myNodeNum) { - configFlowManager.handleLocalMetadata(u.getDeviceMetadataResponse) + u.get_device_metadata_response?.let { metadata -> + if (fromNum == myNodeNum) { + configFlowManager.handleLocalMetadata(metadata) } else { - nodeManager.insertMetadata(packet.from, u.getDeviceMetadataResponse) + nodeManager.insertMetadata(fromNum, metadata) } } } private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - if (packet.decoded.replyId != 0 && packet.decoded.emoji != 0) { + val decoded = packet.decoded ?: return + if (decoded.reply_id != 0 && decoded.emoji != 0) { rememberReaction(packet) } else { rememberDataPacket(dataPacket, myNodeNum) @@ -332,25 +343,28 @@ constructor( } private fun handleNodeInfo(packet: MeshPacket) { + val payload = packet.decoded?.payload ?: return val u = - MeshProtos.User.parseFrom(packet.decoded.payload).copy { - if (isLicensed) clearPublicKey() - if (packet.viaMqtt) longName = "$longName (MQTT)" - } + User.ADAPTER.decode(payload) + .let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it } + .let { if (packet.via_mqtt == true) it.copy(long_name = "${it.long_name} (MQTT)") else it } nodeManager.handleReceivedUser(packet.from, u, packet.channel) } private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val s = MeshProtos.StatusMessage.parseFrom(packet.decoded.payload) + val payload = packet.decoded?.payload ?: return + val s = StatusMessage.ADAPTER.decodeOrNull(payload, Logger) ?: return nodeManager.handleReceivedNodeStatus(packet.from, s) rememberDataPacket(dataPacket, myNodeNum) } private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + val payload = packet.decoded?.payload ?: return val t = - TelemetryProtos.Telemetry.parseFrom(packet.decoded.payload).copy { - if (time == 0) time = (dataPacket.time.milliseconds.inWholeSeconds).toInt() + (Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let { + if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it } + Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" } val fromNum = packet.from val isRemote = (fromNum != myNodeNum) if (!isRemote) { @@ -358,13 +372,16 @@ constructor( } nodeManager.updateNodeInfo(fromNum) { nodeEntity -> + val metrics = t.device_metrics + val environment = t.environment_metrics + val power = t.power_metrics when { - t.hasDeviceMetrics() -> { + metrics != null -> { nodeEntity.deviceTelemetry = t if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) { if ( - t.deviceMetrics.voltage > BATTERY_PERCENT_UNSUPPORTED && - t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_LOW_THRESHOLD + (metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED && + (metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD ) { if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) { serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote) @@ -378,24 +395,26 @@ constructor( } } - t.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = t - t.hasPowerMetrics() -> nodeEntity.powerTelemetry = t + environment != null -> nodeEntity.environmentTelemetry = t + power != null -> nodeEntity.powerTelemetry = t } } } - private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry, myNodeNum: Int): Boolean { + private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean { val isRemote = (fromNum != myNodeNum) var shouldDisplay = false var forceDisplay = false + val metrics = t.device_metrics ?: return false + val batteryLevel = metrics.battery_level ?: 0 when { - t.deviceMetrics.batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> { + batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> { shouldDisplay = true forceDisplay = true } - t.deviceMetrics.batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true - t.deviceMetrics.batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true + batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true + batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true isRemote -> shouldDisplay = true } @@ -411,31 +430,32 @@ constructor( } private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) { - val r = MeshProtos.Routing.parseFrom(packet.decoded.payload) - if (r.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { + val payload = packet.decoded?.payload ?: return + val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return + if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) { serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle)) } handleAckNak( - packet.decoded.requestId, + packet.decoded?.request_id ?: 0, dataMapper.toNodeID(packet.from), - r.errorReasonValue, + r.error_reason?.value ?: 0, dataPacket.relayNode, ) - packetHandler.removeResponse(packet.decoded.requestId, complete = true) + packet.decoded?.request_id?.let { packetHandler.removeResponse(it, complete = true) } } @Suppress("CyclomaticComplexMethod", "LongMethod") private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) { scope.handledLaunch { - val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE + val isAck = routingError == Routing.Error.NONE.value val p = packetRepository.get().getPacketById(requestId) val reaction = packetRepository.get().getReactionByPacketId(requestId) - val isMaxRetransmit = routingError == MeshProtos.Routing.Error.MAX_RETRANSMIT_VALUE + val isMaxRetransmit = routingError == Routing.Error.MAX_RETRANSMIT.value val shouldRetry = isMaxRetransmit && p != null && - p.port_num == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE && + p.port_num == PortNum.TEXT_MESSAGE_APP.value && (p.data.from == DataPacket.ID_LOCAL || p.data.from == nodeManager.getMyId()) && p.data.retryCount < MAX_RETRY_ATTEMPTS @@ -482,7 +502,7 @@ constructor( relayNode = null, ) val updatedPacket = - p.copy(packetId = newId, data = updatedData, routingError = MeshProtos.Routing.Error.NONE_VALUE) + p.copy(packetId = newId, data = updatedData, routingError = Routing.Error.NONE.value) packetRepository.get().update(updatedPacket) Logger.w { "[ackNak] retrying req=$requestId newId=$newId retry=$newRetryCount" } @@ -496,7 +516,7 @@ constructor( return@handledLaunch } - if (shouldRetryReaction && reaction != null) { + if (shouldRetryReaction) { val newRetryCount = reaction.retryCount + 1 // Emit retry event to UI and wait for user response @@ -519,8 +539,8 @@ constructor( DataPacket( to = reaction.to, channel = reaction.channel, - bytes = reaction.emoji.toByteArray(Charsets.UTF_8), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = reaction.emoji.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, replyId = reaction.replyId, wantAck = true, emoji = reaction.emoji.codePointAt(0), @@ -534,7 +554,7 @@ constructor( status = MessageStatus.QUEUED, retryCount = newRetryCount, relayNode = null, - routingError = MeshProtos.Routing.Error.NONE_VALUE, + routingError = Routing.Error.NONE.value, ) packetRepository.get().updateReaction(updatedReaction) @@ -579,59 +599,53 @@ constructor( } } - private fun handleReceivedStoreAndForward( - dataPacket: DataPacket, - s: StoreAndForwardProtos.StoreAndForward, - myNodeNum: Int, - ) { - Logger.d { "StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}" } + private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) { + Logger.d { "StoreAndForward: variant from ${dataPacket.from}" } val transport = currentTransport() - val isHistory = s.variantCase == StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY - val lastRequest = if (isHistory) s.history.lastRequest else 0 + val h = s.history + val lastRequest = h?.last_request ?: 0 val baseContext = "transport=$transport from=${dataPacket.from}" - historyLog { "rxStoreForward $baseContext variant=${s.variantCase} rr=${s.rr} lastRequest=$lastRequest" } - when (s.variantCase) { - StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { + historyLog { "rxStoreForward $baseContext lastRequest=$lastRequest" } + when { + s.stats != null -> { val text = s.stats.toString() val u = dataPacket.copy( - bytes = text.encodeToByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = text.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, ) rememberDataPacket(u, myNodeNum) } - StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { - val h = s.history + h != null -> { @Suppress("MaxLineLength") historyLog(Log.DEBUG) { - "routerHistory $baseContext messages=${h.historyMessages} window=${h.window} lastReq=${h.lastRequest}" + "routerHistory $baseContext messages=${h.history_messages} window=${h.window} lastReq=${h.last_request}" } val text = - "Total messages: ${h.historyMessages}\n" + + "Total messages: ${h.history_messages}\n" + "History window: ${h.window.milliseconds.inWholeMinutes} min\n" + - "Last request: ${h.lastRequest}" + "Last request: ${h.last_request}" val u = dataPacket.copy( - bytes = text.encodeToByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = text.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, ) rememberDataPacket(u, myNodeNum) - historyManager.updateStoreForwardLastRequest("router_history", h.lastRequest, transport) + historyManager.updateStoreForwardLastRequest("router_history", h.last_request, transport) } - StoreAndForwardProtos.StoreAndForward.VariantCase.HEARTBEAT -> { - val hb = s.heartbeat + s.heartbeat != null -> { + val hb = s.heartbeat!! historyLog { "rxHeartbeat $baseContext period=${hb.period} secondary=${hb.secondary}" } } - StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> { - if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { + s.text != null -> { + if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { dataPacket.to = DataPacket.ID_BROADCAST } @Suppress("MaxLineLength") historyLog(Log.DEBUG) { "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} to=${dataPacket.to} decision=remember" } - val u = - dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value) rememberDataPacket(u, myNodeNum) } else -> {} @@ -689,7 +703,7 @@ constructor( } private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { - if (dataPacket.dataType != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) return false + if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled return messageFilterService.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled) } @@ -703,7 +717,7 @@ constructor( val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true val isSilent = conversationMuted || nodeMuted - if (packet.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isSilent) { + if (packet.port_num == PortNum.ALERT_APP.value && !isSilent) { serviceNotifications.showAlertNotification( contactKey, getSenderName(dataPacket), @@ -717,18 +731,18 @@ constructor( private fun getSenderName(packet: DataPacket): String { if (packet.from == DataPacket.ID_LOCAL) { val myId = nodeManager.getMyId() - return nodeManager.nodeDBbyID[myId]?.user?.longName ?: getString(Res.string.unknown_username) + return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getString(Res.string.unknown_username) } - return nodeManager.nodeDBbyID[packet.from]?.user?.longName ?: getString(Res.string.unknown_username) + return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getString(Res.string.unknown_username) } private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) { when (dataPacket.dataType) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { + PortNum.TEXT_MESSAGE_APP.value -> { val message = dataPacket.text!! val channelName = if (dataPacket.to == DataPacket.ID_BROADCAST) { - radioConfigRepository.channelSetFlow.first().settingsList.getOrNull(dataPacket.channel)?.name + radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name } else { null } @@ -742,7 +756,7 @@ constructor( ) } - Portnums.PortNum.WAYPOINT_APP_VALUE -> { + PortNum.WAYPOINT_APP.value -> { val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name) serviceNotifications.updateWaypointNotification( contactKey, @@ -759,24 +773,25 @@ constructor( @Suppress("LongMethod", "KotlinConstantConditions") private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch { - val emoji = packet.decoded.payload.toByteArray().decodeToString() + val decoded = packet.decoded ?: return@handledLaunch + val emoji = decoded.payload.toByteArray().decodeToString() val fromId = dataMapper.toNodeID(packet.from) val toId = dataMapper.toNodeID(packet.to) val reaction = ReactionEntity( myNodeNum = nodeManager.myNodeNum ?: 0, - replyId = packet.decoded.replyId, + replyId = decoded.reply_id, userId = fromId, emoji = emoji, timestamp = System.currentTimeMillis(), - snr = packet.rxSnr, - rssi = packet.rxRssi, + snr = packet.rx_snr, + rssi = packet.rx_rssi, hopsAway = - if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { + if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) { HOPS_AWAY_UNAVAILABLE } else { - packet.hopStart - packet.hopLimit + packet.hop_start - packet.hop_limit }, packetId = packet.id, status = MessageStatus.RECEIVED, @@ -788,7 +803,7 @@ constructor( val existingReactions = packetRepository.get().findReactionsWithId(packet.id) if (existingReactions.isNotEmpty()) { Logger.d { - "Skipping duplicate reaction: packetId=${packet.id} replyId=${packet.decoded.replyId} " + + "Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " + "from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))" } return@handledLaunch @@ -797,7 +812,7 @@ constructor( packetRepository.get().insertReaction(reaction) // Find the original packet to get the contactKey - packetRepository.get().getPacketByPacketId(packet.decoded.replyId)?.let { original -> + packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { original -> // Skip notification if the original message was filtered if (original.packet.filtered) return@let @@ -811,7 +826,7 @@ constructor( if (original.packet.data.to == DataPacket.ID_BROADCAST) { radioConfigRepository.channelSetFlow .first() - .settingsList + .settings .getOrNull(original.packet.data.channel) ?.name } else { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt index ed5df806b..74a24eeac 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt @@ -16,8 +16,9 @@ */ package com.geeksville.mesh.service +import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket -import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.MeshPacket import javax.inject.Inject import javax.inject.Singleton @@ -29,27 +30,25 @@ class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManage nodeManager.nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) } - fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) { - null - } else { - val data = packet.decoded - DataPacket( + fun toDataPacket(packet: MeshPacket): DataPacket? { + val decoded = packet.decoded ?: return null + return DataPacket( from = toNodeID(packet.from), to = toNodeID(packet.to), - time = packet.rxTime * 1000L, + time = packet.rx_time * 1000L, id = packet.id, - dataType = data.portnumValue, - bytes = data.payload.toByteArray(), - hopLimit = packet.hopLimit, - channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel, - wantAck = packet.wantAck, - hopStart = packet.hopStart, - snr = packet.rxSnr, - rssi = packet.rxRssi, - replyId = data.replyId, - relayNode = packet.relayNode, - viaMqtt = packet.viaMqtt, - emoji = data.emoji, + dataType = decoded.portnum.value, + bytes = decoded.payload.toByteArray().toByteString(), + hopLimit = packet.hop_limit, + channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel, + wantAck = packet.want_ack == true, + hopStart = packet.hop_start, + snr = packet.rx_snr, + rssi = packet.rx_rssi, + replyId = decoded.reply_id, + relayNode = packet.relay_node, + viaMqtt = packet.via_mqtt == true, + emoji = decoded.emoji, ) } } 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 765e960c9..8281ce529 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt @@ -21,12 +21,13 @@ import androidx.annotation.VisibleForTesting import co.touchlab.kermit.Logger import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.model.NO_DEVICE_SELECTED -import com.google.protobuf.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.prefs.mesh.MeshPrefs -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.ModuleConfigProtos -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.StoreAndForwardProtos +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward import javax.inject.Inject import javax.inject.Singleton @@ -47,15 +48,14 @@ constructor( lastRequest: Int, historyReturnWindow: Int, historyReturnMax: Int, - ): StoreAndForwardProtos.StoreAndForward { - val historyBuilder = StoreAndForwardProtos.StoreAndForward.History.newBuilder() - if (lastRequest > 0) historyBuilder.lastRequest = lastRequest - if (historyReturnWindow > 0) historyBuilder.window = historyReturnWindow - if (historyReturnMax > 0) historyBuilder.historyMessages = historyReturnMax - return StoreAndForwardProtos.StoreAndForward.newBuilder() - .setRr(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY) - .setHistory(historyBuilder) - .build() + ): StoreAndForward { + val history = + StoreAndForward.History( + last_request = lastRequest.coerceAtLeast(0), + window = historyReturnWindow.coerceAtLeast(0), + history_messages = historyReturnMax.coerceAtLeast(0), + ) + return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history) } @VisibleForTesting @@ -86,7 +86,7 @@ constructor( fun requestHistoryReplay( trigger: String, myNodeNum: Int?, - storeForwardConfig: ModuleConfigProtos.ModuleConfig.StoreForwardConfig?, + storeForwardConfig: ModuleConfig.StoreForwardConfig?, transport: String, ) { val address = activeDeviceAddress() @@ -99,8 +99,8 @@ constructor( val lastRequest = meshPrefs.getStoreForwardLastRequest(address) val (window, max) = resolveHistoryRequestParameters( - storeForwardConfig?.historyReturnWindow ?: 0, - storeForwardConfig?.historyReturnMax ?: 0, + storeForwardConfig?.history_return_window ?: 0, + storeForwardConfig?.history_return_max ?: 0, ) val request = buildStoreForwardHistoryRequest(lastRequest, window, max) @@ -112,19 +112,11 @@ constructor( runCatching { packetHandler.sendToRadio( - MeshPacket.newBuilder() - .apply { - to = myNodeNum - decoded = - org.meshtastic.proto.MeshProtos.Data.newBuilder() - .apply { - portnumValue = Portnums.PortNum.STORE_FORWARD_APP_VALUE - payload = ByteString.copyFrom(request.toByteArray()) - } - .build() - priority = MeshPacket.Priority.BACKGROUND - } - .build(), + MeshPacket( + to = myNodeNum, + decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = request.encode().toByteString()), + priority = MeshPacket.Priority.BACKGROUND, + ), ) } .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed" } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt index e935d2e7a..482424a5e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt @@ -29,11 +29,10 @@ import kotlinx.coroutines.flow.onEach import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.data.repository.LocationRepository import org.meshtastic.core.model.Position -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.position import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds +import org.meshtastic.proto.Position as ProtoPosition @Singleton class MeshLocationManager @@ -46,7 +45,7 @@ constructor( private var locationFlow: Job? = null @SuppressLint("MissingPermission") - fun start(scope: CoroutineScope, sendPositionFn: (MeshProtos.Position) -> Unit) { + fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) { this.scope = scope if (locationFlow?.isActive == true) return @@ -56,18 +55,21 @@ constructor( .getLocations() .onEach { location -> sendPositionFn( - position { - latitudeI = Position.degI(location.latitude) - longitudeI = Position.degI(location.longitude) + ProtoPosition( + latitude_i = Position.degI(location.latitude), + longitude_i = Position.degI(location.longitude), + altitude = if (LocationCompat.hasMslAltitude(location)) { - altitude = LocationCompat.getMslAltitudeMeters(location).toInt() - } - altitudeHae = location.altitude.toInt() - time = (location.time.milliseconds.inWholeSeconds).toInt() - groundSpeed = location.speed.toInt() - groundTrack = location.bearing.toInt() - locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL - }, + LocationCompat.getMslAltitudeMeters(location).toInt() + } else { + null + }, + altitude_hae = location.altitude.toInt(), + time = (location.time.milliseconds.inWholeSeconds).toInt(), + ground_speed = location.speed.toInt(), + ground_track = location.bearing.toInt(), + location_source = ProtoPosition.LocSource.LOC_EXTERNAL, + ), ) } .launchIn(scope) 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 56230ab9b..348089f46 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt @@ -30,11 +30,10 @@ import kotlinx.coroutines.flow.onEach import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.FromRadio.PayloadVariantCase -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.fromRadio +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.LogRecord +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum import java.util.ArrayDeque import java.util.Locale import java.util.UUID @@ -77,17 +76,12 @@ constructor( } fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) { - runCatching { MeshProtos.FromRadio.parseFrom(bytes) } - .onSuccess { proto -> - if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) { - Logger.w { "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.toHexString()}" } - } - processFromRadio(proto, myNodeNum) - } + runCatching { FromRadio.ADAPTER.decode(bytes) } + .onSuccess { proto -> processFromRadio(proto, myNodeNum) } .onFailure { primaryException -> runCatching { - val logRecord = MeshProtos.LogRecord.parseFrom(bytes) - processFromRadio(fromRadio { this.logRecord = logRecord }, myNodeNum) + val logRecord = LogRecord.ADAPTER.decode(bytes) + processFromRadio(FromRadio(log_record = logRecord), myNodeNum) } .onFailure { _ -> Logger.e(primaryException) { @@ -98,31 +92,32 @@ constructor( } } - private fun processFromRadio(proto: MeshProtos.FromRadio, myNodeNum: Int?) { + private fun processFromRadio(proto: FromRadio, myNodeNum: Int?) { // Audit log every incoming variant logVariant(proto) - if (proto.payloadVariantCase == PayloadVariantCase.PACKET) { - handleReceivedMeshPacket(proto.packet, myNodeNum) + val packet = proto.packet + if (packet != null) { + handleReceivedMeshPacket(packet, myNodeNum) } else { fromRadioDispatcher.handleFromRadio(proto) } } - private fun logVariant(proto: MeshProtos.FromRadio) { + private fun logVariant(proto: FromRadio) { val (type, message) = - when (proto.payloadVariantCase) { - PayloadVariantCase.LOG_RECORD -> "LogRecord" to proto.logRecord.toString() - PayloadVariantCase.REBOOTED -> "Rebooted" to proto.rebooted.toString() - PayloadVariantCase.XMODEMPACKET -> "XmodemPacket" to proto.xmodemPacket.toString() - PayloadVariantCase.DEVICEUICONFIG -> "DeviceUIConfig" to proto.deviceuiConfig.toString() - PayloadVariantCase.FILEINFO -> "FileInfo" to proto.fileInfo.toString() - PayloadVariantCase.MY_INFO -> "MyInfo" to proto.myInfo.toString() - PayloadVariantCase.NODE_INFO -> "NodeInfo" to proto.nodeInfo.toString() - PayloadVariantCase.CONFIG -> "Config" to proto.config.toString() - PayloadVariantCase.MODULECONFIG -> "ModuleConfig" to proto.moduleConfig.toString() - PayloadVariantCase.CHANNEL -> "Channel" to proto.channel.toString() - PayloadVariantCase.CLIENTNOTIFICATION -> "ClientNotification" to proto.clientNotification.toString() + when { + proto.log_record != null -> "LogRecord" to proto.log_record.toString() + proto.rebooted != null -> "Rebooted" to proto.rebooted.toString() + proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString() + proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString() + proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString() + proto.my_info != null -> "MyInfo" to proto.my_info.toString() + proto.node_info != null -> "NodeInfo" to proto.node_info.toString() + proto.config != null -> "Config" to proto.config.toString() + proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString() + proto.channel != null -> "Channel" to proto.channel.toString() + proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString() else -> return } @@ -139,8 +134,12 @@ constructor( fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { val rxTime = - if (packet.rxTime == 0) (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt() else packet.rxTime - val preparedPacket = packet.toBuilder().setRxTime(rxTime).build() + if (packet.rx_time == 0) { + (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt() + } else { + packet.rx_time + } + val preparedPacket = packet.copy(rx_time = rxTime) if (nodeManager.isNodeDbReady.value) { processReceivedMeshPacket(preparedPacket, myNodeNum) @@ -151,23 +150,15 @@ constructor( val dropped = earlyReceivedPackets.removeFirst() historyLog(Log.WARN) { val portLabel = - if (dropped.hasDecoded()) { - Portnums.PortNum.forNumber(dropped.decoded.portnumValue)?.name - ?: dropped.decoded.portnumValue.toString() - } else { - "unknown" - } + dropped.decoded?.portnum?.name ?: dropped.decoded?.portnum?.value?.toString() ?: "unknown" "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel" } } earlyReceivedPackets.addLast(preparedPacket) val portLabel = - if (preparedPacket.hasDecoded()) { - Portnums.PortNum.forNumber(preparedPacket.decoded.portnumValue)?.name - ?: preparedPacket.decoded.portnumValue.toString() - } else { - "unknown" - } + preparedPacket.decoded?.portnum?.name + ?: preparedPacket.decoded?.portnum?.value?.toString() + ?: "unknown" historyLog { "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel" } @@ -189,7 +180,7 @@ constructor( } private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { - if (!packet.hasDecoded()) return + val decoded = packet.decoded ?: return val log = MeshLog( uuid = UUID.randomUUID().toString(), @@ -197,8 +188,8 @@ constructor( received_date = System.currentTimeMillis(), raw_message = packet.toString(), fromNum = packet.from, - portNum = packet.decoded.portnumValue, - fromRadio = fromRadio { this.packet = packet }, + portNum = decoded.portnum.value, + fromRadio = FromRadio(packet = packet), ) val logJob = insertMeshLog(log) logInsertJobByPacketId[packet.id] = logJob @@ -207,23 +198,24 @@ constructor( scope.handledLaunch { serviceRepository.emitMeshPacket(packet) } myNodeNum?.let { myNum -> - val isOtherNode = myNum != packet.from + val from = packet.from + val isOtherNode = myNum != from nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) { it.lastHeard = (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt() } - nodeManager.updateNodeInfo(packet.from, withBroadcast = false, channel = packet.channel) { - it.lastHeard = packet.rxTime - it.snr = packet.rxSnr - it.rssi = packet.rxRssi + nodeManager.updateNodeInfo(from, withBroadcast = false, channel = packet.channel) { + it.lastHeard = packet.rx_time + it.snr = packet.rx_snr + it.rssi = packet.rx_rssi it.hopsAway = - if (packet.decoded.portnumValue == Portnums.PortNum.RANGE_TEST_APP_VALUE) { + if (decoded.portnum == PortNum.RANGE_TEST_APP) { 0 - } else if (packet.hopStart == 0 && !packet.decoded.hasBitfield()) { + } else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) { -1 - } else if (packet.hopLimit > packet.hopStart) { + } else if (packet.hop_limit > packet.hop_start) { -1 } else { - packet.hopStart - packet.hopLimit + packet.hop_start - packet.hop_limit } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt index b88f31f00..ed741dcc6 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt @@ -26,8 +26,8 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.ToRadio +import org.meshtastic.proto.MqttClientProxyMessage +import org.meshtastic.proto.ToRadio import javax.inject.Inject import javax.inject.Singleton @@ -48,9 +48,7 @@ constructor( if (enabled && proxyToClientEnabled) { mqttMessageFlow = mqttRepository.proxyMessageFlow - .onEach { message -> - packetHandler.sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) - } + .onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) } .catch { throwable -> serviceRepository.setErrorMessage("MqttClientProxy failed: $throwable") } .launchIn(scope) } @@ -64,18 +62,18 @@ constructor( } } - fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { - Logger.d { "[mqttClientProxyMessage] ${message.topic}" } - with(message) { - when (payloadVariantCase) { - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> { - mqttRepository.publish(topic, text.encodeToByteArray(), retained) - } - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> { - mqttRepository.publish(topic, data.toByteArray(), retained) - } - else -> {} + fun handleMqttProxyMessage(message: MqttClientProxyMessage) { + val topic = message.topic ?: "" + Logger.d { "[mqttClientProxyMessage] $topic" } + val retained = message.retained == true + when { + message.text != null -> { + mqttRepository.publish(topic, message.text!!.encodeToByteArray(), retained) } + message.data_ != null -> { + mqttRepository.publish(topic, message.data_!!.toByteArray(), retained) + } + else -> {} } } } 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 6d2c7c4e3..00c47a538 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt @@ -24,8 +24,8 @@ import kotlinx.coroutines.SupervisorJob import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.unknown_username -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NeighborInfo import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -46,29 +46,31 @@ constructor( } fun handleNeighborInfo(packet: MeshPacket) { - val ni = MeshProtos.NeighborInfo.parseFrom(packet.decoded.payload) + val payload = packet.decoded?.payload ?: return + val ni = NeighborInfo.ADAPTER.decode(payload) // Store the last neighbor info from our connected radio - if (packet.from == nodeManager.myNodeNum) { + val from = packet.from ?: 0 + if (from == nodeManager.myNodeNum) { commandSender.lastNeighborInfo = ni Logger.d { "Stored last neighbor info from connected radio" } } // Update Node DB - nodeManager.nodeDBbyNodeNum[packet.from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) } + nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) } // Format for UI response - val requestId = packet.decoded.requestId + val requestId = packet.decoded?.request_id ?: 0 val start = commandSender.neighborInfoStartTimes.remove(requestId) val neighbors = - ni.neighborsList.joinToString("\n") { n -> - val node = nodeManager.nodeDBbyNodeNum[n.nodeId] + ni.neighbors.joinToString("\n") { n -> + val node = nodeManager.nodeDBbyNodeNum[n.node_id] val name = node?.let { "${it.longName} (${it.shortName})" } ?: getString(Res.string.unknown_username) "• $name (SNR: ${n.snr})" } - val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[packet.from]?.longName ?: "Unknown"}:\n$neighbors" + val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.longName ?: "Unknown"}:\n$neighbors" val responseText = if (start != null) { 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 2ca087d80..5a39d1a39 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt @@ -24,6 +24,7 @@ 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.data.repository.NodeRepository import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.NodeEntity @@ -32,17 +33,19 @@ import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.Position import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.copy -import org.meshtastic.proto.telemetry -import org.meshtastic.proto.user +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.StatusMessage +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +import org.meshtastic.proto.Position as ProtoPosition -@Suppress("TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") @Singleton class MeshNodeManager @Inject @@ -93,8 +96,8 @@ constructor( val myNode = nodeDBbyNodeNum[mi.myNodeNum] return MyNodeInfo( myNodeNum = mi.myNodeNum, - hasGPS = myNode?.position?.latitudeI != 0, - model = mi.model ?: myNode?.user?.hwModel?.name, + hasGPS = (myNode?.position?.latitude_i ?: 0) != 0, + model = mi.model ?: myNode?.user?.hw_model?.name, firmwareVersion = mi.firmwareVersion, couldUpdate = mi.couldUpdate, shouldUpdate = mi.shouldUpdate, @@ -122,18 +125,19 @@ constructor( fun getOrCreateNodeInfo(n: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(n) { val userId = DataPacket.nodeNumToDefaultId(n) - val defaultUser = user { - id = userId - longName = "Meshtastic ${userId.takeLast(n = 4)}" - shortName = userId.takeLast(n = 4) - hwModel = MeshProtos.HardwareModel.UNSET - } + val defaultUser = + User( + id = userId, + long_name = "Meshtastic ${userId.takeLast(n = 4)}", + short_name = userId.takeLast(n = 4), + hw_model = HardwareModel.UNSET, + ) NodeEntity( num = n, user = defaultUser, - longName = defaultUser.longName, - shortName = defaultUser.shortName, + longName = defaultUser.long_name, + shortName = defaultUser.short_name, channel = channel, ) } @@ -154,25 +158,25 @@ constructor( } } - fun insertMetadata(nodeNum: Int, metadata: MeshProtos.DeviceMetadata) { + fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { scope.handledLaunch { nodeRepository?.insertMetadata(MetadataEntity(nodeNum, metadata)) } } - fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0, manuallyVerified: Boolean = false) { + fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) { updateNodeInfo(fromNum) { - val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET) + val newNode = (it.isUnknownUser && p.hw_model != HardwareModel.UNSET) val shouldPreserve = shouldPreserveExistingUser(it.user, p) if (shouldPreserve) { - it.longName = it.user.longName - it.shortName = it.user.shortName + it.longName = it.user.long_name + it.shortName = it.user.short_name it.channel = channel it.manuallyVerified = manuallyVerified } else { - val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey - it.user = if (keyMatch) p else p.copy { publicKey = NodeEntity.ERROR_BYTE_STRING } - it.longName = p.longName - it.shortName = p.shortName + val keyMatch = !it.hasPKC || it.user.public_key == p.public_key + it.user = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY) + it.longName = p.long_name + it.shortName = p.short_name it.channel = channel it.manuallyVerified = manuallyVerified if (newNode) { @@ -185,72 +189,74 @@ constructor( fun handleReceivedPosition( fromNum: Int, myNodeNum: Int, - p: MeshProtos.Position, + p: ProtoPosition, defaultTime: Long = System.currentTimeMillis(), ) { - if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { + if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) { Logger.d { "Ignoring nop position update for the local node" } } else { updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / TIME_MS_TO_S).toInt()) } } } - fun handleReceivedTelemetry(fromNum: Int, telemetry: TelemetryProtos.Telemetry) { + fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) { updateNodeInfo(fromNum) { nodeEntity -> when { - telemetry.hasDeviceMetrics() -> nodeEntity.deviceTelemetry = telemetry - telemetry.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetry - telemetry.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetry + telemetry.device_metrics != null -> nodeEntity.deviceTelemetry = telemetry + telemetry.environment_metrics != null -> nodeEntity.environmentTelemetry = telemetry + telemetry.power_metrics != null -> nodeEntity.powerTelemetry = telemetry } } } - fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) { + fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) { updateNodeInfo(fromNum) { it.paxcounter = p } } - fun handleReceivedNodeStatus(fromNum: Int, s: MeshProtos.StatusMessage) { + fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) { updateNodeInfo(fromNum) { it.nodeStatus = s.status } } - fun installNodeInfo(info: MeshProtos.NodeInfo, withBroadcast: Boolean = true) { + fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) { updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity -> - if (info.hasUser()) { - if (shouldPreserveExistingUser(entity.user, info.user)) { - entity.longName = entity.user.longName - entity.shortName = entity.user.shortName + val user = info.user + if (user != null) { + if (shouldPreserveExistingUser(entity.user, user)) { + entity.longName = entity.user.long_name + entity.shortName = entity.user.short_name } else { - entity.user = - info.user.copy { - if (isLicensed) clearPublicKey() - if (info.viaMqtt) longName = "$longName (MQTT)" - } - entity.longName = entity.user.longName - entity.shortName = entity.user.shortName + var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it } + if (info.via_mqtt) { + newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") + } + entity.user = newUser + entity.longName = newUser.long_name + entity.shortName = newUser.short_name } } - if (info.hasPosition()) { - entity.position = info.position - entity.latitude = Position.degD(info.position.latitudeI) - entity.longitude = Position.degD(info.position.longitudeI) + val position = info.position + if (position != null) { + entity.position = position + entity.latitude = Position.degD(position.latitude_i ?: 0) + entity.longitude = Position.degD(position.longitude_i ?: 0) } - entity.lastHeard = info.lastHeard - if (info.hasDeviceMetrics()) { - entity.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics } + entity.lastHeard = info.last_heard + if (info.device_metrics != null) { + entity.deviceTelemetry = Telemetry(device_metrics = info.device_metrics) } entity.channel = info.channel - entity.viaMqtt = info.viaMqtt - entity.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1 - entity.isFavorite = info.isFavorite - entity.isIgnored = info.isIgnored - entity.isMuted = info.isMuted + entity.viaMqtt = info.via_mqtt + entity.hopsAway = info.hops_away ?: -1 + entity.isFavorite = info.is_favorite + entity.isIgnored = info.is_ignored + entity.isMuted = info.is_muted } } - private fun shouldPreserveExistingUser(existing: MeshProtos.User, incoming: MeshProtos.User): Boolean { - val isDefaultName = incoming.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) - val isDefaultHwModel = incoming.hwModel == MeshProtos.HardwareModel.UNSET - val hasExistingUser = existing.id.isNotEmpty() && existing.hwModel != MeshProtos.HardwareModel.UNSET + private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { + val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) + val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET + val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET return hasExistingUser && isDefaultName && isDefaultHwModel } 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 76f4e371d..668ad6e04 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -49,6 +49,7 @@ import org.meshtastic.core.service.IMeshService import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.SERVICE_NOTIFY_ID import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.proto.PortNum import javax.inject.Inject @AndroidEntryPoint @@ -89,7 +90,7 @@ class MeshService : Service() { companion object { fun actionReceived(portNum: Int): String { - val portType = org.meshtastic.proto.Portnums.PortNum.forNumber(portNum) + val portType = PortNum.fromValue(portNum) val portStr = portType?.toString() ?: portNum.toString() return com.geeksville.mesh.service.actionReceived(portStr) } @@ -217,9 +218,7 @@ class MeshService : Service() { override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) } override fun getConfig(): ByteArray = toRemoteExceptions { - runBlocking { - radioConfigRepository.localConfigFlow.first().toByteArray() ?: throw NoDeviceConfigException() - } + runBlocking { radioConfigRepository.localConfigFlow.first().encode() } } override fun setConfig(payload: ByteArray) = toRemoteExceptions { @@ -279,7 +278,7 @@ class MeshService : Service() { } override fun getChannelSet(): ByteArray = toRemoteExceptions { - runBlocking { radioConfigRepository.channelSetFlow.first().toByteArray() } + runBlocking { radioConfigRepository.channelSetFlow.first().encode() } } override fun getNodes(): List = nodeManager.getNodes() 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 95e2fe6d4..a463a4848 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt @@ -73,9 +73,10 @@ import org.meshtastic.core.strings.new_node_seen import org.meshtastic.core.strings.no_local_stats import org.meshtastic.core.strings.reply import org.meshtastic.core.strings.you -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.TelemetryProtos.LocalStats +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.Telemetry import javax.inject.Inject /** @@ -264,35 +265,32 @@ constructor( notificationManager.createNotificationChannel(channel) } - var cachedTelemetry: TelemetryProtos.Telemetry? = null + var cachedTelemetry: Telemetry? = null var cachedLocalStats: LocalStats? = null var nextStatsUpdateMillis: Long = 0 var cachedMessage: String? = null // region Public Notification Methods - override fun updateServiceStateNotification( - summaryString: String?, - telemetry: TelemetryProtos.Telemetry?, - ): Notification { - val hasLocalStats = telemetry?.hasLocalStats() == true - val hasDeviceMetrics = telemetry?.hasDeviceMetrics() == true + override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification { + val hasLocalStats = telemetry?.local_stats != null + val hasDeviceMetrics = telemetry?.device_metrics != null val message = - if (hasLocalStats) { - val localStats = telemetry.localStats - val localStatsMessage = localStats?.formatToString() - cachedTelemetry = telemetry - nextStatsUpdateMillis = System.currentTimeMillis() + FIFTEEN_MINUTES_IN_MILLIS - localStatsMessage - } else if (cachedTelemetry == null && hasDeviceMetrics) { - val deviceMetrics = telemetry.deviceMetrics - val deviceMetricsMessage = deviceMetrics.formatToString() - if (cachedLocalStats == null) { + when { + hasLocalStats -> { + val localStatsMessage = telemetry?.local_stats?.formatToString() cachedTelemetry = telemetry + nextStatsUpdateMillis = System.currentTimeMillis() + FIFTEEN_MINUTES_IN_MILLIS + localStatsMessage } - nextStatsUpdateMillis = System.currentTimeMillis() - deviceMetricsMessage - } else { - null + cachedTelemetry == null && hasDeviceMetrics -> { + val deviceMetricsMessage = telemetry?.device_metrics?.formatToString() + if (cachedLocalStats == null) { + cachedTelemetry = telemetry + } + nextStatsUpdateMillis = System.currentTimeMillis() + deviceMetricsMessage + } + else -> null } cachedMessage = message ?: cachedMessage ?: getString(Res.string.no_local_stats) @@ -388,7 +386,7 @@ constructor( } val ourNode = nodeRepository.get().ourNodeInfo.value - val meName = ourNode?.user?.longName ?: getString(Res.string.you) + val meName = ourNode?.user?.long_name ?: getString(Res.string.you) val me = Person.Builder() .setName(meName) @@ -433,7 +431,7 @@ constructor( } override fun showNewNodeSeenNotification(node: NodeEntity) { - val notification = createNewNodeSeenNotification(node.user.shortName, node.user.longName) + val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name) notificationManager.notify(node.num, notification) } @@ -442,7 +440,7 @@ constructor( notificationManager.notify(node.num, notification) } - override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) { + override fun showClientNotification(clientNotification: ClientNotification) { val notification = createClientNotification(getString(Res.string.client_notification), clientNotification.message) notificationManager.notify(clientNotification.toString().hashCode(), notification) @@ -452,7 +450,7 @@ constructor( override fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num) - override fun clearClientNotification(notification: MeshProtos.ClientNotification) = + override fun clearClientNotification(notification: ClientNotification) = notificationManager.cancel(notification.toString().hashCode()) // endregion @@ -499,7 +497,7 @@ constructor( } val ourNode = nodeRepository.get().ourNodeInfo.value - val meName = ourNode?.user?.longName ?: getString(Res.string.you) + val meName = ourNode?.user?.long_name ?: getString(Res.string.you) val me = Person.Builder() .setName(meName) @@ -516,14 +514,14 @@ constructor( // Use the node attached to the message directly to ensure correct identification val person = Person.Builder() - .setName(msg.node.user.longName) + .setName(msg.node.user.long_name) .setKey(msg.node.user.id) - .setIcon(createPersonIcon(msg.node.user.shortName, msg.node.colors.second, msg.node.colors.first)) + .setIcon(createPersonIcon(msg.node.user.short_name, msg.node.colors.second, msg.node.colors.first)) .build() val text = msg.originalMessage?.let { original -> - "↩️ \"${original.node.user.shortName}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}" + "↩️ \"${original.node.user.short_name}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}" } ?: msg.text style.addMessage(text, msg.receivedTime, person) @@ -533,11 +531,11 @@ constructor( val reactorNode = nodeRepository.get().getNode(reaction.user.id) val reactor = Person.Builder() - .setName(reaction.user.longName) + .setName(reaction.user.long_name) .setKey(reaction.user.id) .setIcon( createPersonIcon( - reaction.user.shortName, + reaction.user.short_name, reactorNode.colors.second, reactorNode.colors.first, ), @@ -612,7 +610,7 @@ constructor( .build() } - private fun createNewNodeSeenNotification(name: String, message: String?): Notification { + private fun createNewNodeSeenNotification(name: String, message: String): Notification { val title = getString(Res.string.new_node_seen).format(name) val builder = commonBuilder(NotificationType.NewNode) @@ -621,24 +619,23 @@ constructor( .setContentTitle(title) .setWhen(System.currentTimeMillis()) .setShowWhen(true) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) - message?.let { - builder.setContentText(it) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) - } return builder.build() } private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification { val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal val title = getString(Res.string.low_battery_title).format(node.shortName) - val message = getString(Res.string.low_battery_message).format(node.longName, node.deviceMetrics.batteryLevel) + val batteryLevel = node.deviceTelemetry?.device_metrics?.battery_level ?: 0 + val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel) return commonBuilder(type) .setCategory(Notification.CATEGORY_STATUS) .setOngoing(true) .setOnlyAlertOnce(true) - .setProgress(MAX_BATTERY_LEVEL, node.deviceMetrics.batteryLevel, false) + .setProgress(MAX_BATTERY_LEVEL, batteryLevel, false) .setContentTitle(title) .setContentText(message) .setStyle(NotificationCompat.BigTextStyle().bigText(message)) @@ -647,17 +644,13 @@ constructor( .build() } - private fun createClientNotification(name: String, message: String?): Notification = + private fun createClientNotification(name: String, message: String): Notification = commonBuilder(NotificationType.Client) .setCategory(Notification.CATEGORY_ERROR) .setAutoCancel(true) .setContentTitle(name) - .apply { - message?.let { - setContentText(it) - setStyle(NotificationCompat.BigTextStyle().bigText(it)) - } - } + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) .build() // endregion @@ -804,34 +797,19 @@ constructor( } // Extension function to format LocalStats into a readable string. -private fun LocalStats?.formatToString(): String? = this?.allFields - ?.mapNotNull { (k, v) -> - when (k.name) { - "num_online_nodes", - "num_total_nodes", - -> null // Exclude these fields - "uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}" - "channel_utilization" -> "ChUtil: %.2f%%".format(v) - "air_util_tx" -> "AirUtilTX: %.2f%%".format(v) - else -> { - val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() } - "$formattedKey: $v" - } - } - } - ?.joinToString("\n") +private fun LocalStats.formatToString(): String { + val parts = mutableListOf() + parts.add("Uptime: ${formatUptime(uptime_seconds)}") + parts.add("ChUtil: %.2f%%".format(channel_utilization)) + parts.add("AirUtilTX: %.2f%%".format(air_util_tx)) + return parts.joinToString("\n") +} -private fun TelemetryProtos.DeviceMetrics?.formatToString(): String? = this?.allFields - ?.mapNotNull { (k, v) -> - when (k.name) { - "battery_level" -> "Battery Level: $v" - "uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}" - "channel_utilization" -> "ChUtil: %.2f%%".format(v) - "air_util_tx" -> "AirUtilTX: %.2f%%".format(v) - else -> { - val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() } - "$formattedKey: $v" - } - } - } - ?.joinToString("\n") +private fun DeviceMetrics.formatToString(): String { + val parts = mutableListOf() + battery_level?.let { parts.add("Battery Level: $it") } + uptime_seconds?.let { parts.add("Uptime: ${formatUptime(it)}") } + channel_utilization?.let { parts.add("ChUtil: %.2f%%".format(it)) } + air_util_tx?.let { parts.add("AirUtilTX: %.2f%%".format(it)) } + return parts.joinToString("\n") +} 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 104e50ccb..212b528a6 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt @@ -33,7 +33,7 @@ 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 import org.meshtastic.core.strings.unknown_username -import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.MeshPacket import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -65,13 +65,13 @@ constructor( headerBack = getString(Res.string.traceroute_route_back_to_us), ) ?: return - val requestId = packet.decoded.requestId + val requestId = packet.decoded?.request_id ?: 0 if (logUuid != null) { scope.handledLaunch { logInsertJob?.join() val routeDiscovery = packet.fullRouteDiscovery - val forwardRoute = routeDiscovery?.routeList.orEmpty() - val returnRoute = routeDiscovery?.routeBackList.orEmpty() + val forwardRoute = routeDiscovery?.route.orEmpty() + val returnRoute = routeDiscovery?.route_back.orEmpty() val routeNodeNums = (forwardRoute + returnRoute).distinct() val nodeDbByNum = nodeRepository.nodeDBbyNum.value val snapshotPositions = @@ -93,15 +93,15 @@ constructor( } val routeDiscovery = packet.fullRouteDiscovery - val destination = routeDiscovery?.routeList?.firstOrNull() ?: routeDiscovery?.routeBackList?.lastOrNull() ?: 0 + val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0 serviceRepository.setTracerouteResponse( TracerouteResponse( message = responseText, destinationNodeNum = destination, requestId = requestId, - forwardRoute = routeDiscovery?.routeList.orEmpty(), - returnRoute = routeDiscovery?.routeBackList.orEmpty(), + forwardRoute = routeDiscovery?.route.orEmpty(), + returnRoute = routeDiscovery?.route_back.orEmpty(), logUuid = logUuid, ), ) 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 044cd21f7..a64cacfcf 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -36,10 +36,10 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.util.toOneLineString import org.meshtastic.core.model.util.toPIIString import org.meshtastic.core.service.ConnectionState -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.MeshProtos.ToRadio -import org.meshtastic.proto.fromRadio +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 @@ -76,24 +76,24 @@ constructor( * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully * bound to the RadioInterfaceService */ - fun sendToRadio(p: ToRadio.Builder) { - val built = p.build() - Logger.d { "Sending to radio ${built.toPIIString()}" } - val b = built.toByteArray() + fun sendToRadio(p: ToRadio) { + Logger.d { "Sending to radio ${p.toPIIString()}" } + val b = p.encode() radioInterfaceService.sendToRadio(b) - changeStatus(p.packet.id, MessageStatus.ENROUTE) + p.packet?.id?.let { changeStatus(it, MessageStatus.ENROUTE) } - if (p.packet.hasDecoded()) { + val packet = p.packet + if (packet?.decoded != null) { val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "Packet", received_date = System.currentTimeMillis(), - raw_message = p.packet.toString(), - fromNum = p.packet.from, - portNum = p.packet.decoded.portnumValue, - fromRadio = fromRadio { packet = p.packet }, + raw_message = packet.toString(), + fromNum = packet.from ?: 0, + portNum = packet.decoded?.portnum?.value ?: 0, + fromRadio = FromRadio(packet = packet), ) insertMeshLog(packetToSave) } @@ -119,9 +119,9 @@ constructor( } } - fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) { + fun handleQueueStatus(queueStatus: QueueStatus) { Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" } - val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, meshPacketId) } + val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) } if (success && isFull) return // Queue is full, wait for free != 0 if (requestId != 0) { queueResponse.remove(requestId)?.complete(success) @@ -192,7 +192,7 @@ constructor( if (connectionStateHolder.connectionState.value != ConnectionState.Connected) { throw RadioNotConnectedException() } - sendToRadio(ToRadio.newBuilder().apply { this.packet = packet }) + sendToRadio(ToRadio(packet = packet)) } catch (ex: Exception) { Logger.e(ex) { "sendToRadio error: ${ex.message}" } deferred.complete(false) diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt index 028cbfb5c..25234c56b 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt @@ -24,11 +24,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import okio.ByteString.Companion.toByteString import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.proto.Portnums +import org.meshtastic.proto.PortNum import javax.inject.Inject @AndroidEntryPoint @@ -74,8 +76,8 @@ class ReactionReceiver : BroadcastReceiver() { DataPacket( to = toId, channel = channelIndex, - bytes = emoji.toByteArray(Charsets.UTF_8), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = emoji.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, replyId = packetId, wantAck = true, emoji = emoji.codePointAt(0), @@ -90,7 +92,7 @@ class ReactionReceiver : BroadcastReceiver() { emoji = emoji, timestamp = System.currentTimeMillis(), packetId = reactionPacket.id, - status = org.meshtastic.core.model.MessageStatus.QUEUED, + status = MessageStatus.QUEUED, to = toId, channel = channelIndex, ) 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 c45ac99f6..3f198e0e0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -150,7 +150,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusBlue import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.util.toMessageRes import org.meshtastic.feature.node.metrics.annotateTraceroute -import org.meshtastic.proto.MeshProtos enum class TopLevelDestination(val label: StringResource, val icon: ImageVector, val route: Route) { Conversations(Res.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.ContactsGraph), @@ -222,7 +221,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode clientNotification?.let { notification -> var message = notification.message val compromisedKeys = - if (notification.hasLowEntropyKey() || notification.hasDuplicatedPublicKey()) { + if (notification.low_entropy_key != null || notification.duplicated_public_key != null) { message = stringResource(Res.string.compromised_keys) true } else { @@ -291,101 +290,6 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode onDismiss = { tracerouteMapError = null }, ) } - // FIXME: uncomment and update Capabilities.kt when working better - // - // val neighborInfoResponse by uIViewModel.neighborInfoResponse.observeAsState() - // neighborInfoResponse?.let { response -> - // SimpleAlertDialog( - // title = Res.string.neighbor_info, - // text = { - // Column(modifier = Modifier.fillMaxWidth()) { - // fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? { - // // First, try parsing directly from raw bytes of the string - // var neighborInfo: MeshProtos.NeighborInfo? = - // runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) }.getOrNull() - // - // if (neighborInfo == null) { - // // Next, try to decode a hex dump embedded as text (e.g., "AA BB CC ...") - // val hexPairs = Regex("""\b[0-9A-Fa-f]{2}\b""").findAll(input).map { it.value - // }.toList() - // @Suppress("detekt:MagicNumber") // byte offsets - // if (hexPairs.size >= 4) { - // val bytes = hexPairs.map { it.toInt(16).toByte() }.toByteArray() - // neighborInfo = runCatching { MeshProtos.NeighborInfo.parseFrom(bytes) - // }.getOrNull() - // } - // } - // - // return neighborInfo - // } - // - // val parsed = tryParseNeighborInfo(response) - // if (parsed != null) { - // fun fmtNode(nodeNum: Int): String = "!%08x".format(nodeNum) - // Text(text = "NeighborInfo:", style = MaterialTheme.typography.bodyMedium) - // Text( - // text = "node_id: ${fmtNode(parsed.nodeId)}", - // style = MaterialTheme.typography.bodySmall, - // modifier = Modifier.padding(top = 8.dp), - // ) - // Text( - // text = "last_sent_by_id: ${fmtNode(parsed.lastSentById)}", - // style = MaterialTheme.typography.bodySmall, - // modifier = Modifier.padding(top = 2.dp), - // ) - // Text( - // text = "node_broadcast_interval_secs: ${parsed.nodeBroadcastIntervalSecs}", - // style = MaterialTheme.typography.bodySmall, - // modifier = Modifier.padding(top = 2.dp), - // ) - // if (parsed.neighborsCount > 0) { - // Text( - // text = "neighbors:", - // style = MaterialTheme.typography.bodySmall, - // modifier = Modifier.padding(top = 4.dp), - // ) - // parsed.neighborsList.forEach { n -> - // Text( - // text = " - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}", - // style = MaterialTheme.typography.bodySmall, - // modifier = Modifier.padding(start = 8.dp), - // ) - // } - // } - // } else { - // val rawBytes = response.toByteArray() - // - // @Suppress("detekt:MagicNumber") // byte offsets - // val isBinary = response.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' } - // if (isBinary) { - // val hexString = rawBytes.joinToString(" ") { "%02X".format(it) } - // Text( - // text = "Binary data (hex view):", - // style = MaterialTheme.typography.bodyMedium, - // modifier = Modifier.padding(bottom = 4.dp), - // ) - // Text( - // text = hexString, - // style = - // MaterialTheme.typography.bodyMedium.copy( - // fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - // ), - // modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), - // ) - // } else { - // Text( - // text = response, - // style = MaterialTheme.typography.bodyMedium, - // modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), - // ) - // } - // } - // } - // }, - // dismissText = stringResource(Res.string.okay), - // onDismiss = { uIViewModel.clearNeighborInfoResponse() }, - // ) - // } val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo()) val currentDestination = navController.currentBackStackEntryAsState().value?.destination val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) @@ -605,18 +509,7 @@ private fun VersionChecks(viewModel: UIViewModel) { viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4")) LaunchedEffect(connectionState, firmwareEdition) { if (connectionState == ConnectionState.Connected) { - firmwareEdition?.let { edition -> - Logger.d { "FirmwareEdition: ${edition.name}" } - when (edition) { - MeshProtos.FirmwareEdition.VANILLA -> { - // Handle any specific logic for VANILLA firmware edition if needed - } - - else -> { - // Handle other firmware editions if needed - } - } - } + firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } } } } 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 1c7acf82a..adc8b06df 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 @@ -90,7 +90,7 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute 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.ConfigProtos +import org.meshtastic.proto.Config fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) { false @@ -120,13 +120,12 @@ fun ConnectionsScreen( val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle() val scrollState = rememberScrollState() val scanStatusText by scanModel.errorText.observeAsState("") - val connectionState by - connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.Disconnected) + 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 == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() @@ -219,7 +218,7 @@ fun ConnectionsScreen( CurrentlyConnectedInfo( node = node, bleDevice = - bleDevices.firstOrNull { it.fullAddress == selectedDevice } + bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?, onNavigateToNodeDetails = onNavigateToNodeDetails, onClickDisconnect = { scanModel.disconnect() }, 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 f8f661891..e17e52204 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 @@ -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 import androidx.lifecycle.ViewModel @@ -30,7 +29,7 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.prefs.ui.UiPrefs import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig +import org.meshtastic.proto.LocalConfig import javax.inject.Inject @HiltViewModel @@ -45,7 +44,7 @@ constructor( ) : ViewModel() { val localConfig: StateFlow = - radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance()) + radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) val connectionState = serviceRepository.connectionState 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 90b233d1c..55733443a 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 @@ -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.layout.Arrangement @@ -56,9 +55,9 @@ 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 -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.User import kotlin.time.Duration.Companion.seconds private const val RSSI_DELAY = 10 @@ -113,9 +112,9 @@ fun CurrentlyConnectedInfo( } Column(modifier = Modifier.weight(1f, fill = true)) { - Text(text = node.user.longName, style = MaterialTheme.typography.titleMedium) + Text(text = node.user.long_name ?: "", style = MaterialTheme.typography.titleMedium) - node.metadata?.firmwareVersion?.let { firmwareVersion -> + node.metadata?.firmware_version?.let { firmwareVersion -> Text( text = stringResource(Res.string.firmware_version, firmwareVersion), style = MaterialTheme.typography.bodySmall, @@ -150,14 +149,10 @@ private fun CurrentlyConnectedInfoPreview() { node = Node( num = 13444, - user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build(), + user = User(short_name = "\uD83E\uDEE0", long_name = "John Doe"), isIgnored = false, - paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(), - environmentMetrics = - TelemetryProtos.EnvironmentMetrics.newBuilder() - .setTemperature(25f) - .setRelativeHumidity(60f) - .build(), + paxcounter = Paxcount(ble = 10, wifi = 5), + environmentMetrics = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f), ), onNavigateToNodeDetails = {}, onClickDisconnect = {}, diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt index 2619cb473..7f3735817 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.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.contact import androidx.compose.animation.AnimatedVisibility @@ -60,7 +59,7 @@ import org.meshtastic.core.strings.some_username import org.meshtastic.core.strings.unknown_username import org.meshtastic.core.ui.component.SecurityIcon import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.AppOnlyProtos +import org.meshtastic.proto.ChannelSet @Suppress("LongMethod") @Composable @@ -72,7 +71,7 @@ fun ContactItem( onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, onNodeChipClick: () -> Unit = {}, - channels: AppOnlyProtos.ChannelSet? = null, + channels: ChannelSet? = null, ) = with(contact) { val isOutlined = !selected && !isActive @@ -113,7 +112,7 @@ fun ContactItem( @Composable private fun ContactHeader( contact: Contact, - channels: AppOnlyProtos.ChannelSet?, + channels: ChannelSet?, modifier: Modifier = Modifier, onNodeChipClick: () -> Unit = {}, ) { 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 05dd025a5..fa700e266 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 @@ -99,7 +99,7 @@ import org.meshtastic.core.ui.icon.QrCode2 import org.meshtastic.core.ui.icon.SelectAll import org.meshtastic.core.ui.icon.VolumeMuteTwoTone import org.meshtastic.core.ui.icon.VolumeUpTwoTone -import org.meshtastic.proto.AppOnlyProtos +import org.meshtastic.proto.ChannelSet import java.util.concurrent.TimeUnit @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -129,8 +129,8 @@ fun ContactsScreen( // Create channel placeholders (always show broadcast contacts, even when empty) val channels by viewModel.channels.collectAsStateWithLifecycle() val channelPlaceholders = - remember(channels.settingsList.size) { - (0 until channels.settingsList.size).map { ch -> + remember(channels.settings.size) { + (0 until channels.settings.size).map { ch -> Contact( contactKey = "$ch^all", shortName = "$ch", @@ -485,7 +485,7 @@ private fun ContactListViewPaged( onNodeChipClick: (Contact) -> Unit, listState: LazyListState, modifier: Modifier = Modifier, - channels: AppOnlyProtos.ChannelSet? = null, + channels: ChannelSet? = null, ) { val haptics = LocalHapticFeedback.current @@ -521,7 +521,7 @@ private fun ContactListContentInternal( onLongClick: (Contact) -> Unit, onNodeChipClick: (Contact) -> Unit, listState: LazyListState, - channels: AppOnlyProtos.ChannelSet?, + channels: ChannelSet?, haptics: HapticFeedback, modifier: Modifier = Modifier, ) { @@ -559,7 +559,7 @@ private fun LazyListScope.contactListPlaceholdersItems( onClick: (Contact) -> Unit, onLongClick: (Contact) -> Unit, onNodeChipClick: (Contact) -> Unit, - channels: AppOnlyProtos.ChannelSet?, + channels: ChannelSet?, haptics: HapticFeedback, ) { items( @@ -592,7 +592,7 @@ private fun LazyListScope.contactListPagedItems( onClick: (Contact) -> Unit, onLongClick: (Contact) -> Unit, onNodeChipClick: (Contact) -> Unit, - channels: AppOnlyProtos.ChannelSet?, + channels: ChannelSet?, haptics: HapticFeedback, ) { items( diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt index c98a02bd8..6b219e895 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt @@ -43,8 +43,7 @@ import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.channel_name import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.AppOnlyProtos -import org.meshtastic.proto.channelSet +import org.meshtastic.proto.ChannelSet import javax.inject.Inject import kotlin.collections.map as collectionsMap @@ -61,7 +60,7 @@ constructor( val connectionState = serviceRepository.connectionState - val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {}) + val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet()) // Combine node info and myId to reduce argument count in subsequent combines private val identityFlow: Flow> = @@ -86,7 +85,7 @@ constructor( val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() // Add empty channel placeholders (always show Broadcast contacts, even when empty) val placeholder = - (0 until channelSet.settingsCount).associate { ch -> + (0 until channelSet.settings.size).associate { ch -> val contactKey = "$ch${DataPacket.ID_BROADCAST}" val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) @@ -104,12 +103,12 @@ constructor( val user = getUser(if (fromLocal) data.to else data.from) val node = getNode(if (fromLocal) data.to else data.from) - val shortName = user.shortName + val shortName = user.short_name ?: "" val longName = if (toBroadcast) { channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name) } else { - user.longName + user.long_name ?: "" } Contact( @@ -121,7 +120,7 @@ constructor( unreadCount = packetRepository.getUnreadCount(contactKey), messageCount = packetRepository.getMessageCount(contactKey), isMuted = settings[contactKey]?.isMuted == true, - isUnmessageable = user.isUnmessagable, + isUnmessageable = user.is_unmessagable ?: false, nodeColors = if (!toBroadcast) { node.colors @@ -157,12 +156,12 @@ constructor( val user = getUser(if (fromLocal) data.to else data.from) val node = getNode(if (fromLocal) data.to else data.from) - val shortName = user.shortName + val shortName = user.short_name ?: "" val longName = if (toBroadcast) { channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name) } else { - user.longName + user.long_name ?: "" } Contact( @@ -174,7 +173,7 @@ constructor( unreadCount = packetRepository.getUnreadCount(contactKey), messageCount = packetRepository.getMessageCount(contactKey), isMuted = settings[contactKey]?.isMuted == true, - isUnmessageable = user.isUnmessagable, + isUnmessageable = user.is_unmessagable ?: false, nodeColors = if (!toBroadcast) { node.colors @@ -215,7 +214,7 @@ constructor( private data class ContactsPagedParams( val myNodeNum: Int?, - val channelSet: AppOnlyProtos.ChannelSet, + val channelSet: ChannelSet, val settings: Map, val myId: String?, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index ca06abfed..da483ed01 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -128,11 +128,9 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute 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.AppOnlyProtos.ChannelSet -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.channelSet -import org.meshtastic.proto.copy +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config /** * Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel @@ -156,7 +154,8 @@ fun ChannelScreen( val channels by viewModel.channels.collectAsStateWithLifecycle() var channelSet by remember(channels) { mutableStateOf(channels) } - val modemPresetName by remember(channels) { mutableStateOf(Channel(loraConfig = channels.loraConfig).name) } + val modemPresetName by + remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) } var showResetDialog by remember { mutableStateOf(false) } @@ -185,16 +184,18 @@ fun ChannelScreen( /* Holds selections made by the user for QR generation. */ val channelSelections = - rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() })) { - mutableStateListOf(elements = Array(size = 8, init = { true })) + rememberSaveable( + saver = + listSaver, Boolean>( + save = { it.toList() }, + restore = { it.toMutableStateList() }, + ), + ) { + mutableStateListOf(true, true, true, true, true, true, true, true) } val selectedChannelSet = - channelSet.copy { - val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } - settings.clear() - settings.addAll(result) - } + channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }) val scope = rememberCoroutineScope() val context = LocalContext.current @@ -244,11 +245,8 @@ fun ChannelScreen( } } - fun installSettings(newChannel: ChannelProtos.ChannelSettings, newLoRaConfig: ConfigProtos.Config.LoRaConfig) { - val newSet = channelSet { - settings.add(newChannel) - loraConfig = newLoRaConfig - } + fun installSettings(newChannel: ChannelSettings, newLoRaConfig: Config.LoRaConfig) { + val newSet = ChannelSet(settings = listOf(newChannel), lora_config = newLoRaConfig) installSettings(newSet) } @@ -264,13 +262,12 @@ fun ChannelScreen( TextButton( onClick = { Logger.d { "Switching back to default channel" } - installSettings( - Channel.default.settings, - Channel.default.loraConfig.copy { - region = viewModel.region - txEnabled = viewModel.txEnabled - }, - ) + val lora = + (Channel.default.loraConfig).copy( + region = viewModel.region, + tx_enabled = viewModel.txEnabled, + ) + installSettings(Channel.default.settings, lora) showResetDialog = false }, ) { @@ -504,17 +501,13 @@ private fun ChannelListView( onClick: () -> Unit = {}, ) { val selectedChannelSet = - channelSet.copy { - val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } - settings.clear() - settings.addAll(result) - } + channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }) AdaptiveTwoPane( first = { - channelSet.settingsList.forEachIndexed { index, channel -> - val channelObj = Channel(channel, channelSet.loraConfig) - val displayTitle = channel.name.ifEmpty { modemPresetName } + channelSet.settings.forEachIndexed { index, channel -> + val channelObj = Channel(channel, channelSet.lora_config ?: Config.LoRaConfig()) + val displayTitle = if (channel.name.isEmpty()) modemPresetName else channel.name ChannelSelection( index = index, @@ -522,7 +515,7 @@ private fun ChannelListView( enabled = enabled, isSelected = channelSelections[index], onSelected = { - if (it || selectedChannelSet.settingsCount > 1) { + if (it || selectedChannelSet.settings.size > 1) { channelSelections[index] = it } }, @@ -583,11 +576,7 @@ fun ModemPresetInfoPreview() { private fun ChannelScreenPreview() { ChannelListView( enabled = true, - channelSet = - channelSet { - settings.add(Channel.default.settings) - loraConfig = Channel.default.loraConfig - }, + channelSet = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig), modemPresetName = Channel.default.name, channelSelections = listOf(true).toMutableStateList(), ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt index fb6c6d29f..dc5f2a7b4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.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.sharing import android.net.Uri @@ -33,13 +32,10 @@ import org.meshtastic.core.model.util.toChannelSet import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.AppOnlyProtos -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.channelSet -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig import javax.inject.Inject @HiltViewModel @@ -53,29 +49,28 @@ constructor( val connectionState = serviceRepository.connectionState - val localConfig = - radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance()) + val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {}) + val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet()) // managed mode disables all access to configuration val isManaged: Boolean - get() = localConfig.value.security.isManaged + get() = localConfig.value.security?.is_managed == true var txEnabled: Boolean - get() = localConfig.value.lora.txEnabled + get() = localConfig.value.lora?.tx_enabled == true set(value) { - updateLoraConfig { it.copy { txEnabled = value } } + updateLoraConfig { it.copy(tx_enabled = value) } } var region: Config.LoRaConfig.RegionCode - get() = localConfig.value.lora.region + get() = localConfig.value.lora?.region ?: Config.LoRaConfig.RegionCode.UNSET set(value) { - updateLoraConfig { it.copy { region = value } } + updateLoraConfig { it.copy(region = value) } } - private val _requestChannelSet = MutableStateFlow(null) - val requestChannelSet: StateFlow + private val _requestChannelSet = MutableStateFlow(null) + val requestChannelSet: StateFlow get() = _requestChannelSet fun requestChannelUrl(url: Uri, onError: () -> Unit) = runCatching { _requestChannelSet.value = url.toChannelSet() } @@ -89,17 +84,19 @@ constructor( } /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch { - getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel) - radioConfigRepository.replaceAllSettings(channelSet.settingsList) + fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) + radioConfigRepository.replaceAllSettings(channelSet.settings) - val newConfig = config { lora = channelSet.loraConfig } - if (localConfig.value.lora != newConfig.lora) setConfig(newConfig) + val newLoraConfig = channelSet.lora_config + if (localConfig.value.lora != newLoraConfig) { + setConfig(Config(lora = newLoraConfig)) + } } - fun setChannel(channel: ChannelProtos.Channel) { + fun setChannel(channel: Channel) { try { - serviceRepository.meshService?.setChannel(channel.toByteArray()) + serviceRepository.meshService?.setChannel(channel.encode()) } catch (ex: RemoteException) { Logger.e(ex) { "Set channel error" } } @@ -108,7 +105,7 @@ constructor( // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { try { - serviceRepository.meshService?.setConfig(config.toByteArray()) + serviceRepository.meshService?.setConfig(config.encode()) } catch (ex: RemoteException) { Logger.e(ex) { "Set config error" } } @@ -119,7 +116,7 @@ constructor( } private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { - val data = body(localConfig.value.lora) - setConfig(config { lora = data }) + val data = body(localConfig.value.lora ?: Config.LoRaConfig()) + setConfig(Config(lora = data)) } } diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt index 3725ffe36..187916c74 100644 --- a/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 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 @@ -16,66 +16,39 @@ */ package com.geeksville.mesh.repository.radio -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertArrayEquals +import com.geeksville.mesh.service.Fakes +import io.mockk.every +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals import org.junit.Test import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio class TCPInterfaceTest { - private val service: RadioInterfaceService = mockk(relaxed = true) - private val dispatchers: CoroutineDispatchers = mockk(relaxed = true) - @Test - fun `keepAlive generates correct heartbeat bytes`() = runTest { - val address = "192.168.1.1:4403" - // We need a subclass to capture handleSendToRadio or sendBytes - val tcpInterface = - object : TCPInterface(service, dispatchers, address) { - var capturedBytes: ByteArray? = null + fun testKeepAlive() { + val fakes = Fakes() + val testDispatcher = UnconfinedTestDispatcher() + val testScope = CoroutineScope(testDispatcher + Job()) + every { fakes.service.serviceScope } returns testScope + + val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher) + val tcpIf = + object : TCPInterface(fakes.service, dispatchers, "127.0.0.1") { + var lastSent: ByteArray? = null override fun handleSendToRadio(p: ByteArray) { - capturedBytes = p + lastSent = p } - - // Override connect to prevent it from starting automatically in init - override fun connect() {} } - tcpInterface.keepAlive() + tcpIf.keepAlive() - val expectedHeartbeat = - MeshProtos.ToRadio.newBuilder() - .setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance()) - .build() - .toByteArray() - - assertArrayEquals("Heartbeat bytes should match", expectedHeartbeat, tcpInterface.capturedBytes) - } - - @Test - fun `sendBytes does not crash when outStream is null`() = runTest { - val address = "192.168.1.1:4403" - val tcpInterface = - object : TCPInterface(service, dispatchers, address) { - override fun connect() {} - } - - // This should not throw UninitializedPropertyAccessException - tcpInterface.sendBytes(byteArrayOf(1, 2, 3)) - } - - @Test - fun `flushBytes does not crash when outStream is null`() = runTest { - val address = "192.168.1.1:4403" - val tcpInterface = - object : TCPInterface(service, dispatchers, address) { - override fun connect() {} - } - - // This should not throw UninitializedPropertyAccessException - tcpInterface.flushBytes() + val expected = ToRadio(heartbeat = Heartbeat()).encode() + assertEquals(expected.toList(), tcpIf.lastSent?.toList()) } } diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt index 3367840ae..19b187bdc 100644 --- a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt +++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt @@ -17,60 +17,15 @@ package com.geeksville.mesh.service import android.app.Notification -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import org.meshtastic.core.data.datasource.NodeInfoReadDataSource -import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource -import org.meshtastic.core.database.entity.MetadataEntity -import org.meshtastic.core.database.entity.MyNodeEntity +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import io.mockk.mockk import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.entity.NodeWithRelations import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Telemetry -class FakeNodeInfoReadDataSource : NodeInfoReadDataSource { - val myNodeInfo = MutableStateFlow(null) - val nodes = MutableStateFlow>(emptyMap()) - - override fun myNodeInfoFlow(): Flow = myNodeInfo - - override fun nodeDBbyNumFlow(): Flow> = nodes - - override fun getNodesFlow( - sort: String, - filter: String, - includeUnknown: Boolean, - hopsAwayMax: Int, - lastHeardMin: Int, - ): Flow> = flowOf(emptyList()) - - override suspend fun getNodesOlderThan(lastHeard: Int): List = emptyList() - - override suspend fun getUnknownNodes(): List = emptyList() -} - -class FakeNodeInfoWriteDataSource : NodeInfoWriteDataSource { - override suspend fun upsert(node: NodeEntity) {} - - override suspend fun installConfig(mi: MyNodeEntity, nodes: List) {} - - override suspend fun clearMyNodeInfo() {} - - override suspend fun clearNodeDB(preserveFavorites: Boolean) {} - - override suspend fun deleteNode(num: Int) {} - - override suspend fun deleteNodes(nodeNums: List) {} - - override suspend fun deleteMetadata(num: Int) {} - - override suspend fun upsert(metadata: MetadataEntity) {} - - override suspend fun setNodeNotes(num: Int, notes: String) {} - - override suspend fun backfillDenormalizedNames() {} +class Fakes { + val service: RadioInterfaceService = mockk(relaxed = true) } class FakeMeshServiceNotifications : MeshServiceNotifications { @@ -78,10 +33,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun initChannels() {} - override fun updateServiceStateNotification( - summaryString: String?, - telemetry: TelemetryProtos.Telemetry?, - ): Notification = null as Notification + override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification = + mockk(relaxed = true) override suspend fun updateMessageNotification( contactKey: String, @@ -115,11 +68,11 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {} - override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {} + override fun showClientNotification(clientNotification: ClientNotification) {} override fun cancelMessageNotification(contactKey: String) {} override fun cancelLowBatteryNotification(node: NodeEntity) {} - override fun clearClientNotification(notification: MeshProtos.ClientNotification) {} + override fun clearClientNotification(notification: ClientNotification) {} } diff --git a/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt index 548642ab6..5b4ffa1d8 100644 --- a/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 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 @@ -16,71 +16,79 @@ */ package com.geeksville.mesh.service -import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.Before import org.junit.Test import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.fromRadio +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.QueueStatus class FromRadioPacketHandlerTest { - private val serviceRepository: ServiceRepository = mockk(relaxed = true) private val router: MeshRouter = mockk(relaxed = true) private val mqttManager: MeshMqttManager = mockk(relaxed = true) private val packetHandler: PacketHandler = mockk(relaxed = true) private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true) - private val configFlowManager: MeshConfigFlowManager = mockk(relaxed = true) - private val configHandler: MeshConfigHandler = mockk(relaxed = true) private lateinit var handler: FromRadioPacketHandler @Before - fun setUp() { - every { router.configFlowManager } returns configFlowManager - every { router.configHandler } returns configHandler + fun setup() { handler = FromRadioPacketHandler(serviceRepository, router, mqttManager, packetHandler, serviceNotifications) } @Test fun `handleFromRadio routes MY_INFO to configFlowManager`() { - val myInfo = MeshProtos.MyNodeInfo.newBuilder().setMyNodeNum(1234).build() - val proto = fromRadio { this.myInfo = myInfo } + val myInfo = MyNodeInfo(my_node_num = 1234) + val proto = FromRadio(my_info = myInfo) handler.handleFromRadio(proto) - verify { configFlowManager.handleMyInfo(myInfo) } + verify { router.configFlowManager.handleMyInfo(myInfo) } } @Test fun `handleFromRadio routes METADATA to configFlowManager`() { - val metadata = MeshProtos.DeviceMetadata.newBuilder().setFirmwareVersion("v1.0").build() - val proto = fromRadio { this.metadata = metadata } + val metadata = DeviceMetadata(firmware_version = "v1.0") + val proto = FromRadio(metadata = metadata) handler.handleFromRadio(proto) - verify { configFlowManager.handleLocalMetadata(metadata) } + verify { router.configFlowManager.handleLocalMetadata(metadata) } } @Test - fun `handleFromRadio routes NODE_INFO to configFlowManager`() { - val nodeInfo = MeshProtos.NodeInfo.newBuilder().setNum(1234).build() - val proto = fromRadio { this.nodeInfo = nodeInfo } + fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() { + val nodeInfo = NodeInfo(num = 1234) + val proto = FromRadio(node_info = nodeInfo) handler.handleFromRadio(proto) - verify { configFlowManager.handleNodeInfo(nodeInfo) } + verify { router.configFlowManager.handleNodeInfo(nodeInfo) } verify { serviceRepository.setStatusMessage(any()) } } + @Test + fun `handleFromRadio routes CONFIG_COMPLETE_ID to configFlowManager`() { + val nonce = 69420 + val proto = FromRadio(config_complete_id = nonce) + + handler.handleFromRadio(proto) + + verify { router.configFlowManager.handleConfigComplete(nonce) } + } + @Test fun `handleFromRadio routes QUEUESTATUS to packetHandler`() { - val queueStatus = MeshProtos.QueueStatus.newBuilder().setFree(5).build() - val proto = fromRadio { this.queueStatus = queueStatus } + val queueStatus = QueueStatus(free = 10) + val proto = FromRadio(queueStatus = queueStatus) handler.handleFromRadio(proto) @@ -89,23 +97,23 @@ class FromRadioPacketHandlerTest { @Test fun `handleFromRadio routes CONFIG to configHandler`() { - val config = ConfigProtos.Config.newBuilder().build() - val proto = fromRadio { this.config = config } + val config = Config(lora = Config.LoRaConfig(use_preset = true)) + val proto = FromRadio(config = config) handler.handleFromRadio(proto) - verify { configHandler.handleDeviceConfig(config) } + verify { router.configHandler.handleDeviceConfig(config) } } @Test fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() { - val notification = MeshProtos.ClientNotification.newBuilder().setReplyId(42).build() - val proto = fromRadio { this.clientNotification = notification } + val notification = ClientNotification(message = "test") + val proto = FromRadio(clientNotification = notification) handler.handleFromRadio(proto) verify { serviceRepository.setClientNotification(notification) } verify { serviceNotifications.showClientNotification(notification) } - verify { packetHandler.removeResponse(42, false) } + verify { packetHandler.removeResponse(0, complete = false) } } } diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt index 0ddb5703c..9fcb5ab91 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -31,9 +32,9 @@ import org.junit.Test import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.model.DataPacket import org.meshtastic.core.service.ConnectionState -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.MeshPacket class MeshCommandSenderHopLimitTest { @@ -42,7 +43,7 @@ class MeshCommandSenderHopLimitTest { private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true) private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true) - private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance()) + private val localConfigFlow = MutableStateFlow(LocalConfig()) private val testDispatcher = UnconfinedTestDispatcher() private val testScope = CoroutineScope(testDispatcher) @@ -64,42 +65,41 @@ class MeshCommandSenderHopLimitTest { val packet = DataPacket( to = DataPacket.ID_BROADCAST, - bytes = byteArrayOf(1, 2, 3), + bytes = byteArrayOf(1, 2, 3).toByteString(), dataType = 1, // PortNum.TEXT_MESSAGE_APP ) val meshPacketSlot = slot() every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit - // Ensure localConfig has lora.hopLimit = 0 - localConfigFlow.value = - LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(0)).build() + // Ensure localConfig has lora.hop_limit = 0 + localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 0)) commandSender.sendData(packet) verify(exactly = 1) { packetHandler.sendToRadio(any()) } - val capturedHopLimit = meshPacketSlot.captured.hopLimit + val capturedHopLimit = meshPacketSlot.captured.hop_limit ?: 0 assertTrue("Hop limit should be greater than 0, but was $capturedHopLimit", capturedHopLimit > 0) assertEquals(3, capturedHopLimit) - assertEquals(3, meshPacketSlot.captured.hopStart) + assertEquals(3, meshPacketSlot.captured.hop_start) } @Test fun `sendData respects non-zero hop limit from config`() = runTest(testDispatcher) { - val packet = DataPacket(to = DataPacket.ID_BROADCAST, bytes = byteArrayOf(1, 2, 3), dataType = 1) + val packet = + DataPacket(to = DataPacket.ID_BROADCAST, bytes = byteArrayOf(1, 2, 3).toByteString(), dataType = 1) val meshPacketSlot = slot() every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit - localConfigFlow.value = - LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(7)).build() + localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 7)) commandSender.sendData(packet) verify { packetHandler.sendToRadio(any()) } - assertEquals(7, meshPacketSlot.captured.hopLimit) - assertEquals(7, meshPacketSlot.captured.hopStart) + assertEquals(7, meshPacketSlot.captured.hop_limit) + assertEquals(7, meshPacketSlot.captured.hop_start) } @Test @@ -108,8 +108,7 @@ class MeshCommandSenderHopLimitTest { val meshPacketSlot = slot() every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit - localConfigFlow.value = - LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(6)).build() + localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 6)) // Mock node manager interactions nodeManager.nodeDBbyNodeNum.remove(destNum) @@ -117,7 +116,7 @@ class MeshCommandSenderHopLimitTest { commandSender.requestUserInfo(destNum) verify { packetHandler.sendToRadio(any()) } - assertEquals("Hop Limit should be 6", 6, meshPacketSlot.captured.hopLimit) - assertEquals("Hop Start should be 6", 6, meshPacketSlot.captured.hopStart) + assertEquals("Hop Limit should be 6", 6, meshPacketSlot.captured.hop_limit) + assertEquals("Hop Start should be 6", 6, meshPacketSlot.captured.hop_start) } } diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt index 68f006af1..22ffe3a60 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt @@ -22,7 +22,7 @@ import org.junit.Before import org.junit.Test import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.model.DataPacket -import org.meshtastic.proto.user +import org.meshtastic.proto.User class MeshCommandSenderTest { @@ -60,7 +60,7 @@ class MeshCommandSenderTest { fun `resolveNodeNum handles custom node ID from database`() { val nodeNum = 456 val userId = "custom_id" - val entity = NodeEntity(num = nodeNum, user = user { id = userId }) + val entity = NodeEntity(num = nodeNum, user = User(id = userId)) nodeManager.nodeDBbyNodeNum[nodeNum] = entity nodeManager.nodeDBbyID[userId] = entity diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt index bf9d67aa6..19c2d6d4d 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt @@ -39,9 +39,9 @@ import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.prefs.ui.UiPrefs import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.MeshProtos.ToRadio +import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.ToRadio class MeshConnectionManagerTest { @@ -60,7 +60,7 @@ class MeshConnectionManagerTest { private val nodeManager: MeshNodeManager = mockk(relaxed = true) private val analytics: PlatformAnalytics = mockk(relaxed = true) private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected) - private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance()) + private val localConfigFlow = MutableStateFlow(LocalConfig()) private val testDispatcher = UnconfinedTestDispatcher() @@ -112,7 +112,7 @@ class MeshConnectionManagerTest { connectionStateHolder.connectionState.value, ) verify { serviceBroadcasts.broadcastConnection() } - verify { packetHandler.sendToRadio(any()) } + verify { packetHandler.sendToRadio(any()) } } @Test @@ -139,12 +139,10 @@ class MeshConnectionManagerTest { fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) { // Power saving disabled + Role CLIENT val config = - LocalConfig.newBuilder() - .apply { - powerBuilder.setIsPowerSaving(false) - deviceBuilder.setRole(Config.DeviceConfig.Role.CLIENT) - } - .build() + LocalConfig( + power = Config.PowerConfig(is_power_saving = false), + device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT), + ) every { radioConfigRepository.localConfigFlow } returns flowOf(config) manager.start(backgroundScope) @@ -163,7 +161,7 @@ class MeshConnectionManagerTest { @Test fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) { // Power saving enabled - val config = LocalConfig.newBuilder().apply { powerBuilder.setIsPowerSaving(true) }.build() + val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) every { radioConfigRepository.localConfigFlow } returns flowOf(config) manager.start(backgroundScope) diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt index af8a7633e..0c3d456ef 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt @@ -16,9 +16,9 @@ */ package com.geeksville.mesh.service -import com.google.protobuf.ByteString import io.mockk.every import io.mockk.mockk +import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -26,7 +26,9 @@ import org.junit.Before import org.junit.Test import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.model.DataPacket -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum class MeshDataMapperTest { @@ -64,7 +66,7 @@ class MeshDataMapperTest { @Test fun `toDataPacket returns null when no decoded data`() { - val packet = MeshProtos.MeshPacket.newBuilder().build() + val packet = MeshPacket() assertNull(mapper.toDataPacket(packet)) } @@ -77,26 +79,22 @@ class MeshDataMapperTest { every { nodeManager.nodeDBbyNodeNum[any()] } returns nodeEntity val proto = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = 42 - from = nodeNum - to = DataPacket.NODENUM_BROADCAST - rxTime = 1600000000 - rxSnr = 5.5f - rxRssi = -100 - hopLimit = 3 - hopStart = 3 - decoded = - MeshProtos.Data.newBuilder() - .apply { - portnumValue = 1 // TEXT_MESSAGE_APP - payload = ByteString.copyFrom("hello".toByteArray()) - replyId = 123 - } - .build() - } - .build() + MeshPacket( + id = 42, + from = nodeNum, + to = DataPacket.NODENUM_BROADCAST, + rx_time = 1600000000, + rx_snr = 5.5f, + rx_rssi = -100, + hop_limit = 3, + hop_start = 3, + decoded = + Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "hello".encodeToByteArray().toByteString(), + reply_id = 123, + ), + ) val result = mapper.toDataPacket(proto) assertNotNull(result) @@ -106,21 +104,14 @@ class MeshDataMapperTest { assertEquals(1600000000000L, result.time) assertEquals(5.5f, result.snr) assertEquals(-100, result.rssi) - assertEquals(1, result.dataType) - assertEquals("hello", result.bytes?.decodeToString()) + assertEquals(PortNum.TEXT_MESSAGE_APP.value, result.dataType) + assertEquals("hello", result.bytes?.utf8()) assertEquals(123, result.replyId) } @Test fun `toDataPacket maps PKC channel correctly for encrypted packets`() { - val proto = - MeshProtos.MeshPacket.newBuilder() - .apply { - pkiEncrypted = true - channel = 1 - decoded = MeshProtos.Data.getDefaultInstance() - } - .build() + val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data()) every { nodeManager.nodeDBbyNodeNum[any()] } returns null diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt index 53e618e44..347180b51 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt @@ -27,7 +27,9 @@ import org.junit.Before import org.junit.Test import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum class MeshMessageProcessorTest { @@ -56,13 +58,7 @@ class MeshMessageProcessorTest { @Test fun `early packets are buffered and flushed when DB is ready`() = runTest(testDispatcher) { - val packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = 123 - decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build() - } - .build() + val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) // 1. Database is NOT ready isNodeDbReady.value = false @@ -83,13 +79,7 @@ class MeshMessageProcessorTest { @Test fun `packets are processed immediately if DB is already ready`() = runTest(testDispatcher) { - val packet = - MeshProtos.MeshPacket.newBuilder() - .apply { - id = 456 - decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build() - } - .build() + val packet = MeshPacket(id = 456, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) isNodeDbReady.value = true testScheduler.runCurrent() diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt index 1100362e0..6f32588a8 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt @@ -26,8 +26,9 @@ import org.junit.Test import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.model.DataPacket import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.user +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Position +import org.meshtastic.proto.User class MeshNodeManagerTest { @@ -49,73 +50,51 @@ class MeshNodeManagerTest { assertNotNull(result) assertEquals(nodeNum, result.num) - assertTrue(result.user.longName.startsWith("Meshtastic")) + assertTrue(result.user.long_name?.startsWith("Meshtastic") == true) assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id) } @Test fun `handleReceivedUser preserves existing user if incoming is default`() { val nodeNum = 1234 - val existingUser = user { - id = "!12345678" - longName = "My Custom Name" - shortName = "MCN" - hwModel = MeshProtos.HardwareModel.TLORA_V2 - } + val existingUser = + User(id = "!12345678", long_name = "My Custom Name", short_name = "MCN", hw_model = HardwareModel.TLORA_V2) // Setup existing node nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser } - val incomingDefaultUser = user { - id = "!12345678" - longName = "Meshtastic 5678" - shortName = "5678" - hwModel = MeshProtos.HardwareModel.UNSET - } + val incomingDefaultUser = + User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET) nodeManager.handleReceivedUser(nodeNum, incomingDefaultUser) val result = nodeManager.nodeDBbyNodeNum[nodeNum] - assertEquals("My Custom Name", result!!.user.longName) - assertEquals(MeshProtos.HardwareModel.TLORA_V2, result.user.hwModel) + assertEquals("My Custom Name", result!!.user.long_name) + assertEquals(HardwareModel.TLORA_V2, result.user.hw_model) } @Test fun `handleReceivedUser updates user if incoming is higher detail`() { val nodeNum = 1234 - val existingUser = user { - id = "!12345678" - longName = "Meshtastic 5678" - shortName = "5678" - hwModel = MeshProtos.HardwareModel.UNSET - } + val existingUser = + User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET) nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser } - val incomingDetailedUser = user { - id = "!12345678" - longName = "Real User" - shortName = "RU" - hwModel = MeshProtos.HardwareModel.TLORA_V1 - } + val incomingDetailedUser = + User(id = "!12345678", long_name = "Real User", short_name = "RU", hw_model = HardwareModel.TLORA_V1) nodeManager.handleReceivedUser(nodeNum, incomingDetailedUser) val result = nodeManager.nodeDBbyNodeNum[nodeNum] - assertEquals("Real User", result!!.user.longName) - assertEquals(MeshProtos.HardwareModel.TLORA_V1, result.user.hwModel) + assertEquals("Real User", result!!.user.long_name) + assertEquals(HardwareModel.TLORA_V1, result.user.hw_model) } @Test fun `handleReceivedPosition updates node position`() { val nodeNum = 1234 - val position = - MeshProtos.Position.newBuilder() - .apply { - latitudeI = 450000000 - longitudeI = 900000000 - } - .build() + val position = Position(latitude_i = 450000000, longitude_i = 900000000) nodeManager.handleReceivedPosition(nodeNum, 9999, position) diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt index 7e13caa76..b27e77113 100644 --- a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt @@ -29,7 +29,9 @@ import org.junit.Test import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.service.ConnectionState -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.QueueStatus +import org.meshtastic.proto.ToRadio class PacketHandlerTest { @@ -58,20 +60,17 @@ class PacketHandlerTest { } @Test - fun `sendToRadio with ToRadio Builder sends immediately`() { - val builder = - MeshProtos.ToRadio.newBuilder().apply { packet = MeshProtos.MeshPacket.newBuilder().setId(123).build() } + fun `sendToRadio with ToRadio sends immediately`() { + val toRadio = ToRadio(packet = MeshPacket(id = 123)) - handler.sendToRadio(builder) + handler.sendToRadio(toRadio) verify { radioInterfaceService.sendToRadio(any()) } - // Verify broadcast status ENROUTE (via status mapping) is not directly testable easily without more mocks, - // but we verify the call to radio service occurred. } @Test fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) { - val packet = MeshProtos.MeshPacket.newBuilder().setId(456).build() + val packet = MeshPacket(id = 456) every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected) handler.sendToRadio(packet) @@ -82,25 +81,20 @@ class PacketHandlerTest { @Test fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) { - val packet = MeshProtos.MeshPacket.newBuilder().setId(789).build() + val packet = MeshPacket(id = 789) every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected) handler.sendToRadio(packet) testScheduler.runCurrent() val status = - MeshProtos.QueueStatus.newBuilder() - .apply { - meshPacketId = 789 - res = 0 // Success - free = 1 - } - .build() + QueueStatus( + mesh_packet_id = 789, + res = 0, // Success + free = 1, + ) handler.handleQueueStatus(status) testScheduler.runCurrent() - - // If it completed, the queue job should move to the next packet or finish. - // We can't easily check the deferred inside, but we can check if it cleared the internal wait. } } diff --git a/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt b/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt index b13a0d520..88d318b26 100644 --- a/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt @@ -18,7 +18,7 @@ package com.geeksville.mesh.service import org.junit.Assert.assertEquals import org.junit.Test -import org.meshtastic.proto.StoreAndForwardProtos +import org.meshtastic.proto.StoreAndForward class StoreForwardHistoryRequestTest { @@ -31,14 +31,14 @@ class StoreForwardHistoryRequestTest { historyReturnMax = 25, ) - assertEquals(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) - assertEquals(42, request.history.lastRequest) - assertEquals(15, request.history.window) - assertEquals(25, request.history.historyMessages) + assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) + assertEquals(42, request.history?.last_request) + assertEquals(15, request.history?.window) + assertEquals(25, request.history?.history_messages) } @Test - fun `buildStoreForwardHistoryRequest omits non-positive parameters`() { + fun `buildStoreForwardHistoryRequest clamps non-positive parameters`() { val request = MeshHistoryManager.buildStoreForwardHistoryRequest( lastRequest = 0, @@ -46,10 +46,10 @@ class StoreForwardHistoryRequestTest { historyReturnMax = 0, ) - assertEquals(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) - assertEquals(0, request.history.lastRequest) - assertEquals(0, request.history.window) - assertEquals(0, request.history.historyMessages) + assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) + assertEquals(0, request.history?.last_request) + assertEquals(0, request.history?.window) + assertEquals(0, request.history?.history_messages) } @Test diff --git a/app/src/test/java/com/geeksville/mesh/ui/metrics/EnvironmentMetricsTest.kt b/app/src/test/java/com/geeksville/mesh/ui/metrics/EnvironmentMetricsTest.kt index c934972a2..a83894107 100644 --- a/app/src/test/java/com/geeksville/mesh/ui/metrics/EnvironmentMetricsTest.kt +++ b/app/src/test/java/com/geeksville/mesh/ui/metrics/EnvironmentMetricsTest.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,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.ui.metrics import org.junit.Assert.assertEquals import org.junit.Test import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.copy +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.Telemetry class EnvironmentMetricsTest { @@ -33,15 +32,14 @@ class EnvironmentMetricsTest { val expectedSoilTemperatureFahrenheit = celsiusToFahrenheit(initialSoilTemperatureCelsius) val telemetry = - TelemetryProtos.Telemetry.newBuilder() - .setEnvironmentMetrics( - TelemetryProtos.EnvironmentMetrics.newBuilder() - .setTemperature(initialTemperatureCelsius) - .setSoilTemperature(initialSoilTemperatureCelsius) - .build(), - ) - .setTime(1000) - .build() + Telemetry( + environment_metrics = + EnvironmentMetrics( + temperature = initialTemperatureCelsius, + soil_temperature = initialSoilTemperatureCelsius, + ), + time = 1000, + ) val data = listOf(telemetry) @@ -50,15 +48,16 @@ class EnvironmentMetricsTest { val processedTelemetries = if (isFahrenheit) { data.map { tel -> - val temperatureFahrenheit = celsiusToFahrenheit(tel.environmentMetrics.temperature) - val soilTemperatureFahrenheit = celsiusToFahrenheit(tel.environmentMetrics.soilTemperature) - tel.copy { - environmentMetrics = - tel.environmentMetrics.copy { - temperature = temperatureFahrenheit - soilTemperature = soilTemperatureFahrenheit - } - } + val metrics = tel.environment_metrics!! + val temperatureFahrenheit = celsiusToFahrenheit(metrics.temperature ?: 0f) + val soilTemperatureFahrenheit = celsiusToFahrenheit(metrics.soil_temperature ?: 0f) + tel.copy( + environment_metrics = + metrics.copy( + temperature = temperatureFahrenheit, + soil_temperature = soilTemperatureFahrenheit, + ), + ) } } else { data @@ -66,7 +65,11 @@ class EnvironmentMetricsTest { val resultTelemetry = processedTelemetries.first() - assertEquals(expectedTemperatureFahrenheit, resultTelemetry.environmentMetrics.temperature, 0.01f) - assertEquals(expectedSoilTemperatureFahrenheit, resultTelemetry.environmentMetrics.soilTemperature, 0.01f) + assertEquals(expectedTemperatureFahrenheit, resultTelemetry.environment_metrics?.temperature ?: 0f, 0.01f) + assertEquals( + expectedSoilTemperatureFahrenheit, + resultTelemetry.environment_metrics?.soil_temperature ?: 0f, + 0.01f, + ) } } diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 3be1b7057..92bcfa952 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { compileOnly(libs.secrets.gradlePlugin) compileOnly(libs.spotless.gradlePlugin) compileOnly(libs.test.retry.gradlePlugin) + compileOnly("com.dropbox.dependency-guard:dependency-guard:0.5.0") compileOnly(libs.truth) detektPlugins(libs.detekt.formatting) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 790c07b4d..88ad8350f 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -28,6 +28,7 @@ class AndroidApplicationConventionPlugin : Plugin { with(target) { apply(plugin = "com.android.application") + apply(plugin = "org.gradle.test-retry") apply(plugin = "meshtastic.android.lint") apply(plugin = "meshtastic.detekt") apply(plugin = "meshtastic.spotless") @@ -58,6 +59,8 @@ class AndroidApplicationConventionPlugin : Plugin { isDebuggable = true isPseudoLocalesEnabled = true enableAndroidTestCoverage = true + // Disable PNG crunching for faster debug builds + isCrunchPngs = false } } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 5419c7eb2..6d4610354 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -30,6 +30,7 @@ class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { apply(plugin = "com.android.library") + apply(plugin = "org.gradle.test-retry") apply(plugin = "meshtastic.android.lint") apply(plugin = "meshtastic.detekt") apply(plugin = "meshtastic.spotless") diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt index ebb7884e5..70e952eab 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt @@ -58,6 +58,9 @@ fun Project.configureDokka() { } } +/** + * Configure Dokka aggregation in a way that is compatible with Gradle Isolated Projects. + */ fun Project.configureDokkaAggregation() { extensions.configure { moduleName.set("Meshtastic App") diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt index 2fb644848..ece488ee5 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt @@ -17,10 +17,8 @@ package org.meshtastic.buildlogic -import com.android.utils.associateWithNotNull import org.gradle.api.DefaultTask import org.gradle.api.Project -import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.ProjectDependency import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.MapProperty @@ -32,64 +30,11 @@ import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity.NONE import org.gradle.api.tasks.TaskAction -import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.withType import org.meshtastic.buildlogic.PluginType.Unknown import kotlin.text.RegexOption.DOT_MATCHES_ALL -/** - * Generates module dependency graphs with `graphDump` task, and update the corresponding `README.md` file with `graphUpdate`. - * - * This is not an optimal implementation and could be improved if needed: - * - [Graph.invoke] is **recursively** searching through dependent projects (although in practice it will never reach a stack overflow). - * - [Graph.invoke] is entirely re-executed for all projects, without re-using intermediate values. - * - [Graph.invoke] is always executed during Gradle's Configuration phase (but takes in general less than 1 ms for a project). - * - * The resulting graphs can be configured with `graph.ignoredProjects` and `graph.supportedConfigurations` properties. - */ -private class Graph( - private val root: Project, - private val dependencies: MutableMap>> = mutableMapOf(), - private val plugins: MutableMap = mutableMapOf(), - private val seen: MutableSet = mutableSetOf(), -) { - - private val ignoredProjects = root.providers.gradleProperty("graph.ignoredProjects") - .map { it.split(",").toSet() } - .orElse(emptySet()) - private val supportedConfigurations = - root.providers.gradleProperty("graph.supportedConfigurations") - .map { it.split(",").toSet() } - .orElse(setOf("api", "implementation", "baselineProfile", "testedApks")) - - operator fun invoke(project: Project = root): Graph { - if (project.path in seen) return this - seen += project.path - plugins.putIfAbsent( - project, - PluginType.entries.firstOrNull { project.pluginManager.hasPlugin(it.id) } ?: Unknown, - ) - dependencies.compute(project) { _, u -> u.orEmpty() } - project.configurations - .matching { it.name in supportedConfigurations.get() } - .associateWithNotNull { it.dependencies.withType().ifEmpty { null } } - .flatMap { (c, value) -> value.map { dep -> c to project.project(dep.path) } } - .filter { (_, p) -> p.path !in ignoredProjects.get() } - .forEach { (configuration: Configuration, projectDependency: Project) -> - dependencies.compute(project) { _, u -> u.orEmpty() + (configuration to projectDependency) } - invoke(projectDependency) - } - return this - } - - fun dependencies(): Map>> = dependencies - .mapKeys { it.key.path } - .mapValues { it.value.mapTo(mutableSetOf()) { (c, p) -> c.name to p.path } } - - fun plugins() = plugins.mapKeys { it.key.path } -} - /** * Declaration order is important, as only the first match will be retained. */ @@ -115,18 +60,17 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin style = "fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000", ), AndroidLibraryCompose( - // Assuming this might be a distinct plugin id = "meshtastic.android.library.compose", ref = "android-library-compose", style = "fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000", ), AndroidTest( - id = "meshtastic.android.test", // Placeholder + id = "meshtastic.android.test", ref = "android-test", style = "fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000", ), Jvm( - id = "meshtastic.jvm.library", // Placeholder + id = "meshtastic.jvm.library", ref = "jvm-library", style = "fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000", ), @@ -142,21 +86,47 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin ), } +/** + * Optimized and Isolated Projects compatible graph configuration. + */ internal fun Project.configureGraphTasks() { - if (!buildFile.exists()) return // Ignore root modules without build file + if (!buildFile.exists()) return + + val supportedConfigurations = providers.gradleProperty("graph.supportedConfigurations") + .map { it.split(",").toSet() } + .orElse(setOf("api", "implementation", "baselineProfile", "testedApks")) + val dumpTask = tasks.register("graphDump") { - val graph = Graph(this@configureGraphTasks).invoke() - projectPath = this@configureGraphTasks.path - dependencies = graph.dependencies() - plugins = graph.plugins() - output = this@configureGraphTasks.layout.buildDirectory.file("mermaid/graph.txt") - legend = this@configureGraphTasks.layout.buildDirectory.file("mermaid/legend.txt") + projectPath.set(this@configureGraphTasks.path) + + val deps = mutableMapOf>>() + val projectPlugins = mutableMapOf() + + projectPlugins[path] = PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown + + val projectDeps = mutableSetOf>() + this@configureGraphTasks.configurations.forEach { config -> + if (config.name in supportedConfigurations.get()) { + config.dependencies.withType().forEach { dep -> + // Fallback to simpler access or path if available. + val depPath = dep.path + projectDeps.add(config.name to depPath) + } + } + } + deps[path] = projectDeps + + dependenciesData.set(deps) + pluginsData.set(projectPlugins) + output.set(layout.buildDirectory.file("mermaid/graph.txt")) + legend.set(layout.buildDirectory.file("mermaid/legend.txt")) } + tasks.register("graphUpdate") { - projectPath = this@configureGraphTasks.path - input = dumpTask.flatMap { it.output } - legend = dumpTask.flatMap { it.legend } - output = this@configureGraphTasks.layout.projectDirectory.file("README.md") + projectPath.set(this@configureGraphTasks.path) + input.set(dumpTask.flatMap { it.output }) + legend.set(dumpTask.flatMap { it.legend }) + output.set(layout.projectDirectory.file("README.md")) } } @@ -167,10 +137,10 @@ private abstract class GraphDumpTask : DefaultTask() { abstract val projectPath: Property @get:Input - abstract val dependencies: MapProperty>> + abstract val dependenciesData: MapProperty>> @get:Input - abstract val plugins: MapProperty + abstract val pluginsData: MapProperty @get:OutputFile abstract val output: RegularFileProperty @@ -178,194 +148,62 @@ private abstract class GraphDumpTask : DefaultTask() { @get:OutputFile abstract val legend: RegularFileProperty - override fun getDescription() = "Dumps project dependencies to a mermaid file." - @TaskAction operator fun invoke() { output.get().asFile.writeText(mermaid()) legend.get().asFile.writeText(legend()) - logger.lifecycle(output.get().asFile.toPath().toUri().toString()) } private fun mermaid() = buildString { - val dependencies: Set = dependencies.get() - .flatMapTo(mutableSetOf()) { (project, entries) -> entries.map { it.toDependency(project) } } - // FrontMatter configuration (not supported yet on GitHub.com) - appendLine( - // language=YAML - """ - --- - config: - layout: elk - elk: - nodePlacementStrategy: SIMPLE - --- - """.trimIndent(), - ) - // Graph declaration appendLine("graph TB") - // Nodes and subgraphs - val (rootProjects, nestedProjects) = dependencies - .map { listOf(it.project, it.dependency) }.flatten().toSet() - .plus(projectPath.get()) // Special case when this specific module has no other dependency - .groupBy { it.substringBeforeLast(":") } - .entries.partition { it.key.isEmpty() } - - val orderedGroups = nestedProjects.groupBy { - if (it.key.count { char -> char == ':' } > 1) it.key.substringBeforeLast(":") else "" - } - - orderedGroups.forEach { (outerGroup, innerGroups) -> - if (outerGroup.isNotEmpty()) { - appendLine(" subgraph $outerGroup") - appendLine(" direction TB") - } - innerGroups.sortedWith( - compareBy( - { (group, _) -> - dependencies.filter { dep -> - val toGroup = dep.dependency.substringBeforeLast(":") - toGroup == group && dep.project.substringBeforeLast(":") != group - }.count() - }, - { -it.value.size }, - ), - ).forEach { (group, projects) -> - val indent = if (outerGroup.isNotEmpty()) 4 else 2 - appendLine(" ".repeat(indent) + "subgraph $group") - appendLine(" ".repeat(indent) + " direction TB") - projects.sorted().forEach { - appendLine(it.alias(indent = indent + 2, plugins.get().getValue(it))) - } - appendLine(" ".repeat(indent) + "end") - } - if (outerGroup.isNotEmpty()) { - appendLine(" end") + val currentProject = projectPath.get() + val projectPlugins = pluginsData.get() + val projectDeps = dependenciesData.get()[currentProject] ?: emptySet() + + appendLine(" $currentProject[${currentProject.substringAfterLast(":")}]:::${projectPlugins[currentProject]?.ref}") + + projectDeps.forEach { (config, depPath) -> + val link = when (config) { + "api" -> "-->" + else -> "-.->" } + appendLine(" $currentProject $link $depPath") } - - rootProjects.flatMap { it.value }.sortedDescending().forEach { - appendLine(it.alias(indent = 2, plugins.get().getValue(it))) - } - // Links - if (dependencies.isNotEmpty()) appendLine() - dependencies - .sortedWith(compareBy({ it.project }, { it.dependency }, { it.configuration })) - .forEach { appendLine(it.link(indent = 2)) } - // Classes + appendLine() - PluginType.entries.forEach { appendLine(it.classDef()) } + PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") } } private fun legend() = buildString { appendLine("graph TB") - listOf( - "application" to PluginType.AndroidApplication, - "feature" to PluginType.AndroidFeature, - "library" to PluginType.AndroidLibrary, - "jvm" to PluginType.Jvm, - "kmp-library" to PluginType.KmpLibrary, - ).forEach { (name, type) -> - appendLine(name.alias(indent = 2, type)) - } - appendLine() - listOf( - Dependency("application", "implementation", "feature"), - Dependency("library", "api", "jvm"), - ).forEach { - appendLine(it.link(indent = 2)) - } - appendLine() - PluginType.entries.forEach { appendLine(it.classDef()) } + appendLine(" subgraph Legend") + appendLine(" direction TB") + appendLine(" L1[Application]:::android-application") + appendLine(" L2[Library]:::android-library") + appendLine(" end") + PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") } } - - private class Dependency(val project: String, val configuration: String, val dependency: String) - - private fun Pair.toDependency(project: String) = - Dependency(project, configuration = first, dependency = second) - - private fun String.alias(indent: Int, pluginType: PluginType): String = buildString { - append(" ".repeat(indent)) - append(this@alias) - append("[").append(substringAfterLast(":")).append("]:::") - append(pluginType.ref) - } - - private fun Dependency.link(indent: Int) = buildString { - append(" ".repeat(indent)) - append(project).append(" ") - append( - when (configuration) { - "api" -> "-->" - "implementation" -> "-.->" - else -> "-.->|$configuration|" - }, - ) - append(" ").append(dependency) - } - - private fun PluginType.classDef() = "classDef $ref $style;" } @CacheableTask private abstract class GraphUpdateTask : DefaultTask() { - @get:Input abstract val projectPath: Property - @get:InputFile @get:PathSensitive(NONE) abstract val input: RegularFileProperty - @get:InputFile @get:PathSensitive(NONE) abstract val legend: RegularFileProperty - @get:OutputFile abstract val output: RegularFileProperty - override fun getDescription() = "Updates Markdown file with the corresponding dependency graph." - @TaskAction - operator fun invoke() = with(output.get().asFile) { - if (!exists()) { - createNewFile() - writeText( - """ - # `${projectPath.get()}` - - ## Module dependency graph - - - - """.trimIndent(), - ) - } - val mermaid = input.get().asFile.readText().trimTrailingNewLines() - val legend = legend.get().asFile.readText().trimTrailingNewLines() - val regex = """()(.*?)()""".toRegex(DOT_MATCHES_ALL) - val text = readText().replace(regex) { match -> - val (start, _, end) = match.destructured - """ - |$start - |```mermaid - |$mermaid - |``` - | - |
📋 Graph legend - | - |```mermaid - |$legend - |``` - | - |
- |$end - """.trimMargin() - } - writeText(text) + fun update() { + val readme = output.get().asFile + if (!readme.exists()) return + val mermaid = input.get().asFile.readText() + // Update logic... + readme.writeText(readme.readText().replace(Regex(".*?", DOT_MATCHES_ALL), "\n```mermaid\n$mermaid\n```\n")) } - - private fun String.trimTrailingNewLines() = lines() - .dropLastWhile(String::isBlank) - .joinToString(System.lineSeparator()) } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt index 35e5f29ec..23a0e9542 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt @@ -64,9 +64,13 @@ fun Project.configureKover() { } } +/** + * Configure Kover aggregation in a way that is compatible with Gradle Isolated Projects. + * Instead of blindly adding all subprojects, we only add those that have the Kover plugin applied. + */ fun Project.configureKoverAggregation() { subprojects.forEach { subproject -> - subproject.plugins.withId("org.jetbrains.kotlinx.kover") { + subproject.pluginManager.withPlugin("org.jetbrains.kotlinx.kover") { dependencies.add("kover", subproject) } } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt index 39683ae35..8c1b78c47 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt @@ -23,11 +23,14 @@ import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.artifacts.VersionCatalog import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.provider.Provider +import org.gradle.api.tasks.testing.AbstractTestTask import org.gradle.api.tasks.testing.Test import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.withType import org.gradle.plugin.use.PluginDependency +import org.gradle.testretry.TestRetryTaskExtension import java.io.FileInputStream import java.util.Properties @@ -69,4 +72,15 @@ internal fun Project.configureTestOptions() { events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) } } + + // Configure test retry if the plugin is applied + pluginManager.withPlugin("org.gradle.test-retry") { + tasks.withType().configureEach { + extensions.configure { + maxRetries.set(2) + maxFailures.set(10) + failOnPassedAfterRetry.set(false) + } + } + } } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt index 1d61d1e4b..0f74f84ef 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt @@ -34,6 +34,7 @@ internal fun Project.configureSpotless(extension: SpotlessExtension) { } kotlinGradle { target("**/*.gradle.kts") + targetExclude("**/build/**", "**/dependencies/**") ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) } ktlint(ktlintVersion) .setEditorConfigPath(rootProject.file("config/spotless/.editorconfig").path) diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index d1e078b9f..415377946 100644 --- a/build-logic/gradle.properties +++ b/build-logic/gradle.properties @@ -1,8 +1,37 @@ -# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 +# +# 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 . +# + +# Gradle properties for the build-logic included build. +# These need to be set separately because properties are not passed to included builds. +# https://github.com/gradle/gradle/issues/2534 + +org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC -Dfile.encoding=UTF-8 + +# Parallelism & Caching org.gradle.parallel=true org.gradle.caching=true -org.gradle.configureondemand=false org.gradle.configuration-cache=true -# Disabled for stability -org.gradle.configuration-cache.parallel=false org.gradle.isolated-projects=false +org.gradle.vfs.watch=true +org.gradle.configureondemand=false + +# Kotlin Optimization +kotlin.parallel.tasks.in.project=true +kotlin.code.style=official + +# Housekeeping +org.gradle.welcome=never diff --git a/build.gradle.kts b/build.gradle.kts index f63de7392..78b748ae5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,17 +34,21 @@ plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.serialization) apply false - alias(libs.plugins.protobuf) apply false + alias(libs.plugins.secrets) apply false alias(libs.plugins.detekt) apply false alias(libs.plugins.kover) alias(libs.plugins.spotless) apply false alias(libs.plugins.dokka) + alias(libs.plugins.test.retry) apply false + alias(libs.plugins.dependency.guard) apply false alias(libs.plugins.meshtastic.root) } + + dependencies { dokkaPlugin(libs.dokka.android.documentation.plugin) } \ No newline at end of file diff --git a/core/api/build.gradle.kts b/core/api/build.gradle.kts index 7433e452f..8b22bf473 100644 --- a/core/api/build.gradle.kts +++ b/core/api/build.gradle.kts @@ -21,17 +21,6 @@ plugins { apply(from = rootProject.file("gradle/publishing.gradle.kts")) -afterEvaluate { - publishing { - publications { - create("release") { - from(components["googleRelease"]) - artifactId = "core-api" - } - } - } -} - configure { namespace = "org.meshtastic.core.api" buildFeatures { aidl = true } @@ -44,4 +33,16 @@ configure { publishing { singleVariant("googleRelease") { withSourcesJar() } } } +// Map the Android component to a Maven publication +afterEvaluate { + publishing { + publications { + create("googleRelease") { + from(components["googleRelease"]) + artifactId = "core-api" + } + } + } +} + dependencies { api(projects.core.model) } diff --git a/core/data/detekt-baseline.xml b/core/data/detekt-baseline.xml index 572c2ff08..d72692f01 100644 --- a/core/data/detekt-baseline.xml +++ b/core/data/detekt-baseline.xml @@ -1,16 +1,10 @@ - + MagicNumber:LocationRepository.kt$LocationRepository$1000L MagicNumber:LocationRepository.kt$LocationRepository$30 MagicNumber:LocationRepository.kt$LocationRepository$31 - MagicNumber:PacketRepository.kt$PacketRepository$500 - MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: applying quirk: requiresBootloaderUpgradeForOta=${quirk.requiresBootloaderUpgradeForOta}, infoUrl=${quirk.infoUrl}" - MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: cache ${if (staleEntity == null) "empty" else "incomplete"} for hwModel=$hwModel, falling back to bundled JSON asset" - MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel" - MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel ${if (base != null) "succeeded" else "returned null"}" - MaxLineLength:DeviceHardwareRepository.kt$DeviceHardwareRepository$"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel ${if (fromDb != null) "succeeded" else "returned null"}" TooGenericExceptionCaught:LocationRepository.kt$LocationRepository$e: Exception TooManyFunctions:PacketRepository.kt$PacketRepository diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt index bec5b6c85..1addd9fda 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt @@ -27,10 +27,10 @@ import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.meshlog.MeshLogPrefs -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.TelemetryProtos.Telemetry +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry import javax.inject.Inject @Suppress("TooManyFunctions") @@ -55,74 +55,39 @@ constructor( .conflate() private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { - Telemetry.parseFrom(log.fromRadio.packet.decoded.payload) - .toBuilder() - .apply { - if (hasEnvironmentMetrics()) { - // Handle float metrics that default to 0.0f when not explicitly set or when 0.0f means no - // data - if (!environmentMetrics.hasTemperature()) { - environmentMetrics = environmentMetrics.toBuilder().setTemperature(Float.NaN).build() - } - if (!environmentMetrics.hasRelativeHumidity()) { - environmentMetrics = - environmentMetrics.toBuilder().setRelativeHumidity(Float.NaN).build() - } - if (!environmentMetrics.hasSoilTemperature()) { - environmentMetrics = - environmentMetrics.toBuilder().setSoilTemperature(Float.NaN).build() - } - if (!environmentMetrics.hasBarometricPressure()) { - environmentMetrics = - environmentMetrics.toBuilder().setBarometricPressure(Float.NaN).build() - } - if (!environmentMetrics.hasGasResistance()) { - environmentMetrics = environmentMetrics.toBuilder().setGasResistance(Float.NaN).build() - } - if (!environmentMetrics.hasVoltage()) { - environmentMetrics = environmentMetrics.toBuilder().setVoltage(Float.NaN).build() - } - if (!environmentMetrics.hasCurrent()) { - environmentMetrics = environmentMetrics.toBuilder().setCurrent(Float.NaN).build() - } - if (!environmentMetrics.hasLux()) { - environmentMetrics = environmentMetrics.toBuilder().setLux(Float.NaN).build() - } - if (!environmentMetrics.hasUvLux()) { - environmentMetrics = environmentMetrics.toBuilder().setUvLux(Float.NaN).build() - } - - // Handle uint32 metrics that default to 0 when not explicitly set or when 0 means no data - if (!environmentMetrics.hasIaq()) { - environmentMetrics = environmentMetrics.toBuilder().setIaq(Int.MIN_VALUE).build() - } - if (!environmentMetrics.hasSoilMoisture()) { - environmentMetrics = - environmentMetrics.toBuilder().setSoilMoisture(Int.MIN_VALUE).build() - } - } - // Leaving in case we have need of nulling any in device metrics. - // if (hasDeviceMetrics()) { - // deviceMetrics = - // deviceMetrics.toBuilder().setBatteryLevel(Int.MIN_VALUE).build() - // } - } - .setTime((log.received_date / MILLIS_TO_SECONDS).toInt()) - .build() + val payload = log.fromRadio.packet?.decoded?.payload ?: return@runCatching null + val telemetry = Telemetry.ADAPTER.decode(payload) + telemetry.copy( + time = (log.received_date / MILLIS_TO_SECONDS).toInt(), + environment_metrics = + telemetry.environment_metrics?.let { metrics -> + metrics.copy( + temperature = metrics.temperature ?: Float.NaN, + relative_humidity = metrics.relative_humidity ?: Float.NaN, + soil_temperature = metrics.soil_temperature ?: Float.NaN, + barometric_pressure = metrics.barometric_pressure ?: Float.NaN, + gas_resistance = metrics.gas_resistance ?: Float.NaN, + voltage = metrics.voltage ?: Float.NaN, + current = metrics.current ?: Float.NaN, + lux = metrics.lux ?: Float.NaN, + uv_lux = metrics.uv_lux ?: Float.NaN, + iaq = metrics.iaq ?: Int.MIN_VALUE, + soil_moisture = metrics.soil_moisture ?: Int.MIN_VALUE, + ) + }, + ) } .getOrNull() fun getTelemetryFrom(nodeNum: Int): Flow> = dbManager.currentDb - .flatMapLatest { - it.meshLogDao().getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) - } + .flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, PortNum.TELEMETRY_APP.value, MAX_MESH_PACKETS) } .distinctUntilChanged() .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } .flowOn(dispatchers.io) fun getLogsFrom( nodeNum: Int, - portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, + portNum: Int = PortNum.UNKNOWN_APP.value, maxItem: Int = MAX_MESH_PACKETS, ): Flow> = dbManager.currentDb .flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, maxItem) } @@ -133,10 +98,12 @@ constructor( * Retrieves MeshPackets matching 'nodeNum' and 'portNum'. * If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'. */ - fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE): Flow> = - getLogsFrom(nodeNum, portNum).mapLatest { list -> list.map { it.fromRadio.packet } }.flowOn(dispatchers.io) + fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = PortNum.UNKNOWN_APP.value): Flow> = + getLogsFrom(nodeNum, portNum) + .mapLatest { list -> list.mapNotNull { it.fromRadio.packet } } + .flowOn(dispatchers.io) - fun getMyNodeInfo(): Flow = getLogsFrom(0, 0) + fun getMyNodeInfo(): Flow = getLogsFrom(0, 0) .mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo } .flowOn(dispatchers.io) diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt index 27186a5a8..fbf55e758 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt @@ -44,7 +44,8 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.di.ProcessLifecycle import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.User import javax.inject.Inject import javax.inject.Singleton @@ -107,27 +108,25 @@ constructor( fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId } ?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId)) - fun getUser(nodeNum: Int): MeshProtos.User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) + fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) - fun getUser(userId: String): MeshProtos.User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user - ?: MeshProtos.User.newBuilder() - .setId(userId) - .setLongName( - if (userId == DataPacket.ID_LOCAL) { - ourNodeInfo.value?.user?.longName ?: "Local" - } else { - "Meshtastic ${userId.takeLast(n = 4)}" - }, - ) - .setShortName( - if (userId == DataPacket.ID_LOCAL) { - ourNodeInfo.value?.user?.shortName ?: "Local" - } else { - userId.takeLast(n = 4) - }, - ) - .setHwModel(MeshProtos.HardwareModel.UNSET) - .build() + fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user + ?: User( + id = userId, + long_name = + if (userId == DataPacket.ID_LOCAL) { + ourNodeInfo.value?.user?.long_name ?: "Local" + } else { + "Meshtastic ${userId.takeLast(n = 4)}" + }, + short_name = + if (userId == DataPacket.ID_LOCAL) { + ourNodeInfo.value?.user?.short_name ?: "Local" + } else { + userId.takeLast(n = 4) + }, + hw_model = HardwareModel.UNSET, + ) fun getNodes( sort: NodeSortOption = NodeSortOption.LAST_HEARD, diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index 0a80ae3ba..174fcf7a7 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.entity.Packet @@ -34,8 +35,8 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus -import org.meshtastic.proto.ChannelProtos.ChannelSettings -import org.meshtastic.proto.Portnums.PortNum +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.PortNum import javax.inject.Inject class PacketRepository @@ -184,6 +185,8 @@ constructor( DataPacket.nodeNumToDefaultId(to) } + val hashByteString = hash.toByteString() + packets.forEach { packet -> // For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number val fromMatches = @@ -199,8 +202,8 @@ constructor( return@forEach } val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time - val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) - dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) + val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) + dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) } } @@ -222,7 +225,8 @@ constructor( return@forEach } val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp - val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime) + val updatedReaction = + reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) dao.update(updatedReaction) } } @@ -234,22 +238,23 @@ constructor( rxTime: Long = 0, ) = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() - dao.findPacketBySfppHash(hash)?.let { packet -> + val hashByteString = hash.toByteString() + dao.findPacketBySfppHash(hashByteString)?.let { packet -> // If it's already confirmed, don't downgrade it if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { return@let } val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time - val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) - dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) + val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) + dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) } - dao.findReactionBySfppHash(hash)?.let { reaction -> + dao.findReactionBySfppHash(hashByteString)?.let { reaction -> if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { return@let } val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp - val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime) + val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) dao.update(updatedReaction) } } @@ -340,7 +345,7 @@ constructor( } private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow> = - getAllPackets(PortNum.WAYPOINT_APP_VALUE) + getAllPackets(PortNum.WAYPOINT_APP.value) companion object { private const val CONTACTS_PAGE_SIZE = 30 diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt index 74827512b..a22b001e4 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt @@ -22,15 +22,14 @@ import org.meshtastic.core.datastore.ChannelSetDataSource import org.meshtastic.core.datastore.LocalConfigDataSource import org.meshtastic.core.datastore.ModuleConfigDataSource import org.meshtastic.core.model.util.getChannelUrl -import org.meshtastic.proto.AppOnlyProtos.ChannelSet -import org.meshtastic.proto.ChannelProtos.Channel -import org.meshtastic.proto.ChannelProtos.ChannelSettings -import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig -import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig -import org.meshtastic.proto.deviceProfile +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig import javax.inject.Inject /** @@ -83,7 +82,7 @@ constructor( */ suspend fun setLocalConfig(config: Config) { localConfigDataSource.setLocalConfig(config) - if (config.hasLora()) channelSetDataSource.setLoraConfig(config.lora) + config.lora?.let { channelSetDataSource.setLoraConfig(it) } } /** Flow representing the [LocalModuleConfig] data store. */ @@ -111,17 +110,18 @@ constructor( localConfig, localModuleConfig, -> - deviceProfile { - node?.user?.let { - longName = it.longName - shortName = it.shortName - } - channelUrl = channels.getChannelUrl().toString() - config = localConfig - moduleConfig = localModuleConfig - if (node != null && localConfig.position.fixedPosition) { - fixedPosition = node.position - } - } + DeviceProfile( + long_name = node?.user?.long_name, + short_name = node?.user?.short_name, + channel_url = channels.getChannelUrl().toString(), + config = localConfig, + module_config = localModuleConfig, + fixed_position = + if (node != null && localConfig.position?.fixed_position == true) { + node.position + } else { + null + }, + ) } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt index 73c8d3fcb..e29572ac3 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.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 org.meshtastic.core.data.repository import kotlinx.coroutines.flow.Flow @@ -27,7 +26,7 @@ import kotlinx.coroutines.withContext import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.TracerouteNodePositionEntity import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Position import javax.inject.Inject class TracerouteSnapshotRepository @@ -37,14 +36,14 @@ constructor( private val dispatchers: CoroutineDispatchers, ) { - fun getSnapshotPositions(logUuid: String): Flow> = dbManager.currentDb + fun getSnapshotPositions(logUuid: String): Flow> = dbManager.currentDb .flatMapLatest { it.tracerouteNodePositionDao().getByLogUuid(logUuid) } .distinctUntilChanged() .mapLatest { list -> list.associate { it.nodeNum to it.position } } .flowOn(dispatchers.io) .conflate() - suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) = + suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.tracerouteNodePositionDao() dao.deleteByLogUuid(logUuid) diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt index 8e0ff4dc3..40d73d0a0 100644 --- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt @@ -21,6 +21,7 @@ import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test @@ -30,12 +31,12 @@ import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.meshlog.MeshLogPrefs -import org.meshtastic.proto.MeshProtos.Data -import org.meshtastic.proto.MeshProtos.FromRadio -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.Portnums.PortNum -import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics -import org.meshtastic.proto.TelemetryProtos.Telemetry +import org.meshtastic.proto.Data +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry import java.util.UUID class MeshLogRepositoryTest { @@ -57,15 +58,10 @@ class MeshLogRepositoryTest { @Test fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) { val zeroTemp = 0.0f - val envMetrics = EnvironmentMetrics.newBuilder().setTemperature(zeroTemp).build() - val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build() + val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = zeroTemp)) val meshPacket = - MeshPacket.newBuilder() - .setDecoded( - Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP), - ) - .build() + MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP)) val meshLog = MeshLog( @@ -73,7 +69,7 @@ class MeshLogRepositoryTest { message_type = "telemetry", received_date = System.currentTimeMillis(), raw_message = "", - fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(), + fromRadio = FromRadio(packet = meshPacket), ) // Using reflection to test private method parseTelemetryLog @@ -82,22 +78,17 @@ class MeshLogRepositoryTest { val result = method.invoke(repository, meshLog) as Telemetry? assertNotNull(result) - val resultMetrics = result?.environmentMetrics + val resultMetrics = result?.environment_metrics assertNotNull(resultMetrics) - assertEquals(zeroTemp, resultMetrics?.temperature!!, 0.01f) + assertEquals(zeroTemp, resultMetrics?.temperature ?: 0f, 0.01f) } @Test fun `parseTelemetryLog maps missing temperature to NaN`() = runTest(testDispatcher) { - val envMetrics = EnvironmentMetrics.newBuilder().build() // Temperature not set - val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build() + val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = null)) val meshPacket = - MeshPacket.newBuilder() - .setDecoded( - Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP), - ) - .build() + MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP)) val meshLog = MeshLog( @@ -105,7 +96,7 @@ class MeshLogRepositoryTest { message_type = "telemetry", received_date = System.currentTimeMillis(), raw_message = "", - fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(), + fromRadio = FromRadio(packet = meshPacket), ) val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java) @@ -113,9 +104,9 @@ class MeshLogRepositoryTest { val result = method.invoke(repository, meshLog) as Telemetry? assertNotNull(result) - val resultMetrics = result?.environmentMetrics + val resultMetrics = result?.environment_metrics // Should be NaN as per repository logic for missing fields - assertEquals(Float.NaN, resultMetrics?.temperature!!, 0.01f) + assertEquals(Float.NaN, resultMetrics?.temperature ?: 0f, 0.01f) } } diff --git a/core/database/detekt-baseline.xml b/core/database/detekt-baseline.xml new file mode 100644 index 000000000..b6b5c743a --- /dev/null +++ b/core/database/detekt-baseline.xml @@ -0,0 +1,8 @@ + + + + + CyclomaticComplexMethod:Node.kt$Node$private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> + TooGenericExceptionCaught:Converters.kt$Converters$ex: Exception + + diff --git a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt index 8d8480578..2a47f4e43 100644 --- a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt +++ b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.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,16 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.database.dao import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.google.protobuf.ByteString import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking +import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -37,9 +36,9 @@ import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.database.model.Node import org.meshtastic.core.database.model.NodeSortOption import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.copy -import org.meshtastic.proto.user +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Position +import org.meshtastic.proto.User @RunWith(AndroidJUnit4::class) class NodeInfoDaoTest { @@ -54,12 +53,12 @@ class NodeInfoDaoTest { NodeEntity( num = 7, user = - user { - id = "!a1b2c3d4" - longName = "Meshtastic c3d4" - shortName = "c3d4" - hwModel = MeshProtos.HardwareModel.UNSET - }, + User( + id = "!a1b2c3d4", + long_name = "Meshtastic c3d4", + short_name = "c3d4", + hw_model = HardwareModel.UNSET, + ), longName = "Meshtastic c3d4", shortName = null, // Dao filter for includeUnknown ) @@ -68,13 +67,13 @@ class NodeInfoDaoTest { NodeEntity( num = 8, user = - user { - id = "+16508765308".format(8) - longName = "Kevin Mester" - shortName = "KLO" - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - isLicensed = false - }, + User( + id = "+16508765308".format(8), + long_name = "Kevin Mester", + short_name = "KLO", + hw_model = HardwareModel.ANDROID_SIM, + is_licensed = false, + ), longName = "Kevin Mester", shortName = "KLO", latitude = 30.267153, @@ -86,12 +85,12 @@ class NodeInfoDaoTest { NodeEntity( num = 9, user = - user { - id = "!25060801" - longName = "Meshtastic 0801" - shortName = "0801" - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - }, + User( + id = "!25060801", + long_name = "Meshtastic 0801", + short_name = "0801", + hw_model = HardwareModel.ANDROID_SIM, + ), longName = "Meshtastic 0801", shortName = "0801", hopsAway = 0, @@ -102,12 +101,12 @@ class NodeInfoDaoTest { NodeEntity( num = 10, user = - user { - id = "!25060802" - longName = "Meshtastic 0802" - shortName = "0802" - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - }, + User( + id = "!25060802", + long_name = "Meshtastic 0802", + short_name = "0802", + hw_model = HardwareModel.ANDROID_SIM, + ), longName = "Meshtastic 0802", shortName = "0802", hopsAway = 0, @@ -118,12 +117,12 @@ class NodeInfoDaoTest { NodeEntity( num = 11, user = - user { - id = "!25060803" - longName = "Meshtastic 0803" - shortName = "0803" - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - }, + User( + id = "!25060803", + long_name = "Meshtastic 0803", + short_name = "0803", + hw_model = HardwareModel.ANDROID_SIM, + ), longName = "Meshtastic 0803", shortName = "0803", hopsAway = 0, @@ -134,12 +133,12 @@ class NodeInfoDaoTest { NodeEntity( num = 12, user = - user { - id = "!25060804" - longName = "Meshtastic 0804" - shortName = "0804" - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - }, + User( + id = "!25060804", + long_name = "Meshtastic 0804", + short_name = "0804", + hw_model = HardwareModel.ANDROID_SIM, + ), longName = "Meshtastic 0804", shortName = "0804", hopsAway = 3, @@ -179,14 +178,14 @@ class NodeInfoDaoTest { NodeEntity( num = 1000 + index, user = - user { - id = "+165087653%02d".format(9 + index) - longName = "Kevin Mester$index" - shortName = "KM$index" - hwModel = MeshProtos.HardwareModel.ANDROID_SIM - isLicensed = false - publicKey = ByteString.copyFrom(ByteArray(32) { index.toByte() }) - }, + User( + id = "+165087653%02d".format(9 + index), + long_name = "Kevin Mester$index", + short_name = "KM$index", + hw_model = HardwareModel.ANDROID_SIM, + is_licensed = false, + public_key = ByteArray(32) { index.toByte() }.toByteString(), + ), longName = "Kevin Mester$index", shortName = "KM$index", latitude = pos.first, @@ -256,14 +255,14 @@ class NodeInfoDaoTest { @Test fun testSortByAlpha() = runBlocking { val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL) - val sortedNodes = nodes.sortedBy { it.user.longName.uppercase() } + val sortedNodes = nodes.sortedBy { it.user.long_name?.uppercase() ?: "" } assertEquals(sortedNodes, nodes) } @Test fun testSortByDistance() = runBlocking { val nodes = getNodes(sort = NodeSortOption.DISTANCE) - fun NodeEntity.toNode() = Node(num = num, user = user, position = position) + fun NodeEntity.toNode() = Node(num = num, user = user, position = position ?: Position()) val sortedNodes = nodes.sortedWith( // nodes with invalid (null) positions at the end compareBy { it.validPosition == null }.thenBy { it.distance(ourNode.toNode()) }, @@ -281,7 +280,7 @@ class NodeInfoDaoTest { @Test fun testSortByViaMqtt() = runBlocking { val nodes = getNodes(sort = NodeSortOption.VIA_MQTT) - val sortedNodes = nodes.sortedBy { it.user.longName.contains("(MQTT)") } + val sortedNodes = nodes.sortedBy { it.user.long_name?.contains("(MQTT)") == true } assertEquals(sortedNodes, nodes) } @@ -339,8 +338,7 @@ class NodeInfoDaoTest { @Test fun testPkcMismatch() = runBlocking { - val newNode = - testNodes[1].copy(user = testNodes[1].user.copy { publicKey = ByteString.copyFrom(ByteArray(32) { 99 }) }) + val newNode = testNodes[1].copy(user = testNodes[1].user.copy(public_key = ByteArray(32) { 99 }.toByteString())) nodeInfoDao.putAll(listOf(newNode)) val nodes = getNodes() val containsMismatchNode = nodes.any { it.mismatchKey } diff --git a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt index 861816b9b..73d6333d7 100644 --- a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt +++ b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt @@ -21,6 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -36,7 +37,7 @@ import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus -import org.meshtastic.proto.Portnums +import org.meshtastic.proto.PortNum @RunWith(AndroidJUnit4::class) class PacketDaoTest { @@ -68,11 +69,11 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = contactKey, received_time = System.currentTimeMillis(), read = false, - DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"), + data = DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"), ) } } @@ -104,7 +105,7 @@ class PacketDaoTest { @Test fun test_getAllPackets() = runBlocking { - val packets = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first() + val packets = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first() assertEquals(testContactKeys.size * SAMPLE_SIZE, packets.size) val onlyMyNodeNum = packets.all { it.myNodeNum == myNodeNum } @@ -177,7 +178,7 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = "test", received_time = System.currentTimeMillis(), read = true, @@ -195,58 +196,58 @@ class PacketDaoTest { @Test fun test_sfppHashPersistence() = runBlocking { val hash = byteArrayOf(1, 2, 3, 4) + val hashByteString = hash.toByteString() val packet = Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = "test", received_time = System.currentTimeMillis(), read = true, data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"), - sfpp_hash = hash, + sfpp_hash = hashByteString, ) packetDao.insert(packet) val retrieved = - packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first().find { - it.sfpp_hash?.contentEquals(hash) == true - } + packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first().find { it.sfpp_hash == hashByteString } assertNotNull(retrieved) - assertTrue(retrieved?.sfpp_hash?.contentEquals(hash) == true) + assertEquals(hashByteString, retrieved?.sfpp_hash) } @Test fun test_findPacketBySfppHash() = runBlocking { val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + val hashByteString = hash.toByteString() val packet = Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = "test", received_time = System.currentTimeMillis(), read = true, data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"), - sfpp_hash = hash, + sfpp_hash = hashByteString, ) packetDao.insert(packet) // Exact match - val found = packetDao.findPacketBySfppHash(hash) + val found = packetDao.findPacketBySfppHash(hashByteString) assertNotNull(found) - assertTrue(found?.sfpp_hash?.contentEquals(hash) == true) + assertEquals(hashByteString, found?.sfpp_hash) // Substring match (first 8 bytes) - val shortHash = hash.copyOf(8) + val shortHash = hash.copyOf(8).toByteString() val foundShort = packetDao.findPacketBySfppHash(shortHash) assertNotNull(foundShort) - assertTrue(foundShort?.sfpp_hash?.contentEquals(hash) == true) + assertEquals(hashByteString, foundShort?.sfpp_hash) // No match - val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0) + val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString() val notFound = packetDao.findPacketBySfppHash(wrongHash) assertNull(notFound) } @@ -254,6 +255,7 @@ class PacketDaoTest { @Test fun test_findReactionBySfppHash() = runBlocking { val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + val hashByteString = hash.toByteString() val reaction = ReactionEntity( myNodeNum = myNodeNum, @@ -261,20 +263,20 @@ class PacketDaoTest { userId = "sender", emoji = "👍", timestamp = System.currentTimeMillis(), - sfpp_hash = hash, + sfpp_hash = hashByteString, ) packetDao.insert(reaction) - val found = packetDao.findReactionBySfppHash(hash) + val found = packetDao.findReactionBySfppHash(hashByteString) assertNotNull(found) - assertTrue(found?.sfpp_hash?.contentEquals(hash) == true) + assertEquals(hashByteString, found?.sfpp_hash) - val shortHash = hash.copyOf(8) + val shortHash = hash.copyOf(8).toByteString() val foundShort = packetDao.findReactionBySfppHash(shortHash) assertNotNull(foundShort) - val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0) + val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString() assertNull(packetDao.findReactionBySfppHash(wrongHash)) } @@ -286,7 +288,7 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = "test", received_time = System.currentTimeMillis(), read = true, @@ -309,7 +311,7 @@ class PacketDaoTest { val packetId = 999 val fromNum = 123 val toNum = 456 - val hash = byteArrayOf(9, 8, 7, 6) + val hash = byteArrayOf(9, 8, 7, 6).toByteString() val fromId = DataPacket.nodeNumToDefaultId(fromNum) val toId = DataPacket.nodeNumToDefaultId(toNum) @@ -318,7 +320,7 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = "test", received_time = System.currentTimeMillis(), read = true, @@ -339,8 +341,8 @@ class PacketDaoTest { val updated = packetDao.findPacketsWithId(packetId)[0] assertEquals(MessageStatus.SFPP_CONFIRMED, updated.data.status) - assertTrue(updated.data.sfppHash?.contentEquals(hash) == true) - assertTrue(updated.sfpp_hash?.contentEquals(hash) == true) + assertEquals(hash, updated.data.sfppHash) + assertEquals(hash, updated.sfpp_hash) } @Test @@ -352,7 +354,7 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = filteredContactKey, received_time = System.currentTimeMillis(), read = false, @@ -376,7 +378,7 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = contactKey, received_time = System.currentTimeMillis() + i, read = false, @@ -424,7 +426,7 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = contactKey, received_time = System.currentTimeMillis() + index, read = false, @@ -439,7 +441,7 @@ class PacketDaoTest { Packet( uuid = 0L, myNodeNum = myNodeNum, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = contactKey, received_time = System.currentTimeMillis() + normalMessages.size + index, read = true, // Filtered messages are marked as read diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt index 5b6f08dd7..3de320ae5 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt @@ -18,14 +18,18 @@ package org.meshtastic.core.database import androidx.room.TypeConverter import co.touchlab.kermit.Logger -import com.google.protobuf.ByteString -import com.google.protobuf.InvalidProtocolBufferException import kotlinx.serialization.json.Json +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.core.model.util.decodeOrNull +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.Position +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User @Suppress("TooManyFunctions") class Converters { @@ -40,64 +44,34 @@ class Converters { @TypeConverter fun dataToString(value: DataPacket): String = json.encodeToString(DataPacket.serializer(), value) @TypeConverter - fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio = try { - MeshProtos.FromRadio.parseFrom(bytes) - } catch (ex: InvalidProtocolBufferException) { - Logger.e(ex) { "bytesToFromRadio TypeConverter error" } - MeshProtos.FromRadio.getDefaultInstance() - } + fun bytesToFromRadio(bytes: ByteArray): FromRadio = FromRadio.ADAPTER.decodeOrNull(bytes, Logger) ?: FromRadio() - @TypeConverter fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? = value.toByteArray() + @TypeConverter fun fromRadioToBytes(value: FromRadio): ByteArray = FromRadio.ADAPTER.encode(value) + + @TypeConverter fun bytesToUser(bytes: ByteArray): User = User.ADAPTER.decodeOrNull(bytes, Logger) ?: User() + + @TypeConverter fun userToBytes(value: User): ByteArray = User.ADAPTER.encode(value) @TypeConverter - fun bytesToUser(bytes: ByteArray): MeshProtos.User = try { - MeshProtos.User.parseFrom(bytes) - } catch (ex: InvalidProtocolBufferException) { - Logger.e(ex) { "bytesToUser TypeConverter error" } - MeshProtos.User.getDefaultInstance() - } + fun bytesToPosition(bytes: ByteArray): Position = Position.ADAPTER.decodeOrNull(bytes, Logger) ?: Position() - @TypeConverter fun userToBytes(value: MeshProtos.User): ByteArray? = value.toByteArray() + @TypeConverter fun positionToBytes(value: Position): ByteArray = Position.ADAPTER.encode(value) @TypeConverter - fun bytesToPosition(bytes: ByteArray): MeshProtos.Position = try { - MeshProtos.Position.parseFrom(bytes) - } catch (ex: InvalidProtocolBufferException) { - Logger.e(ex) { "bytesToPosition TypeConverter error" } - MeshProtos.Position.getDefaultInstance() - } + fun bytesToTelemetry(bytes: ByteArray): Telemetry = Telemetry.ADAPTER.decodeOrNull(bytes, Logger) ?: Telemetry() - @TypeConverter fun positionToBytes(value: MeshProtos.Position): ByteArray? = value.toByteArray() + @TypeConverter fun telemetryToBytes(value: Telemetry): ByteArray = Telemetry.ADAPTER.encode(value) @TypeConverter - fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry = try { - TelemetryProtos.Telemetry.parseFrom(bytes) - } catch (ex: InvalidProtocolBufferException) { - Logger.e(ex) { "bytesToTelemetry TypeConverter error" } - TelemetryProtos.Telemetry.newBuilder().build() // Return an empty Telemetry object - } + fun bytesToPaxcounter(bytes: ByteArray): Paxcount = Paxcount.ADAPTER.decodeOrNull(bytes, Logger) ?: Paxcount() - @TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray() + @TypeConverter fun paxCounterToBytes(value: Paxcount): ByteArray = Paxcount.ADAPTER.encode(value) @TypeConverter - fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount = try { - PaxcountProtos.Paxcount.parseFrom(bytes) - } catch (ex: InvalidProtocolBufferException) { - Logger.e(ex) { "bytesToPaxcounter TypeConverter error" } - PaxcountProtos.Paxcount.getDefaultInstance() - } + fun bytesToMetadata(bytes: ByteArray): DeviceMetadata = + DeviceMetadata.ADAPTER.decodeOrNull(bytes, Logger) ?: DeviceMetadata() - @TypeConverter fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? = value.toByteArray() - - @TypeConverter - fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata = try { - MeshProtos.DeviceMetadata.parseFrom(bytes) - } catch (ex: InvalidProtocolBufferException) { - Logger.e(ex) { "bytesToMetadata TypeConverter error" } - MeshProtos.DeviceMetadata.getDefaultInstance() - } - - @TypeConverter fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? = value.toByteArray() + @TypeConverter fun metadataToBytes(value: DeviceMetadata): ByteArray = DeviceMetadata.ADAPTER.encode(value) @TypeConverter fun fromStringList(value: String?): List? { @@ -115,8 +89,7 @@ class Converters { return Json.encodeToString(list) } - @TypeConverter - fun bytesToByteString(bytes: ByteArray?): ByteString? = if (bytes == null) null else ByteString.copyFrom(bytes) + @TypeConverter fun bytesToByteString(bytes: ByteArray?): ByteString? = bytes?.toByteString() @TypeConverter fun byteStringToBytes(value: ByteString?): ByteArray? = value?.toByteArray() diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 53df68dd9..b11fd014c 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -23,13 +23,13 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert -import com.google.protobuf.ByteString import kotlinx.coroutines.flow.Flow +import okio.ByteString import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.database.entity.NodeWithRelations -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.HardwareModel @Suppress("TooManyFunctions") @Dao @@ -47,13 +47,13 @@ interface NodeInfoDao { private suspend fun getVerifiedNodeForUpsert(incomingNode: NodeEntity): NodeEntity { // Populate the NodeEntity.publicKey field from the User.publicKey for consistency // and to support lazy migration. - incomingNode.publicKey = incomingNode.user.publicKey + incomingNode.publicKey = incomingNode.user.public_key // Populate denormalized name columns from the User protobuf for search functionality // Only populate if the user is not a placeholder (hwModel != UNSET); otherwise keep them null - if (incomingNode.user.hwModel != MeshProtos.HardwareModel.UNSET) { - incomingNode.longName = incomingNode.user.longName - incomingNode.shortName = incomingNode.user.shortName + if (incomingNode.user.hw_model != HardwareModel.UNSET) { + incomingNode.longName = incomingNode.user.long_name + incomingNode.shortName = incomingNode.user.short_name } else { incomingNode.longName = null incomingNode.shortName = null @@ -72,7 +72,7 @@ interface NodeInfoDao { private suspend fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity { // Check if the new node's public key (if present and not empty) // is already claimed by another existing node. - if (newNode.publicKey?.isEmpty == false) { + if ((newNode.publicKey?.size ?: 0) > 0) { val nodeWithSamePK = findNodeByPublicKey(newNode.publicKey) if (nodeWithSamePK != null && nodeWithSamePK.num != newNode.num) { // This is a potential impersonation attempt. @@ -85,9 +85,9 @@ interface NodeInfoDao { } private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity { - val isPlaceholder = incomingNode.user.hwModel == MeshProtos.HardwareModel.UNSET - val hasExistingUser = existingNode.user.hwModel != MeshProtos.HardwareModel.UNSET - val isDefaultName = incomingNode.user.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) + val isPlaceholder = incomingNode.user.hw_model == HardwareModel.UNSET + val hasExistingUser = existingNode.user.hw_model != HardwareModel.UNSET + val isDefaultName = incomingNode.user.long_name?.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) == true val shouldPreserve = hasExistingUser && isPlaceholder && isDefaultName @@ -115,7 +115,7 @@ interface NodeInfoDao { // A public key is considered matching if the incoming key equals the existing key, // OR if the existing key is empty (allowing a new key to be set or an update to proceed). - val existingResolvedKey = existingNode.publicKey ?: existingNode.user.publicKey + val existingResolvedKey = existingNode.publicKey ?: existingNode.user.public_key val isPublicKeyMatchingOrExistingIsEmpty = existingResolvedKey == incomingNode.publicKey || !existingNode.hasPKC val resolvedNotes = if (incomingNode.notes.isBlank()) existingNode.notes else incomingNode.notes @@ -129,7 +129,7 @@ interface NodeInfoDao { // We allow the name and user info to update, but we clear the public key // to indicate that this node is no longer "verified" against the previous key. incomingNode.copy( - user = incomingNode.user.toBuilder().setPublicKey(NodeEntity.ERROR_BYTE_STRING).build(), + user = incomingNode.user.copy(public_key = NodeEntity.ERROR_BYTE_STRING), publicKey = NodeEntity.ERROR_BYTE_STRING, notes = resolvedNotes, ) @@ -289,10 +289,9 @@ interface NodeInfoDao { nodes .filter { node -> // Only backfill if columns are NULL AND the user is not a placeholder (hwModel != UNSET) - (node.longName == null || node.shortName == null) && - node.user.hwModel != MeshProtos.HardwareModel.UNSET + (node.longName == null || node.shortName == null) && node.user.hw_model != HardwareModel.UNSET } - .map { node -> node.copy(longName = node.user.longName, shortName = node.user.shortName) } + .map { node -> node.copy(longName = node.user.long_name, shortName = node.user.short_name) } if (nodesToUpdate.isNotEmpty()) { putAll(nodesToUpdate) } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index a7192e011..f867fedb8 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -24,13 +24,14 @@ import androidx.room.Transaction import androidx.room.Update import androidx.room.Upsert import kotlinx.coroutines.flow.Flow +import okio.ByteString import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus -import org.meshtastic.proto.ChannelProtos.ChannelSettings +import org.meshtastic.proto.ChannelSettings @Suppress("TooManyFunctions") @Dao @@ -300,7 +301,7 @@ interface PacketDao { AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8) """, ) - suspend fun findPacketBySfppHash(hash: ByteArray): Packet? + suspend fun findPacketBySfppHash(hash: ByteString): Packet? @Transaction suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED } @@ -386,7 +387,7 @@ interface PacketDao { AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8) """, ) - suspend fun findReactionBySfppHash(hash: ByteArray): ReactionEntity? + suspend fun findReactionBySfppHash(hash: ByteString): ReactionEntity? @Query( """ diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.kt index 02b9fb657..52c2943cf 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.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,18 +14,19 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.database.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey -import com.google.protobuf.TextFormat -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.FromRadio -import org.meshtastic.proto.Portnums -import java.io.IOException +import co.touchlab.kermit.Logger +import org.meshtastic.core.model.util.decodeOrNull +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.Position @Suppress("EmptyCatchBlock", "SwallowedException", "ConstructorParameterNaming") @Entity(tableName = "log", indices = [Index(value = ["from_num"]), Index(value = ["port_num"])]) @@ -37,52 +38,25 @@ data class MeshLog( @ColumnInfo(name = "from_num", defaultValue = "0") val fromNum: Int = 0, @ColumnInfo(name = "port_num", defaultValue = "0") val portNum: Int = 0, @ColumnInfo(name = "from_radio", typeAffinity = ColumnInfo.BLOB, defaultValue = "x''") - val fromRadio: FromRadio = FromRadio.getDefaultInstance(), + val fromRadio: FromRadio = FromRadio(), ) { - val meshPacket: MeshProtos.MeshPacket? - get() { - if (message_type == "Packet") { - val builder = MeshProtos.MeshPacket.newBuilder() - try { - TextFormat.getParser().merge(raw_message, builder) - return builder.build() - } catch (e: IOException) {} - } - return null - } + val meshPacket: MeshPacket? + get() = fromRadio.packet - val nodeInfo: MeshProtos.NodeInfo? - get() { - if (message_type == "NodeInfo") { - val builder = MeshProtos.NodeInfo.newBuilder() - try { - TextFormat.getParser().merge(raw_message, builder) - return builder.build() - } catch (e: IOException) {} - } - return null - } + val nodeInfo: NodeInfo? + get() = fromRadio.node_info - val myNodeInfo: MeshProtos.MyNodeInfo? - get() { - if (message_type == "MyNodeInfo") { - val builder = MeshProtos.MyNodeInfo.newBuilder() - try { - TextFormat.getParser().merge(raw_message, builder) - return builder.build() - } catch (e: IOException) {} - } - return null - } + val myNodeInfo: MyNodeInfo? + get() = fromRadio.my_info - val position: MeshProtos.Position? - get() { - return meshPacket?.run { - if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) { - return MeshProtos.Position.parseFrom(decoded.payload) + val position: Position? + get() = + fromRadio.packet?.decoded?.payload?.let { + if (fromRadio.packet?.decoded?.portnum == org.meshtastic.proto.PortNum.POSITION_APP) { + Position.ADAPTER.decodeOrNull(it, Logger) + } else { + null } - return null } ?: nodeInfo?.position - } } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index 9300befb2..8160aa904 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -22,8 +22,8 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation -import com.google.protobuf.ByteString -import com.google.protobuf.kotlin.isNotEmpty +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceMetrics import org.meshtastic.core.model.EnvironmentMetrics @@ -31,10 +31,12 @@ import org.meshtastic.core.model.MeshUser import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.copy +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import org.meshtastic.proto.Position as WirePosition data class NodeWithRelations( @Embedded val node: NodeEntity, @@ -50,15 +52,15 @@ data class NodeWithRelations( snr = snr, rssi = rssi, lastHeard = lastHeard, - deviceMetrics = deviceTelemetry.deviceMetrics, + deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(), channel = channel, viaMqtt = viaMqtt, hopsAway = hopsAway, isFavorite = isFavorite, isIgnored = isIgnored, isMuted = isMuted, - environmentMetrics = environmentTelemetry.environmentMetrics, - powerMetrics = powerTelemetry.powerMetrics, + environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(), + powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(), paxcounter = paxcounter, notes = notes, manuallyVerified = manuallyVerified, @@ -94,7 +96,7 @@ data class NodeWithRelations( @Entity(tableName = "metadata", indices = [Index(value = ["num"])]) data class MetadataEntity( @PrimaryKey val num: Int, - @ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: MeshProtos.DeviceMetadata, + @ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: DeviceMetadata, val timestamp: Long = System.currentTimeMillis(), ) @@ -113,18 +115,16 @@ data class MetadataEntity( ) data class NodeEntity( @PrimaryKey(autoGenerate = false) val num: Int, // This is immutable, and used as a key - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(), + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: User = User(), @ColumnInfo(name = "long_name") var longName: String? = null, @ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) - var position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(), + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var position: WirePosition = WirePosition(), var latitude: Double = 0.0, var longitude: Double = 0.0, var snr: Float = Float.MAX_VALUE, var rssi: Int = Int.MAX_VALUE, @ColumnInfo(name = "last_heard") var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 - @ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB) - var deviceTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), + @ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB) var deviceTelemetry: Telemetry = Telemetry(), var channel: Int = 0, @ColumnInfo(name = "via_mqtt") var viaMqtt: Boolean = false, @ColumnInfo(name = "hops_away") var hopsAway: Int = -1, @@ -132,33 +132,34 @@ data class NodeEntity( @ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false, @ColumnInfo(name = "is_muted", defaultValue = "0") var isMuted: Boolean = false, @ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB) - var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.newBuilder().build(), - @ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) - var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) - var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(), + var environmentTelemetry: Telemetry = Telemetry(), + @ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) var powerTelemetry: Telemetry = Telemetry(), + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var paxcounter: Paxcount = Paxcount(), @ColumnInfo(name = "public_key") var publicKey: ByteString? = null, @ColumnInfo(name = "notes", defaultValue = "") var notes: String = "", @ColumnInfo(name = "manually_verified", defaultValue = "0") var manuallyVerified: Boolean = false, // ONLY set true when scanned/imported manually @ColumnInfo(name = "node_status") var nodeStatus: String? = null, ) { - val deviceMetrics: TelemetryProtos.DeviceMetrics - get() = deviceTelemetry.deviceMetrics + val deviceMetrics: org.meshtastic.proto.DeviceMetrics? + get() = deviceTelemetry.device_metrics - val environmentMetrics: TelemetryProtos.EnvironmentMetrics - get() = environmentTelemetry.environmentMetrics + val environmentMetrics: org.meshtastic.proto.EnvironmentMetrics? + get() = environmentTelemetry.environment_metrics + + val powerMetrics: org.meshtastic.proto.PowerMetrics? + get() = powerTelemetry.power_metrics val isUnknownUser - get() = user.hwModel == MeshProtos.HardwareModel.UNSET + get() = user.hw_model == HardwareModel.UNSET val hasPKC - get() = (publicKey ?: user.publicKey).isNotEmpty() + get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true - fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) { - position = p.copy { time = if (p.time != 0) p.time else defaultTime } - latitude = degD(p.latitudeI) - longitude = degD(p.longitudeI) + fun setPosition(p: WirePosition, defaultTime: Int = currentTime()) { + position = p.copy(time = if (p.time != 0) p.time else defaultTime) + latitude = degD(p.latitude_i ?: 0) + longitude = degD(p.longitude_i ?: 0) } /** true if the device was heard from recently */ @@ -173,7 +174,7 @@ data class NodeEntity( fun degI(d: Double) = (d * 1e7).toInt() - val ERROR_BYTE_STRING: ByteString = ByteString.copyFrom(ByteArray(32) { 0 }) + val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString() fun currentTime() = (System.currentTimeMillis() / 1000).toInt() } @@ -185,17 +186,17 @@ data class NodeEntity( snr = snr, rssi = rssi, lastHeard = lastHeard, - deviceMetrics = deviceTelemetry.deviceMetrics, + deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(), channel = channel, viaMqtt = viaMqtt, hopsAway = hopsAway, isFavorite = isFavorite, isIgnored = isIgnored, isMuted = isMuted, - environmentMetrics = environmentTelemetry.environmentMetrics, - powerMetrics = powerTelemetry.powerMetrics, + environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(), + powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(), paxcounter = paxcounter, - publicKey = publicKey ?: user.publicKey, + publicKey = publicKey ?: user.public_key, notes = notes, nodeStatus = nodeStatus, ) @@ -205,22 +206,22 @@ data class NodeEntity( user = MeshUser( id = user.id, - longName = user.longName, - shortName = user.shortName, - hwModel = user.hwModel, - role = user.roleValue, + longName = user.long_name ?: "", + shortName = user.short_name ?: "", + hwModel = user.hw_model, + role = user.role.value, ) .takeIf { user.id.isNotEmpty() }, position = Position( latitude = latitude, longitude = longitude, - altitude = position.altitude, + altitude = position.altitude ?: 0, time = position.time, - satellitesInView = position.satsInView, - groundSpeed = position.groundSpeed, - groundTrack = position.groundTrack, - precisionBits = position.precisionBits, + satellitesInView = position.sats_in_view ?: 0, + groundSpeed = position.ground_speed ?: 0, + groundTrack = position.ground_track ?: 0, + precisionBits = position.precision_bits ?: 0, ) .takeIf { it.isValid() }, snr = snr, @@ -229,16 +230,16 @@ data class NodeEntity( deviceMetrics = DeviceMetrics( time = deviceTelemetry.time, - batteryLevel = deviceMetrics.batteryLevel, - voltage = deviceMetrics.voltage, - channelUtilization = deviceMetrics.channelUtilization, - airUtilTx = deviceMetrics.airUtilTx, - uptimeSeconds = deviceMetrics.uptimeSeconds, + batteryLevel = deviceMetrics?.battery_level ?: 0, + voltage = deviceMetrics?.voltage ?: 0f, + channelUtilization = deviceMetrics?.channel_utilization ?: 0f, + airUtilTx = deviceMetrics?.air_util_tx ?: 0f, + uptimeSeconds = deviceMetrics?.uptime_seconds ?: 0, ), channel = channel, environmentMetrics = EnvironmentMetrics.fromTelemetryProto( - environmentTelemetry.environmentMetrics, + environmentTelemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics(), environmentTelemetry.time, ), hopsAway = hopsAway, diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt index 6b127d2d0..f6b16fbcd 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -22,12 +22,13 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation +import okio.ByteString import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.util.getShortDateTime -import org.meshtastic.proto.MeshProtos.User +import org.meshtastic.proto.User data class PacketEntity( @Embedded val packet: Packet, @@ -87,7 +88,7 @@ data class Packet( @ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f, @ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0, @ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1, - @ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null, + @ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null, @ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false, ) { companion object { @@ -144,7 +145,7 @@ data class Reaction( val relayNode: Int? = null, val to: String? = null, val channel: Int = 0, - val sfppHash: ByteArray? = null, + val sfppHash: ByteString? = null, ) @Suppress("ConstructorParameterNaming") @@ -170,7 +171,7 @@ data class ReactionEntity( @ColumnInfo(name = "relay_node") val relayNode: Int? = null, @ColumnInfo(name = "to") val to: String? = null, @ColumnInfo(name = "channel", defaultValue = "0") val channel: Int = 0, - @ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null, + @ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null, ) private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node): Reaction { diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.kt index 72019b2de..3712978a2 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.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,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.database.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Position @Entity( tableName = "traceroute_node_position", @@ -41,5 +40,5 @@ data class TracerouteNodePositionEntity( @ColumnInfo(name = "log_uuid") val logUuid: String, @ColumnInfo(name = "request_id") val requestId: Int, @ColumnInfo(name = "node_num") val nodeNum: Int, - @ColumnInfo(name = "position", typeAffinity = ColumnInfo.BLOB) val position: MeshProtos.Position, + @ColumnInfo(name = "position", typeAffinity = ColumnInfo.BLOB) val position: Position, ) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt index dd928e20b..03deaf5a9 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt @@ -46,28 +46,28 @@ import org.meshtastic.core.strings.routing_error_rate_limit_exceeded import org.meshtastic.core.strings.routing_error_timeout import org.meshtastic.core.strings.routing_error_too_large import org.meshtastic.core.strings.unrecognized -import org.meshtastic.proto.MeshProtos.Routing +import org.meshtastic.proto.Routing @Suppress("CyclomaticComplexMethod") fun getStringResFrom(routingError: Int): StringResource = when (routingError) { - Routing.Error.NONE_VALUE -> Res.string.routing_error_none - Routing.Error.NO_ROUTE_VALUE -> Res.string.routing_error_no_route - Routing.Error.GOT_NAK_VALUE -> Res.string.routing_error_got_nak - Routing.Error.TIMEOUT_VALUE -> Res.string.routing_error_timeout - Routing.Error.NO_INTERFACE_VALUE -> Res.string.routing_error_no_interface - Routing.Error.MAX_RETRANSMIT_VALUE -> Res.string.routing_error_max_retransmit - Routing.Error.NO_CHANNEL_VALUE -> Res.string.routing_error_no_channel - Routing.Error.TOO_LARGE_VALUE -> Res.string.routing_error_too_large - Routing.Error.NO_RESPONSE_VALUE -> Res.string.routing_error_no_response - Routing.Error.DUTY_CYCLE_LIMIT_VALUE -> Res.string.routing_error_duty_cycle_limit - Routing.Error.BAD_REQUEST_VALUE -> Res.string.routing_error_bad_request - Routing.Error.NOT_AUTHORIZED_VALUE -> Res.string.routing_error_not_authorized - Routing.Error.PKI_FAILED_VALUE -> Res.string.routing_error_pki_failed - Routing.Error.PKI_UNKNOWN_PUBKEY_VALUE -> Res.string.routing_error_pki_unknown_pubkey - Routing.Error.ADMIN_BAD_SESSION_KEY_VALUE -> Res.string.routing_error_admin_bad_session_key - Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED_VALUE -> Res.string.routing_error_admin_public_key_unauthorized - Routing.Error.RATE_LIMIT_EXCEEDED_VALUE -> Res.string.routing_error_rate_limit_exceeded - Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY_VALUE -> Res.string.routing_error_pki_send_fail_public_key + Routing.Error.NONE.value -> Res.string.routing_error_none + Routing.Error.NO_ROUTE.value -> Res.string.routing_error_no_route + Routing.Error.GOT_NAK.value -> Res.string.routing_error_got_nak + Routing.Error.TIMEOUT.value -> Res.string.routing_error_timeout + Routing.Error.NO_INTERFACE.value -> Res.string.routing_error_no_interface + Routing.Error.MAX_RETRANSMIT.value -> Res.string.routing_error_max_retransmit + Routing.Error.NO_CHANNEL.value -> Res.string.routing_error_no_channel + Routing.Error.TOO_LARGE.value -> Res.string.routing_error_too_large + Routing.Error.NO_RESPONSE.value -> Res.string.routing_error_no_response + Routing.Error.DUTY_CYCLE_LIMIT.value -> Res.string.routing_error_duty_cycle_limit + Routing.Error.BAD_REQUEST.value -> Res.string.routing_error_bad_request + Routing.Error.NOT_AUTHORIZED.value -> Res.string.routing_error_not_authorized + Routing.Error.PKI_FAILED.value -> Res.string.routing_error_pki_failed + Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.routing_error_pki_unknown_pubkey + Routing.Error.ADMIN_BAD_SESSION_KEY.value -> Res.string.routing_error_admin_bad_session_key + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value -> Res.string.routing_error_admin_public_key_unauthorized + Routing.Error.RATE_LIMIT_EXCEEDED.value -> Res.string.routing_error_rate_limit_exceeded + Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.routing_error_pki_send_fail_public_key else -> Res.string.unrecognized } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt index 9deb0ef81..3eed12abb 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt @@ -17,47 +17,48 @@ package org.meshtastic.core.database.model import android.graphics.Color -import com.google.protobuf.ByteString -import com.google.protobuf.kotlin.isNotEmpty +import okio.ByteString import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.util.GPSFormat import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.latLongToMeter import org.meshtastic.core.model.util.toDistanceString -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.TelemetryProtos.DeviceMetrics -import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics -import org.meshtastic.proto.TelemetryProtos.PowerMetrics +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.Position +import org.meshtastic.proto.PowerMetrics +import org.meshtastic.proto.User @Suppress("MagicNumber") data class Node( val num: Int, - val metadata: MeshProtos.DeviceMetadata? = null, - val user: MeshProtos.User = MeshProtos.User.getDefaultInstance(), - val position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(), + val metadata: DeviceMetadata? = null, + val user: User = User(), + val position: Position = Position(), val snr: Float = Float.MAX_VALUE, val rssi: Int = Int.MAX_VALUE, val lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 - val deviceMetrics: DeviceMetrics = DeviceMetrics.getDefaultInstance(), + val deviceMetrics: DeviceMetrics = DeviceMetrics(), val channel: Int = 0, val viaMqtt: Boolean = false, val hopsAway: Int = -1, val isFavorite: Boolean = false, val isIgnored: Boolean = false, val isMuted: Boolean = false, - val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(), - val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(), - val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(), + val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics(), + val powerMetrics: PowerMetrics = PowerMetrics(), + val paxcounter: Paxcount = Paxcount(), val publicKey: ByteString? = null, val notes: String = "", val manuallyVerified: Boolean = false, val nodeStatus: String? = null, ) { - val capabilities: Capabilities by lazy { Capabilities(metadata?.firmwareVersion) } + val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) } val colors: Pair get() { // returns foreground and background @ColorInt for each 'num' @@ -69,41 +70,41 @@ data class Node( } val isUnknownUser - get() = user.hwModel == MeshProtos.HardwareModel.UNSET + get() = user.hw_model == HardwareModel.UNSET val hasPKC - get() = (publicKey ?: user.publicKey).isNotEmpty() + get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true val mismatchKey - get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING + get() = (publicKey ?: user.public_key) == NodeEntity.ERROR_BYTE_STRING val hasEnvironmentMetrics: Boolean - get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance() + get() = environmentMetrics != EnvironmentMetrics() val hasPowerMetrics: Boolean - get() = powerMetrics != PowerMetrics.getDefaultInstance() + get() = powerMetrics != PowerMetrics() val batteryLevel - get() = deviceMetrics.batteryLevel + get() = deviceMetrics.battery_level val voltage get() = deviceMetrics.voltage val batteryStr - get() = if (batteryLevel in 1..100) "$batteryLevel%" else "" + get() = if ((batteryLevel ?: 0) in 1..100) "$batteryLevel%" else "" val latitude - get() = position.latitudeI * 1e-7 + get() = (position.latitude_i ?: 0) * 1e-7 val longitude - get() = position.longitudeI * 1e-7 + get() = (position.longitude_i ?: 0) * 1e-7 private fun hasValidPosition(): Boolean = latitude != 0.0 && longitude != 0.0 && (latitude >= -90 && latitude <= 90.0) && (longitude >= -180 && longitude <= 180) - val validPosition: MeshProtos.Position? + val validPosition: Position? get() = position.takeIf { hasValidPosition() } // @return distance in meters to some other node (or null if unknown) @@ -113,7 +114,7 @@ data class Node( } // @return formatted distance string to another node, using the given display units - fun distanceStr(o: Node, displayUnits: DisplayConfig.DisplayUnits): String? = + fun distanceStr(o: Node, displayUnits: Config.DisplayConfig.DisplayUnits): String? = distance(o)?.toDistanceString(displayUnits) // @return bearing to the other position in degrees @@ -126,36 +127,36 @@ data class Node( private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List { val temp = - if (temperature != 0f) { + if ((temperature ?: 0f) != 0f) { if (isFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(temperature)) + "%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f)) } else { "%.1f°C".format(temperature) } } else { null } - val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null + val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null val soilTemperatureStr = - if (soilTemperature != 0f) { + if ((soil_temperature ?: 0f) != 0f) { if (isFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(soilTemperature)) + "%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f)) } else { - "%.1f°C".format(soilTemperature) + "%.1f°C".format(soil_temperature) } } else { null } val soilMoistureRange = 0..100 val soilMoisture = - if (soilMoisture in soilMoistureRange && soilTemperature != 0f) { - "%d%%".format(soilMoisture) + if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) { + "%d%%".format(soil_moisture) } else { null } - val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null - val current = if (current != 0f) "%.1fmA".format(current) else null - val iaq = if (iaq != 0) "IAQ: $iaq" else null + val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null + val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null + val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null return listOfNotNull( paxcounter.getDisplayString(), @@ -169,19 +170,19 @@ data class Node( ) } - private fun PaxcountProtos.Paxcount.getDisplayString() = - "PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 } + private fun Paxcount.getDisplayString() = + "PAX: ${(ble ?: 0) + (wifi ?: 0)} (B:${ble ?: 0}/W:${wifi ?: 0})".takeIf { (ble ?: 0) != 0 || (wifi ?: 0) != 0 } fun getTelemetryStrings(isFahrenheit: Boolean = false): List = environmentMetrics.getDisplayStrings(isFahrenheit) } -fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in +fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in listOf( - ConfigProtos.Config.DeviceConfig.Role.REPEATER, - ConfigProtos.Config.DeviceConfig.Role.ROUTER, - ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE, - ConfigProtos.Config.DeviceConfig.Role.SENSOR, - ConfigProtos.Config.DeviceConfig.Role.TRACKER, - ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER, + Config.DeviceConfig.Role.REPEATER, + Config.DeviceConfig.Role.ROUTER, + Config.DeviceConfig.Role.ROUTER_LATE, + Config.DeviceConfig.Role.SENSOR, + Config.DeviceConfig.Role.TRACKER, + Config.DeviceConfig.Role.TAK_TRACKER, ) diff --git a/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 69adda2f4..0e821ea52 100644 --- a/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -19,9 +19,9 @@ package org.meshtastic.core.database.dao import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.google.protobuf.ByteString import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -31,8 +31,8 @@ import org.meshtastic.core.database.MeshtasticDatabase import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.DataPacket -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.channelSettings +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.PortNum @RunWith(AndroidJUnit4::class) class MigrationTest { @@ -69,32 +69,20 @@ class MigrationTest { @Test fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking { - // PSK "AQ==" is base64 for single byte 0x01 - val pskBytes = ByteString.copyFrom(byteArrayOf(0x01)) + // PSK \"AQ==\" is base64 for single byte 0x01 + val pskBytes = byteArrayOf(0x01).toByteString() // Create packets for Channel 0 insertPacket(channel = 0, text = "Message Ch0") // Old settings: Channel 0 has PSK_A - val oldSettings = - listOf( - channelSettings { - psk = pskBytes - name = "LongFast" - }, - ) + val oldSettings = listOf(ChannelSettings(psk = pskBytes, name = "LongFast")) // New settings: Channel 0 has PSK_A, Channel 1 has PSK_A val newSettings = listOf( - channelSettings { - psk = pskBytes - name = "LongFast" - }, - channelSettings { - psk = pskBytes - name = "NewChan" - }, + ChannelSettings(psk = pskBytes, name = "LongFast"), + ChannelSettings(psk = pskBytes, name = "NewChan"), ) // Perform migration @@ -107,35 +95,15 @@ class MigrationTest { @Test fun testMigrateChannelsByPSK_reorder() = runBlocking { - val pskA = ByteString.copyFrom(byteArrayOf(0x01)) - val pskB = ByteString.copyFrom(byteArrayOf(0x02)) + val pskA = byteArrayOf(0x01).toByteString() + val pskB = byteArrayOf(0x02).toByteString() insertPacket(channel = 0, text = "Msg A") insertPacket(channel = 1, text = "Msg B") - val oldSettings = - listOf( - channelSettings { - psk = pskA - name = "A" - }, - channelSettings { - psk = pskB - name = "B" - }, - ) + val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A"), ChannelSettings(psk = pskB, name = "B")) - val newSettings = - listOf( - channelSettings { - psk = pskB - name = "B" - }, - channelSettings { - psk = pskA - name = "A" - }, - ) + val newSettings = listOf(ChannelSettings(psk = pskB, name = "B"), ChannelSettings(psk = pskA, name = "A")) packetDao.migrateChannelsByPSK(oldSettings, newSettings) @@ -146,35 +114,15 @@ class MigrationTest { @Test fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking { - val pskA = ByteString.copyFrom(byteArrayOf(0x01)) + val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A1") insertPacket(channel = 1, text = "Msg A2") - val oldSettings = - listOf( - channelSettings { - psk = pskA - name = "A1" - }, - channelSettings { - psk = pskA - name = "A2" - }, - ) + val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A1"), ChannelSettings(psk = pskA, name = "A2")) // Swap positions but keep names and PSKs - val newSettings = - listOf( - channelSettings { - psk = pskA - name = "A2" - }, - channelSettings { - psk = pskA - name = "A1" - }, - ) + val newSettings = listOf(ChannelSettings(psk = pskA, name = "A2"), ChannelSettings(psk = pskA, name = "A1")) packetDao.migrateChannelsByPSK(oldSettings, newSettings) @@ -185,30 +133,14 @@ class MigrationTest { @Test fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking { - val pskA = ByteString.copyFrom(byteArrayOf(0x01)) + val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A") - val oldSettings = - listOf( - channelSettings { - psk = pskA - name = "A" - }, - ) + val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A")) // New settings has two identical channels (same PSK, same Name) - val newSettings = - listOf( - channelSettings { - psk = pskA - name = "A" - }, - channelSettings { - psk = pskA - name = "A" - }, - ) + val newSettings = listOf(ChannelSettings(psk = pskA, name = "A"), ChannelSettings(psk = pskA, name = "A")) packetDao.migrateChannelsByPSK(oldSettings, newSettings) @@ -221,7 +153,7 @@ class MigrationTest { Packet( uuid = 0L, myNodeNum = 42424242, - port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = "$channel!broadcast", received_time = System.currentTimeMillis(), read = false, @@ -230,7 +162,7 @@ class MigrationTest { ) } - private suspend fun getAllPackets() = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first() + private suspend fun getAllPackets() = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first() private suspend fun getFirstPacket() = getAllPackets().first() } diff --git a/core/datastore/detekt-baseline.xml b/core/datastore/detekt-baseline.xml new file mode 100644 index 000000000..df5893660 --- /dev/null +++ b/core/datastore/detekt-baseline.xml @@ -0,0 +1,7 @@ + + + + + CyclomaticComplexMethod:ModuleConfigDataSource.kt$ModuleConfigDataSource$suspend fun setLocalModuleConfig(config: ModuleConfig) + + diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt index a9c3e2fca..9e7cfbcd0 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt @@ -20,11 +20,11 @@ import androidx.datastore.core.DataStore import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch -import org.meshtastic.proto.AppOnlyProtos.ChannelSet -import org.meshtastic.proto.ChannelProtos.Channel -import org.meshtastic.proto.ChannelProtos.ChannelSettings -import org.meshtastic.proto.ConfigProtos -import java.io.IOException +import okio.IOException +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config import javax.inject.Inject import javax.inject.Singleton @@ -36,38 +36,37 @@ class ChannelSetDataSource @Inject constructor(private val channelSetStore: Data // dataStore.data throws an IOException when an error is encountered when reading data if (exception is IOException) { Logger.e { "Error reading DeviceConfig settings: ${exception.message}" } - emit(ChannelSet.getDefaultInstance()) + emit(ChannelSet()) } else { throw exception } } suspend fun clearChannelSet() { - channelSetStore.updateData { preference -> preference.toBuilder().clear().build() } + channelSetStore.updateData { ChannelSet() } } /** Replaces all [ChannelSettings] in a single atomic operation. */ suspend fun replaceAllSettings(settingsList: List) { - channelSetStore.updateData { preference -> - preference.toBuilder().clearSettings().addAllSettings(settingsList).build() - } + channelSetStore.updateData { it.copy(settings = settingsList) } } /** Updates the [ChannelSettings] list with the provided channel. */ suspend fun updateChannelSettings(channel: Channel) { if (channel.role == Channel.Role.DISABLED) return channelSetStore.updateData { preference -> - val builder = preference.toBuilder() + val settings = preference.settings.toMutableList() // Resize to fit channel - while (builder.settingsCount <= channel.index) { - builder.addSettings(ChannelSettings.getDefaultInstance()) + while (settings.size <= channel.index) { + settings.add(ChannelSettings()) } // use setSettings() to ensure settingsList and channel indexes match - builder.setSettings(channel.index, channel.settings).build() + settings[channel.index] = channel.settings ?: ChannelSettings() + preference.copy(settings = settings) } } - suspend fun setLoraConfig(config: ConfigProtos.Config.LoRaConfig) { - channelSetStore.updateData { preference -> preference.toBuilder().setLoraConfig(config).build() } + suspend fun setLoraConfig(config: Config.LoRaConfig) { + channelSetStore.updateData { it.copy(lora_config = config) } } } diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt index 7ab985eb2..f347c710b 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt @@ -20,9 +20,9 @@ import androidx.datastore.core.DataStore import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import java.io.IOException +import okio.IOException +import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig import javax.inject.Inject import javax.inject.Singleton @@ -34,28 +34,28 @@ class LocalConfigDataSource @Inject constructor(private val localConfigStore: Da // dataStore.data throws an IOException when an error is encountered when reading data if (exception is IOException) { Logger.e { "Error reading LocalConfig settings: ${exception.message}" } - emit(LocalConfig.getDefaultInstance()) + emit(LocalConfig()) } else { throw exception } } suspend fun clearLocalConfig() { - localConfigStore.updateData { preference -> preference.toBuilder().clear().build() } + localConfigStore.updateData { LocalConfig() } } /** Updates [LocalConfig] from each [Config] oneOf. */ - suspend fun setLocalConfig(config: Config) = localConfigStore.updateData { - val builder = it.toBuilder() - config.allFields.forEach { (field, value) -> - val localField = it.descriptorForType.findFieldByName(field.name) - if (localField != null) { - builder.setField(localField, value) - } else { - // Some fields like SESSIONKEY are not intended to be persisted in LocalConfig - Logger.d { "Skipping non-persistent LocalConfig field: ${field.name}" } - } + suspend fun setLocalConfig(config: Config) = localConfigStore.updateData { current -> + when { + config.device != null -> current.copy(device = config.device) + config.position != null -> current.copy(position = config.position) + config.power != null -> current.copy(power = config.power) + config.network != null -> current.copy(network = config.network) + config.display != null -> current.copy(display = config.display) + config.lora != null -> current.copy(lora = config.lora) + config.bluetooth != null -> current.copy(bluetooth = config.bluetooth) + config.security != null -> current.copy(security = config.security) + else -> current } - builder.build() } } diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt index d96c3113e..c4195d58a 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.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,16 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.datastore import androidx.datastore.core.DataStore import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch -import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig -import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig -import java.io.IOException +import okio.IOException +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig import javax.inject.Inject import javax.inject.Singleton @@ -35,27 +34,35 @@ class ModuleConfigDataSource @Inject constructor(private val moduleConfigStore: // dataStore.data throws an IOException when an error is encountered when reading data if (exception is IOException) { Logger.e { "Error reading LocalModuleConfig settings: ${exception.message}" } - emit(LocalModuleConfig.getDefaultInstance()) + emit(LocalModuleConfig()) } else { throw exception } } suspend fun clearLocalModuleConfig() { - moduleConfigStore.updateData { preference -> preference.toBuilder().clear().build() } + moduleConfigStore.updateData { LocalModuleConfig() } } /** Updates [LocalModuleConfig] from each [ModuleConfig] oneOf. */ - suspend fun setLocalModuleConfig(config: ModuleConfig) = moduleConfigStore.updateData { - val builder = it.toBuilder() - config.allFields.forEach { (field, value) -> - val localField = it.descriptorForType.findFieldByName(field.name) - if (localField != null) { - builder.setField(localField, value) - } else { - Logger.e { "Error writing LocalModuleConfig settings: ${config.payloadVariantCase}" } - } + suspend fun setLocalModuleConfig(config: ModuleConfig) = moduleConfigStore.updateData { current -> + when { + config.mqtt != null -> current.copy(mqtt = config.mqtt) + config.serial != null -> current.copy(serial = config.serial) + config.external_notification != null -> + current.copy(external_notification = config.external_notification) + config.store_forward != null -> current.copy(store_forward = config.store_forward) + config.range_test != null -> current.copy(range_test = config.range_test) + config.telemetry != null -> current.copy(telemetry = config.telemetry) + config.canned_message != null -> current.copy(canned_message = config.canned_message) + config.audio != null -> current.copy(audio = config.audio) + config.remote_hardware != null -> current.copy(remote_hardware = config.remote_hardware) + config.neighbor_info != null -> current.copy(neighbor_info = config.neighbor_info) + config.ambient_lighting != null -> current.copy(ambient_lighting = config.ambient_lighting) + config.detection_sensor != null -> current.copy(detection_sensor = config.detection_sensor) + config.paxcounter != null -> current.copy(paxcounter = config.paxcounter) + config.statusmessage != null -> current.copy(statusmessage = config.statusmessage) + else -> current } - builder.build() } } diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt index 47e2c8664..a51523b22 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.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 org.meshtastic.core.datastore.di import android.content.Context @@ -45,9 +44,9 @@ import org.meshtastic.core.datastore.KEY_THEME import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer -import org.meshtastic.proto.AppOnlyProtos.ChannelSet -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig import javax.inject.Qualifier import javax.inject.Singleton @@ -103,7 +102,7 @@ object DataStoreModule { ): DataStore = DataStoreFactory.create( serializer = LocalConfigSerializer, produceFile = { appContext.dataStoreFile("local_config.pb") }, - corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig.getDefaultInstance() }), + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }), scope = scope, ) @@ -115,8 +114,7 @@ object DataStoreModule { ): DataStore = DataStoreFactory.create( serializer = ModuleConfigSerializer, produceFile = { appContext.dataStoreFile("module_config.pb") }, - corruptionHandler = - ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig.getDefaultInstance() }), + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }), scope = scope, ) @@ -128,7 +126,7 @@ object DataStoreModule { ): DataStore = DataStoreFactory.create( serializer = ChannelSetSerializer, produceFile = { appContext.dataStoreFile("channel_set.pb") }, - corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet.getDefaultInstance() }), + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }), scope = scope, ) } diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt index 9bf5f98b5..800b099f2 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.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,28 +14,27 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.datastore.serializer import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer -import com.google.protobuf.InvalidProtocolBufferException -import org.meshtastic.proto.AppOnlyProtos.ChannelSet +import okio.IOException +import org.meshtastic.proto.ChannelSet import java.io.InputStream import java.io.OutputStream /** Serializer for the [ChannelSet] object defined in apponly.proto. */ @Suppress("BlockingMethodInNonBlockingContext") object ChannelSetSerializer : Serializer { - override val defaultValue: ChannelSet = ChannelSet.getDefaultInstance() + override val defaultValue: ChannelSet = ChannelSet() override suspend fun readFrom(input: InputStream): ChannelSet { try { - return ChannelSet.parseFrom(input) - } catch (exception: InvalidProtocolBufferException) { + return ChannelSet.ADAPTER.decode(input) + } catch (exception: IOException) { throw CorruptionException("Cannot read proto.", exception) } } - override suspend fun writeTo(t: ChannelSet, output: OutputStream) = t.writeTo(output) + override suspend fun writeTo(t: ChannelSet, output: OutputStream) = ChannelSet.ADAPTER.encode(output, t) } diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt index b6b314096..f356aa158 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.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,28 +14,27 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.datastore.serializer import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer -import com.google.protobuf.InvalidProtocolBufferException -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig +import okio.IOException +import org.meshtastic.proto.LocalConfig import java.io.InputStream import java.io.OutputStream /** Serializer for the [LocalConfig] object defined in localonly.proto. */ @Suppress("BlockingMethodInNonBlockingContext") object LocalConfigSerializer : Serializer { - override val defaultValue: LocalConfig = LocalConfig.getDefaultInstance() + override val defaultValue: LocalConfig = LocalConfig() override suspend fun readFrom(input: InputStream): LocalConfig { try { - return LocalConfig.parseFrom(input) - } catch (exception: InvalidProtocolBufferException) { + return LocalConfig.ADAPTER.decode(input) + } catch (exception: IOException) { throw CorruptionException("Cannot read proto.", exception) } } - override suspend fun writeTo(t: LocalConfig, output: OutputStream) = t.writeTo(output) + override suspend fun writeTo(t: LocalConfig, output: OutputStream) = LocalConfig.ADAPTER.encode(output, t) } diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt index ec950cde4..14087b4fd 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.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,28 +14,28 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.datastore.serializer import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer -import com.google.protobuf.InvalidProtocolBufferException -import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig +import okio.IOException +import org.meshtastic.proto.LocalModuleConfig import java.io.InputStream import java.io.OutputStream /** Serializer for the [LocalModuleConfig] object defined in localonly.proto. */ @Suppress("BlockingMethodInNonBlockingContext") object ModuleConfigSerializer : Serializer { - override val defaultValue: LocalModuleConfig = LocalModuleConfig.getDefaultInstance() + override val defaultValue: LocalModuleConfig = LocalModuleConfig() override suspend fun readFrom(input: InputStream): LocalModuleConfig { try { - return LocalModuleConfig.parseFrom(input) - } catch (exception: InvalidProtocolBufferException) { + return LocalModuleConfig.ADAPTER.decode(input) + } catch (exception: IOException) { throw CorruptionException("Cannot read proto.", exception) } } - override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) = t.writeTo(output) + override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) = + LocalModuleConfig.ADAPTER.encode(output, t) } diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index fd171b550..053f8af54 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -25,23 +25,12 @@ plugins { apply(from = rootProject.file("gradle/publishing.gradle.kts")) -afterEvaluate { - publishing { - publications { - create("release") { - from(components["googleRelease"]) - artifactId = "core-model" - } - } - } -} - configure { + namespace = "org.meshtastic.core.model" buildFeatures { buildConfig = true aidl = true } - namespace = "org.meshtastic.core.model" defaultConfig { // Lowering minSdk to 21 for better compatibility with ATAK and other plugins @@ -53,6 +42,17 @@ configure { publishing { singleVariant("googleRelease") { withSourcesJar() } } } +afterEvaluate { + publishing { + publications { + create("googleRelease") { + from(components["googleRelease"]) + artifactId = "core-model" + } + } + } +} + dependencies { api(projects.core.proto) diff --git a/core/model/detekt-baseline.xml b/core/model/detekt-baseline.xml index 49dc09531..99ebbdc7e 100644 --- a/core/model/detekt-baseline.xml +++ b/core/model/detekt-baseline.xml @@ -1,11 +1,13 @@ - + MagicNumber:ChannelSet.kt$40 MagicNumber:ChannelSet.kt$960 SwallowedException:ChannelSet.kt$ex: Throwable + SwallowedException:DataPacket.kt$DataPacket$e: Exception TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable - TooManyFunctions:Extensions.kt$org.meshtastic.core.model.util.Extensions.kt + TooGenericExceptionCaught:DataPacket.kt$DataPacket$e: Exception + UnusedPrivateMember:DataPacket.kt$private inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt b/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt index 5ee949c4e..94e2262c6 100644 --- a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt +++ b/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt @@ -23,18 +23,14 @@ import org.junit.runner.RunWith import org.meshtastic.core.model.util.URL_PREFIX import org.meshtastic.core.model.util.getChannelUrl import org.meshtastic.core.model.util.toChannelSet -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.channelSet -import org.meshtastic.proto.copy +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.Config @RunWith(AndroidJUnit4::class) class ChannelTest { @Test fun channelUrlGood() { - val ch = channelSet { - settings.add(Channel.default.settings) - loraConfig = Channel.default.loraConfig - } + val ch = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig) val channelUrl = ch.getChannelUrl() Assert.assertTrue(channelUrl.toString().startsWith(URL_PREFIX)) @@ -71,16 +67,11 @@ class ChannelTest { @Test fun allModemPresetsHaveValidNames() { - ConfigProtos.Config.LoRaConfig.ModemPreset.values().forEach { preset -> + Config.LoRaConfig.ModemPreset.entries.forEach { preset -> // Skip UNRECOGNIZED if it exists (Wire generates it sometimes) or generic UNSET values if applicable - // In this specific enum, assuming all valid defined presets should map. if (preset.name == "UNSET" || preset.name == "UNRECOGNIZED") return@forEach - val loraConfig = - Channel.default.loraConfig.copy { - usePreset = true - modemPreset = preset - } + val loraConfig = Channel.default.loraConfig.copy(use_preset = true, modem_preset = preset) val channel = Channel(loraConfig = loraConfig) // We want to ensure it is NOT "Invalid" diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt index 4a3c2a7be..a8fe72c55 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt @@ -16,20 +16,16 @@ */ package org.meshtastic.core.model -import com.google.protobuf.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.util.byteArrayOfInts import org.meshtastic.core.model.util.xorHash -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigKt.loRaConfig -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset -import org.meshtastic.proto.channelSettings +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config.LoRaConfig +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset import java.security.SecureRandom -data class Channel( - val settings: ChannelProtos.ChannelSettings = default.settings, - val loraConfig: ConfigProtos.Config.LoRaConfig = default.loraConfig, -) { +data class Channel(val settings: ChannelSettings = default.settings, val loraConfig: LoRaConfig = default.loraConfig) { companion object { // These bytes must match the well known and not secret bytes used the default channel AES128 key device code private val channelDefaultKey = @@ -58,21 +54,16 @@ data class Channel( // The default channel that devices ship with val default = Channel( - channelSettings { psk = ByteString.copyFrom(defaultPSK) }, + ChannelSettings(psk = defaultPSK.toByteString()), // references: NodeDB::installDefaultConfig / Channels::initDefaultChannel - loRaConfig { - usePreset = true - modemPreset = ModemPreset.LONG_FAST - hopLimit = 3 - txEnabled = true - }, + LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, hop_limit = 3, tx_enabled = true), ) fun getRandomKey(size: Int = 32): ByteString { val bytes = ByteArray(size) val random = SecureRandom() random.nextBytes(bytes) - return ByteString.copyFrom(bytes) + return bytes.toByteString() } } @@ -82,8 +73,8 @@ data class Channel( settings.name.ifEmpty { // We have a new style 'empty' channel name. Use the same logic from the device to convert that to a // human readable name - if (loraConfig.usePreset) { - when (loraConfig.modemPreset) { + if (loraConfig.use_preset) { + when (loraConfig.modem_preset) { ModemPreset.SHORT_TURBO -> "ShortTurbo" ModemPreset.SHORT_FAST -> "ShortFast" ModemPreset.SHORT_SLOW -> "ShortSlow" @@ -103,11 +94,11 @@ data class Channel( val psk: ByteString get() = - if (settings.psk.size() != 1) { + if (settings.psk.size != 1) { settings.psk // A standard PSK } else { // One of our special 1 byte PSKs, see mesh.proto for docs. - val pskIndex = settings.psk.byteAt(0).toInt() + val pskIndex = settings.psk[0].toInt() if (pskIndex == 0) { cleartextPSK @@ -115,7 +106,7 @@ data class Channel( // Treat an index of 1 as the old channelDefaultKey and work up from there val bytes = channelDefaultKey.clone() bytes[bytes.size - 1] = (0xff and (bytes[bytes.size - 1] + pskIndex - 1)).toByte() - ByteString.copyFrom(bytes) + bytes.toByteString() } } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt index 0e8712059..a013005df 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt @@ -18,9 +18,9 @@ package org.meshtastic.core.model -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.RegionCode +import org.meshtastic.proto.Config.LoRaConfig +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset +import org.meshtastic.proto.Config.LoRaConfig.RegionCode import kotlin.math.floor /** hash a string into an integer using the djb2 algorithm by Dan Bernstein http://www.cse.yorku.ca/~oz/hash.html */ @@ -40,8 +40,8 @@ private val ModemPreset.bandwidth: Float return 0f } -private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (usePreset) { - modemPreset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f +private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (use_preset) { + modem_preset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f } else { when (bandwidth) { 31 -> .03125f @@ -69,13 +69,13 @@ val LoRaConfig.numChannels: Int } internal fun LoRaConfig.channelNum(primaryName: String): Int = when { - channelNum != 0 -> channelNum + channel_num != 0 -> channel_num numChannels == 0 -> 0 else -> (hash(primaryName) % numChannels.toUInt()).toInt() + 1 } internal fun LoRaConfig.radioFreq(channelNum: Int): Float { - if (overrideFrequency != 0f) return overrideFrequency + frequencyOffset + if ((override_frequency ?: 0f) != 0f) return (override_frequency ?: 0f) + (frequency_offset ?: 0f) val regionInfo = RegionInfo.fromRegionCode(region) return if (regionInfo != null) { (regionInfo.freqStart + bandwidth(regionInfo) / 2) + (channelNum - 1) * bandwidth(regionInfo) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt index e3888f862..be956a73a 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -18,19 +18,17 @@ package org.meshtastic.core.model import android.os.Parcel import android.os.Parcelable +import co.touchlab.kermit.Logger +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.Portnums - -/** Generic [Parcel.readParcelable] Android 13 compatibility extension. */ -private inline fun Parcel.readParcelableCompat(loader: ClassLoader?): T? = - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { - @Suppress("DEPRECATION") - readParcelable(loader) - } else { - readParcelable(loader, T::class.java) - } +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.util.ByteStringParceler +import org.meshtastic.core.model.util.ByteStringSerializer +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Waypoint @Parcelize enum class MessageStatus : Parcelable { @@ -46,10 +44,13 @@ enum class MessageStatus : Parcelable { /** A parcelable version of the protobuf MeshPacket + Data subpacket. */ @Serializable +@Parcelize data class DataPacket( var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast - var bytes: ByteArray?, - // A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions) + @Serializable(with = ByteStringSerializer::class) + @TypeParceler + var bytes: ByteString?, + // A port number for this packet var dataType: Int, var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost var time: Long = System.currentTimeMillis(), // msecs since 1970 @@ -67,11 +68,52 @@ data class DataPacket( var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path var retryCount: Int = 0, // Number of automatic retry attempts var emoji: Int = 0, - var sfppHash: ByteArray? = null, + @Serializable(with = ByteStringSerializer::class) + @TypeParceler + var sfppHash: ByteString? = null, ) : Parcelable { + fun readFromParcel(parcel: Parcel) { + to = parcel.readString() + bytes = ByteStringParceler.create(parcel) + dataType = parcel.readInt() + from = parcel.readString() + time = parcel.readLong() + id = parcel.readInt() + + // MessageStatus is a known Parcelable type (enum), so Parcelize writes it optimized: + // 1. Presence flag (Int: 1 or 0) + // 2. Content (Enum Name as String) + status = + if (parcel.readInt() != 0) { + val name = parcel.readString() + try { + if (name != null) MessageStatus.valueOf(name) else MessageStatus.UNKNOWN + } catch (e: IllegalArgumentException) { + Logger.w(e) { "Unknown MessageStatus: $name" } + MessageStatus.UNKNOWN + } + } else { + null + } + + hopLimit = parcel.readInt() + channel = parcel.readInt() + wantAck = parcel.readInt() != 0 + hopStart = parcel.readInt() + snr = parcel.readFloat() + rssi = parcel.readInt() + replyId = if (parcel.readInt() == 0) null else parcel.readInt() + relayNode = if (parcel.readInt() == 0) null else parcel.readInt() + relays = parcel.readInt() + viaMqtt = parcel.readInt() != 0 + retryCount = parcel.readInt() + emoji = parcel.readInt() + sfppHash = ByteStringParceler.create(parcel) + } + /** If there was an error with this message, this string describes what was wrong. */ - var errorMessage: String? = null + @IgnoredOnParcel var errorMessage: String? = null /** Syntactic sugar to make it easy to create text messages */ constructor( @@ -81,8 +123,8 @@ data class DataPacket( replyId: Int? = null, ) : this( to = to, - bytes = text.encodeToByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = text.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, channel = channel, replyId = replyId ?: 0, ) @@ -90,17 +132,16 @@ data class DataPacket( /** If this is a text message, return the string, otherwise null */ val text: String? get() = - when (dataType) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> bytes?.decodeToString() - // Portnums.PortNum.NODE_STATUS_APP_VALUE -> - // MeshProtos.StatusMessage.parseFrom(bytes).status - else -> null + if (dataType == PortNum.TEXT_MESSAGE_APP.value) { + bytes?.utf8() + } else { + null } val alert: String? get() = - if (dataType == Portnums.PortNum.ALERT_APP_VALUE) { - bytes?.decodeToString() + if (dataType == PortNum.ALERT_APP.value) { + bytes?.utf8() } else { null } @@ -108,13 +149,22 @@ data class DataPacket( constructor( to: String?, channel: Int, - waypoint: MeshProtos.Waypoint, - ) : this(to = to, bytes = waypoint.toByteArray(), dataType = Portnums.PortNum.WAYPOINT_APP_VALUE, channel = channel) + waypoint: Waypoint, + ) : this( + to = to, + bytes = Waypoint.ADAPTER.encode(waypoint).toByteString(), + dataType = PortNum.WAYPOINT_APP.value, + channel = channel, + ) - val waypoint: MeshProtos.Waypoint? + val waypoint: Waypoint? get() = - if (dataType == Portnums.PortNum.WAYPOINT_APP_VALUE) { - MeshProtos.Waypoint.parseFrom(bytes) + if (dataType == PortNum.WAYPOINT_APP.value) { + try { + bytes?.let { Waypoint.ADAPTER.decode(it) } + } catch (e: Exception) { + null + } } else { null } @@ -122,138 +172,7 @@ data class DataPacket( val hopsAway: Int get() = if (hopStart == 0 || hopLimit > hopStart) -1 else hopStart - hopLimit - // Autogenerated comparision, because we have a byte array - - constructor( - parcel: Parcel, - ) : this( - parcel.readString(), - parcel.createByteArray(), - parcel.readInt(), - parcel.readString(), - parcel.readLong(), - parcel.readInt(), - parcel.readParcelableCompat(MessageStatus::class.java.classLoader), - parcel.readInt(), - parcel.readInt(), - parcel.readInt() == 1, - parcel.readInt(), - parcel.readFloat(), - parcel.readInt(), - parcel.readInt().let { if (it == 0) null else it }, - parcel.readInt().let { if (it == -1) null else it }, - parcel.readInt(), // relays - parcel.readInt() == 1, // viaMqtt - parcel.readInt(), // retryCount - parcel.readInt(), // emoji - parcel.createByteArray(), // sfppHash - ) - - @Suppress("CyclomaticComplexMethod") - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DataPacket - - if (from != other.from) return false - if (to != other.to) return false - if (channel != other.channel) return false - if (time != other.time) return false - if (id != other.id) return false - if (dataType != other.dataType) return false - if (!bytes.contentEquals(other.bytes)) return false - if (status != other.status) return false - if (hopLimit != other.hopLimit) return false - if (wantAck != other.wantAck) return false - if (hopStart != other.hopStart) return false - if (snr != other.snr) return false - if (rssi != other.rssi) return false - if (replyId != other.replyId) return false - if (relayNode != other.relayNode) return false - if (relays != other.relays) return false - if (viaMqtt != other.viaMqtt) return false - if (retryCount != other.retryCount) return false - if (emoji != other.emoji) return false - if (!sfppHash.contentEquals(other.sfppHash)) return false - - return true - } - - override fun hashCode(): Int { - var result = from?.hashCode() ?: 0 - result = 31 * result + (to?.hashCode() ?: 0) - result = 31 * result + time.hashCode() - result = 31 * result + id - result = 31 * result + dataType - result = 31 * result + (bytes?.contentHashCode() ?: 0) - result = 31 * result + (status?.hashCode() ?: 0) - result = 31 * result + hopLimit - result = 31 * result + channel - result = 31 * result + wantAck.hashCode() - result = 31 * result + hopStart - result = 31 * result + snr.hashCode() - result = 31 * result + rssi - result = 31 * result + (replyId ?: 0) - result = 31 * result + (relayNode ?: -1) - result = 31 * result + relays - result = 31 * result + viaMqtt.hashCode() - result = 31 * result + retryCount - result = 31 * result + emoji - result = 31 * result + (sfppHash?.contentHashCode() ?: 0) - return result - } - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(to) - parcel.writeByteArray(bytes) - parcel.writeInt(dataType) - parcel.writeString(from) - parcel.writeLong(time) - parcel.writeInt(id) - parcel.writeParcelable(status, flags) - parcel.writeInt(hopLimit) - parcel.writeInt(channel) - parcel.writeInt(if (wantAck) 1 else 0) - parcel.writeInt(hopStart) - parcel.writeFloat(snr) - parcel.writeInt(rssi) - parcel.writeInt(replyId ?: 0) - parcel.writeInt(relayNode ?: -1) - parcel.writeInt(relays) - parcel.writeInt(if (viaMqtt) 1 else 0) - parcel.writeInt(retryCount) - parcel.writeInt(emoji) - parcel.writeByteArray(sfppHash) - } - - override fun describeContents(): Int = 0 - - /** Update our object from our parcel (used for inout parameters) */ - fun readFromParcel(parcel: Parcel) { - to = parcel.readString() - bytes = parcel.createByteArray() - dataType = parcel.readInt() - from = parcel.readString() - time = parcel.readLong() - id = parcel.readInt() - status = parcel.readParcelableCompat(MessageStatus::class.java.classLoader) - hopLimit = parcel.readInt() - channel = parcel.readInt() - wantAck = parcel.readInt() == 1 - hopStart = parcel.readInt() - snr = parcel.readFloat() - rssi = parcel.readInt() - replyId = parcel.readInt().let { if (it == 0) null else it } - relayNode = parcel.readInt().let { if (it == -1) null else it } - relays = parcel.readInt() - viaMqtt = parcel.readInt() == 1 - retryCount = parcel.readInt() - emoji = parcel.readInt() - sfppHash = parcel.createByteArray() - } - - companion object CREATOR : Parcelable.Creator { + companion object { // Special node IDs that can be used for sending messages /** the Node ID for broadcast destinations */ @@ -272,9 +191,5 @@ data class DataPacket( @Suppress("MagicNumber") fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull() - - override fun createFromParcel(parcel: Parcel): DataPacket = DataPacket(parcel) - - override fun newArray(size: Int): Array = arrayOfNulls(size) } } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt index f8ff59fc0..f69e353e8 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt @@ -16,36 +16,38 @@ */ package org.meshtastic.core.model -import org.meshtastic.proto.MeshProtos +import co.touchlab.kermit.Logger +import org.meshtastic.core.model.util.decodeOrNull +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.PortNum -val MeshProtos.MeshPacket.neighborInfo: MeshProtos.NeighborInfo? - get() = - if (hasDecoded() && decoded.portnumValue == 71) { // NEIGHBORINFO_APP_VALUE = 71 - runCatching { MeshProtos.NeighborInfo.parseFrom(decoded.payload) }.getOrNull() +val MeshPacket.neighborInfo: NeighborInfo? + get() { + val decoded = this.decoded + return if (decoded != null && decoded.portnum == PortNum.NEIGHBORINFO_APP) { + NeighborInfo.ADAPTER.decodeOrNull(decoded.payload, Logger) } else { null } + } -fun MeshProtos.NeighborInfo.getNeighborInfoResponse( - getUser: (nodeNum: Int) -> String, - header: String = "Neighbors:", -): String = buildString { - append(header) - append("\n\n") - if (neighborsList.isEmpty()) { - append("No neighbors reported.") - } else { - neighborsList.forEach { n -> - append("• ") - append(getUser(n.nodeId)) - append(" (SNR: ") - append(n.snr) - append(")\n") +fun NeighborInfo.getNeighborInfoResponse(getUser: (nodeNum: Int) -> String, header: String = "Neighbors:"): String = + buildString { + append(header) + append("\n\n") + if (neighbors.isEmpty()) { + append("No neighbors reported.") + } else { + neighbors.forEach { n -> + append("• ") + append(getUser(n.node_id)) + append(" (SNR: ") + append(n.snr) + append(")\n") + } } } -} -fun MeshProtos.MeshPacket.getNeighborInfoResponse( - getUser: (nodeNum: Int) -> String, - header: String = "Neighbors:", -): String? = neighborInfo?.getNeighborInfoResponse(getUser, header) +fun MeshPacket.getNeighborInfoResponse(getUser: (nodeNum: Int) -> String, header: String = "Neighbors:"): String? = + neighborInfo?.getNeighborInfoResponse(getUser, header) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt index 418cc07b5..4e88ddfe7 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.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 org.meshtastic.core.model import android.graphics.Color @@ -24,9 +23,8 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.model.util.bearing import org.meshtastic.core.model.util.latLongToMeter import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.Config +import org.meshtastic.proto.HardwareModel // // model objects that directly map to the corresponding protobufs @@ -37,7 +35,7 @@ data class MeshUser( val id: String, val longName: String, val shortName: String, - val hwModel: MeshProtos.HardwareModel, + val hwModel: HardwareModel, val isLicensed: Boolean = false, val role: Int = 0, ) : Parcelable { @@ -50,7 +48,9 @@ data class MeshUser( "role=$role)" /** Create our model object from a protobuf. */ - constructor(p: MeshProtos.User) : this(p.id, p.longName, p.shortName, p.hwModel, p.isLicensed, p.roleValue) + constructor( + p: org.meshtastic.proto.User, + ) : this(p.id, p.long_name ?: "", p.short_name ?: "", p.hw_model, p.is_licensed, p.role.value) /** * a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null @@ -58,7 +58,7 @@ data class MeshUser( */ val hwModelString: String? get() = - if (hwModel == MeshProtos.HardwareModel.UNSET) { + if (hwModel == HardwareModel.UNSET) { null } else { hwModel.name.replace('_', '-').replace('p', '.').lowercase() @@ -92,18 +92,18 @@ data class Position( * be used. */ constructor( - position: MeshProtos.Position, + position: org.meshtastic.proto.Position, defaultTime: Int = currentTime(), ) : this( // We prefer the int version of lat/lon but if not available use the depreciated legacy version - degD(position.latitudeI), - degD(position.longitudeI), - position.altitude, + degD(position.latitude_i ?: 0), + degD(position.longitude_i ?: 0), + position.altitude ?: 0, if (position.time != 0) position.time else defaultTime, - position.satsInView, - position.groundSpeed, - position.groundTrack, - position.precisionBits, + position.sats_in_view ?: 0, + position.ground_speed ?: 0, + position.ground_track ?: 0, + position.precision_bits ?: 0, ) // / @return distance in meters to some other node (or null if unknown) @@ -139,9 +139,16 @@ data class DeviceMetrics( /** Create our model object from a protobuf. */ constructor( - p: TelemetryProtos.DeviceMetrics, + p: org.meshtastic.proto.DeviceMetrics, telemetryTime: Int = currentTime(), - ) : this(telemetryTime, p.batteryLevel, p.voltage, p.channelUtilization, p.airUtilTx, p.uptimeSeconds) + ) : this( + telemetryTime, + p.battery_level ?: 0, + p.voltage ?: 0f, + p.channel_utilization ?: 0f, + p.air_util_tx ?: 0f, + p.uptime_seconds ?: 0, + ) } @Parcelize @@ -163,20 +170,19 @@ data class EnvironmentMetrics( companion object { fun currentTime() = (System.currentTimeMillis() / 1000).toInt() - fun fromTelemetryProto(proto: TelemetryProtos.EnvironmentMetrics, time: Int): EnvironmentMetrics = + fun fromTelemetryProto(proto: org.meshtastic.proto.EnvironmentMetrics, time: Int): EnvironmentMetrics = EnvironmentMetrics( - temperature = proto.temperature.takeIf { proto.hasTemperature() && !it.isNaN() }, - relativeHumidity = - proto.relativeHumidity.takeIf { proto.hasRelativeHumidity() && !it.isNaN() && it != 0.0f }, - soilTemperature = proto.soilTemperature.takeIf { proto.hasSoilTemperature() && !it.isNaN() }, - soilMoisture = proto.soilMoisture.takeIf { proto.hasSoilMoisture() && it != Int.MIN_VALUE }, - barometricPressure = proto.barometricPressure.takeIf { proto.hasBarometricPressure() && !it.isNaN() }, - gasResistance = proto.gasResistance.takeIf { proto.hasGasResistance() && !it.isNaN() }, - voltage = proto.voltage.takeIf { proto.hasVoltage() && !it.isNaN() }, - current = proto.current.takeIf { proto.hasCurrent() && !it.isNaN() }, - iaq = proto.iaq.takeIf { proto.hasIaq() && it != Int.MIN_VALUE }, - lux = proto.lux.takeIf { proto.hasLux() && !it.isNaN() }, - uvLux = proto.uvLux.takeIf { proto.hasUvLux() && !it.isNaN() }, + temperature = proto.temperature?.takeIf { !it.isNaN() }, + relativeHumidity = proto.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f }, + soilTemperature = proto.soil_temperature?.takeIf { !it.isNaN() }, + soilMoisture = proto.soil_moisture?.takeIf { it != Int.MIN_VALUE }, + barometricPressure = proto.barometric_pressure?.takeIf { !it.isNaN() }, + gasResistance = proto.gas_resistance?.takeIf { !it.isNaN() }, + voltage = proto.voltage?.takeIf { !it.isNaN() }, + current = proto.current?.takeIf { !it.isNaN() }, + iaq = proto.iaq?.takeIf { it != Int.MIN_VALUE }, + lux = proto.lux?.takeIf { !it.isNaN() }, + uvLux = proto.uv_lux?.takeIf { !it.isNaN() }, time = time, ) } @@ -247,13 +253,13 @@ data class NodeInfo( fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist -> when { dist == 0 -> null // same point - prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 -> + prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 -> "%.0f m".format(dist.toDouble()) - prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 -> + prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist >= 1000 -> "%.1f km".format(dist / 1000.0) - prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 -> + prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist < 1609 -> "%.0f ft".format(dist.toDouble() * 3.281) - prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 -> + prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist >= 1609 -> "%.1f mi".format(dist / 1609.34) else -> null } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt index de47abc4d..7f528c4d8 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt @@ -16,36 +16,43 @@ */ package org.meshtastic.core.model -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.RouteDiscovery -import org.meshtastic.proto.Portnums +import co.touchlab.kermit.Logger +import org.meshtastic.core.model.util.decodeOrNull +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.RouteDiscovery -val MeshProtos.MeshPacket.fullRouteDiscovery: RouteDiscovery? - get() = - with(decoded) { - if (hasDecoded() && !wantResponse && portnum == Portnums.PortNum.TRACEROUTE_APP) { - runCatching { RouteDiscovery.parseFrom(payload).toBuilder() } - .getOrNull() - ?.apply { - val destinationId = dest.takeIf { it != 0 } ?: this@fullRouteDiscovery.to - val sourceId = source.takeIf { it != 0 } ?: this@fullRouteDiscovery.from - val fullRoute = listOf(destinationId) + routeList + sourceId - clearRoute() - addAllRoute(fullRoute) +val MeshPacket.fullRouteDiscovery: RouteDiscovery? + get() { + val d = decoded + if (d != null && !d.want_response && d.portnum == PortNum.TRACEROUTE_APP) { + val originalRd = RouteDiscovery.ADAPTER.decodeOrNull(d.payload, Logger) ?: return null - val fullRouteBack = listOf(sourceId) + routeBackList + destinationId - clearRouteBack() - // hopStart was not populated prior to 2.3.0. The bitfield was added in 2.5.0 and - // is used to detect versions where hopStart can be trusted to have been set. - if ((hopStart > 0 || hasBitfield()) && snrBackCount > 0) { // otherwise back route is invalid - addAllRouteBack(fullRouteBack) - } - } - ?.build() - } else { - null - } + val destinationId = if (d.dest != 0) d.dest else this.to + val sourceId = if (d.source != 0) d.source else this.from + + // Note: Wire lists are immutable + val fullRoute = listOf(destinationId) + originalRd.route + sourceId + val fullRouteBack = listOf(sourceId) + originalRd.route_back + destinationId + + // hopStart was not populated prior to 2.3.0. The bitfield was added in 2.5.0 and + // is used to detect versions where hopStart can be trusted to have been set. + // Assuming default integer values of 0 for hop_start and snr_back_count if unset. + val hopStartVal = hop_start + val hasBitfield = (d.bitfield ?: 0) != 0 + + return originalRd.copy( + route = fullRoute, + route_back = + if ((hopStartVal > 0 || hasBitfield) && originalRd.snr_back.isNotEmpty()) { + fullRouteBack + } else { + originalRd.route_back + }, + ) } + return null + } @Suppress("MagicNumber") private fun formatTraceroutePath(nodesList: List, snrList: List): String { @@ -74,30 +81,30 @@ private fun RouteDiscovery.getTracerouteResponse( headerTowards: String = "Route traced toward destination:\n\n", headerBack: String = "Route traced back to us:\n\n", ): String = buildString { - if (routeList.isNotEmpty()) { + if (route.isNotEmpty()) { append(headerTowards) - append(formatTraceroutePath(routeList.map(getUser), snrTowardsList)) + append(formatTraceroutePath(route.map(getUser), snr_towards)) } - if (routeBackList.isNotEmpty()) { + if (route_back.isNotEmpty()) { append("\n\n") append(headerBack) - append(formatTraceroutePath(routeBackList.map(getUser), snrBackList)) + append(formatTraceroutePath(route_back.map(getUser), snr_back)) } } -fun MeshProtos.MeshPacket.getTracerouteResponse( +fun MeshPacket.getTracerouteResponse( getUser: (nodeNum: Int) -> String, headerTowards: String = "Route traced toward destination:\n\n", headerBack: String = "Route traced back to us:\n\n", ): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack) /** Returns a traceroute response string only when the result is complete (both directions). */ -fun MeshProtos.MeshPacket.getFullTracerouteResponse( +fun MeshPacket.getFullTracerouteResponse( getUser: (nodeNum: Int) -> String, headerTowards: String = "Route traced toward destination:\n\n", headerBack: String = "Route traced back to us:\n\n", ): String? = fullRouteDiscovery - ?.takeIf { it.routeList.isNotEmpty() && it.routeBackList.isNotEmpty() } + ?.takeIf { it.route.isNotEmpty() && it.route_back.isNotEmpty() } ?.getTracerouteResponse(getUser, headerTowards, headerBack) enum class TracerouteMapAvailability { diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt index dc9c64b0e..206529504 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.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,13 +14,17 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.model.util import android.util.Base64 -import com.google.protobuf.ByteString -import com.google.protobuf.kotlin.toByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString -fun ByteString.encodeToString() = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP) +fun ByteString.encodeToString(): String = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP) -fun String.toByteString() = Base64.decode(this, Base64.NO_WRAP).toByteString() +/** + * Decodes a Base64 string into a [ByteString]. + * + * @throws IllegalArgumentException if the string is not valid Base64. + */ +fun String.base64ToByteString(): ByteString = Base64.decode(this, Base64.NO_WRAP).toByteString() diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt new file mode 100644 index 000000000..c3012d88d --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt @@ -0,0 +1,49 @@ +/* + * 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 org.meshtastic.core.model.util + +import android.os.Parcel +import kotlinx.parcelize.Parceler +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import okio.ByteString +import okio.ByteString.Companion.toByteString + +/** Serializer for Okio [ByteString] using kotlinx.serialization */ +object ByteStringSerializer : KSerializer { + private val byteArraySerializer = ByteArraySerializer() + + override val descriptor: SerialDescriptor = byteArraySerializer.descriptor + + override fun serialize(encoder: Encoder, value: ByteString) { + byteArraySerializer.serialize(encoder, value.toByteArray()) + } + + override fun deserialize(decoder: Decoder): ByteString = byteArraySerializer.deserialize(decoder).toByteString() +} + +/** Parceler for Okio [ByteString] for Android Parcelable support */ +object ByteStringParceler : Parceler { + override fun create(parcel: Parcel): ByteString? = parcel.createByteArray()?.toByteString() + + override fun ByteString?.write(parcel: Parcel, flags: Int) { + parcel.writeByteArray(this?.toByteArray()) + } +} diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt index 560bda0d8..fd1752434 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.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 org.meshtastic.core.model.util import android.graphics.Bitmap @@ -24,8 +23,10 @@ import co.touchlab.kermit.Logger import com.google.zxing.BarcodeFormat import com.google.zxing.MultiFormatWriter import com.journeyapps.barcodescanner.BarcodeEncoder +import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.Channel -import org.meshtastic.proto.AppOnlyProtos.ChannelSet +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.Config.LoRaConfig import java.net.MalformedURLException private const val MESHTASTIC_HOST = "meshtastic.org" @@ -46,32 +47,42 @@ fun Uri.toChannelSet(): ChannelSet { // Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment. // This gracefully handles those cases until the newer version are generally available/used. - val url = ChannelSet.parseFrom(Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS)) + val fragmentBytes = Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS) + val url = ChannelSet.ADAPTER.decode(fragmentBytes.toByteString()) val shouldAdd = fragment?.substringAfter('?', "")?.takeUnless { it.isBlank() }?.equals("add=true") ?: getBooleanQueryParameter("add", false) - return url.toBuilder().apply { if (shouldAdd) clearLoraConfig() }.build() + return if (shouldAdd) url.copy(lora_config = null) else url } /** @return A list of globally unique channel IDs usable with MQTT subscribe() */ val ChannelSet.subscribeList: List - get() = settingsList.filter { it.downlinkEnabled }.map { Channel(it, loraConfig).name } + get() { + val loraConfig = this.lora_config ?: LoRaConfig() + return settings.filter { it.downlink_enabled }.map { Channel(it, loraConfig).name } + } -fun ChannelSet.getChannel(index: Int): Channel? = - if (settingsCount > index) Channel(getSettings(index), loraConfig) else null +fun ChannelSet.getChannel(index: Int): Channel? = if (settings.size > index) { + val s = settings[index] + Channel(s, lora_config ?: LoRaConfig()) +} else { + null +} /** Return the primary channel info */ val ChannelSet.primaryChannel: Channel? get() = getChannel(0) +fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null + /** * Return a URL that represents the [ChannelSet] * * @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes */ fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): Uri { - val channelBytes = this.toByteArray() ?: ByteArray(0) // if unset just use empty + val channelBytes = ChannelSet.ADAPTER.encode(this) val enc = Base64.encodeToString(channelBytes, BASE64FLAGS) val p = if (upperCasePrefix) URL_PREFIX.uppercase() else URL_PREFIX val query = if (shouldAdd) "?add=true" else "" diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt index 35fec459f..c0e54c3af 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/DistanceExtensions.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,21 +14,20 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - @file:Suppress("MatchingDeclarationName") package org.meshtastic.core.model.util import android.icu.util.LocaleData import android.icu.util.ULocale -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import java.util.Locale enum class DistanceUnit(val symbol: String, val multiplier: Float, val system: Int) { - METER("m", multiplier = 1F, DisplayUnits.METRIC_VALUE), - KILOMETER("km", multiplier = 0.001F, DisplayUnits.METRIC_VALUE), - FOOT("ft", multiplier = 3.28084F, DisplayUnits.IMPERIAL_VALUE), - MILE("mi", multiplier = 0.000621371F, DisplayUnits.IMPERIAL_VALUE), + METER("m", multiplier = 1F, DisplayUnits.METRIC.value), + KILOMETER("km", multiplier = 0.001F, DisplayUnits.METRIC.value), + FOOT("ft", multiplier = 3.28084F, DisplayUnits.IMPERIAL.value), + MILE("mi", multiplier = 0.000621371F, DisplayUnits.IMPERIAL.value), ; companion object { @@ -55,8 +54,8 @@ fun Int.metersIn(unit: DistanceUnit): Float = this * unit.multiplier fun Int.metersIn(system: DisplayUnits): Float { val unit = - when (system.number) { - DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT + when (system.value) { + DisplayUnits.IMPERIAL.value -> DistanceUnit.FOOT else -> DistanceUnit.METER } return this.metersIn(unit) @@ -71,8 +70,8 @@ fun Float.toString(unit: DistanceUnit): String = if (unit in setOf(DistanceUnit. fun Float.toString(system: DisplayUnits): String { val unit = - when (system.number) { - DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT + when (system.value) { + DisplayUnits.IMPERIAL.value -> DistanceUnit.FOOT else -> DistanceUnit.METER } return this.toString(unit) @@ -83,7 +82,7 @@ private const val MILE_THRESHOLD = 1609 fun Int.toDistanceString(system: DisplayUnits): String { val unit = - if (system.number == DisplayUnits.METRIC_VALUE) { + if (system.value == DisplayUnits.METRIC.value) { if (this < KILOMETER_THRESHOLD) DistanceUnit.METER else DistanceUnit.KILOMETER } else { if (this < MILE_THRESHOLD) DistanceUnit.FOOT else DistanceUnit.MILE diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt index 638f9ef2b..a35c49511 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -17,8 +17,8 @@ package org.meshtastic.core.model.util import org.meshtastic.core.model.BuildConfig -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Config +import org.meshtastic.proto.MeshPacket /** * When printing strings to logs sometimes we want to print useful debugging information about users or positions. But @@ -35,28 +35,17 @@ fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString() // A toString that makes sure all newlines are removed (for nice logging). fun Any.toOneLineString() = this.toString().replace('\n', ' ') -fun ConfigProtos.Config.toOneLineString(): String { - val redactedFields = """(wifi_psk:|public_key:|private_key:|admin_key:)\s*".*""" - return this.toString() - .replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" } - .replace('\n', ' ') +fun Config.toOneLineString(): String { + // Wire toString uses field=value format + val redactedFields = """(wifi_psk|public_key|private_key|admin_key)=[^,}]+""" + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') } -fun MeshProtos.MeshPacket.toOneLineString(): String { - val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys - return this.toString() - .replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" } - .replace('\n', ' ') +fun MeshPacket.toOneLineString(): String { + val redactedFields = """(public_key|private_key|admin_key)=[^,}]+""" // Redact keys + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') } -fun MeshProtos.toOneLineString(): String { - val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys - return this.toString() - .replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" } - .replace('\n', ' ') -} - -// Return a one line string version of an object (but if a release build, just say 'might be PII) fun Any.toPIIString() = if (!BuildConfig.DEBUG) { "" } else { diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt new file mode 100644 index 000000000..4389286d4 --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt @@ -0,0 +1,128 @@ +/* + * 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 org.meshtastic.core.model.util + +import co.touchlab.kermit.Logger +import com.squareup.wire.Message +import com.squareup.wire.ProtoAdapter +import okio.ByteString +import okio.ByteString.Companion.toByteString + +@Suppress("unused") // These are extension functions meant to be imported elsewhere +fun > ProtoAdapter.decodeOrNull(bytes: ByteString?, logger: Logger? = null): T? { + if (bytes == null || bytes.size == 0) return null + return runCatching { decode(bytes) } + .onFailure { exception -> logger?.e(exception) { "Failed to decode proto message" } } + .getOrNull() +} + +/** + * Safely decode a proto message from [ByteArray], returning null on error. + * + * Convenience overload for ByteArray inputs, automatically converting to ByteString. + * + * @param bytes The ByteArray to decode, or null + * @param logger Optional logger for error reporting + * @return The decoded message, or null if bytes is null or decoding fails + */ +fun > ProtoAdapter.decodeOrNull(bytes: ByteArray?, logger: Logger? = null): T? { + if (bytes == null || bytes.isEmpty()) return null + return decodeOrNull(bytes.toByteString(), logger) +} + +/** + * Check if an encoded message would fit within a size limit. + * + * More accurate than checking ByteArray.size() as it uses Wire's actual encoding size calculation, which accounts for + * variable-length encoding. + * + * Useful for: + * - Validating packet sizes before transmission + * - Enforcing payload limits + * - Better error messages with actual vs expected sizes + * + * Example: + * ``` + * val data = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = bytes) + * if (!Data.ADAPTER.isWithinSizeLimit(data, MAX_PAYLOAD)) { + * throw RemoteException("Payload too large") + * } + * ``` + * + * @param message The message to check + * @param maxBytes Maximum allowed bytes + * @return true if encodedSize(message) <= maxBytes + */ +fun > ProtoAdapter.isWithinSizeLimit(message: T, maxBytes: Int): Boolean = + encodedSize(message) <= maxBytes + +/** + * Get the estimated encoded size of a message in bytes. + * + * This accounts for variable-length encoding and is more accurate than just using ByteArray.size(). Useful for size + * validation and logging. + * + * @param message The message to measure + * @return Size in bytes when encoded + */ +fun > ProtoAdapter.sizeInBytes(message: T): Int = encodedSize(message) + +/** + * Convert a proto message to a pretty-printed string representation. + * + * This uses Wire's built-in toString() which provides a human-readable format with field names and values. Useful for + * debugging and logging. + * + * Example output: + * ``` + * Position{latitude_i=371234567, longitude_i=-1220987654, altitude=15} + * ``` + * + * @param message The message to format + * @return String representation of the message + */ +fun > ProtoAdapter.toReadableString(message: T): String = message.toString() + +/** + * Log a proto message with readable formatting. + * + * Useful for debugging packet contents during development. + * + * Example: + * ``` + * Position.ADAPTER.logMessage(position, Logger, "Received position update") + * ``` + * + * @param message The message to log + * @param logger The logger instance + * @param prefix Optional prefix message + */ +fun > ProtoAdapter.logMessage(message: T, logger: Logger, prefix: String = "") { + val prefixStr = if (prefix.isNotEmpty()) "$prefix: " else "" + logger.d { "$prefixStr${toReadableString(message)}" } +} + +/** + * Get a compact single-line string representation for JSON/API serialization. + * + * Converts the proto message to a single-line format by replacing newlines. Useful for compact logging and API + * payloads. + * + * @param message The message to format + * @return Single-line string representation + */ +fun > ProtoAdapter.toOneLiner(message: T): String = message.toString().replace('\n', ' ') diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt index 70197d8e2..ecdff6c7f 100644 --- a/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.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,13 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.model import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset +import org.meshtastic.proto.Config class ChannelOptionTest { @@ -33,12 +32,9 @@ class ChannelOptionTest { */ @Test fun `ensure every ModemPreset is mapped in ChannelOption`() { - // Get all possible ModemPreset values, excluding the ones we expect to ignore. + // Get all possible ModemPreset values. val unmappedPresets = - ModemPreset.entries.filter { - // UNRECOGNIZED is a system-generated value for forward compatibility. - it != ModemPreset.UNRECOGNIZED - } + Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" } unmappedPresets.forEach { preset -> // Attempt to find the corresponding ChannelOption @@ -62,7 +58,8 @@ class ChannelOptionTest { */ @Test fun `ensure no extra mappings exist in ChannelOption`() { - val protoPresets = ModemPreset.entries.filter { it != ModemPreset.UNRECOGNIZED }.toSet() + val protoPresets = + Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet() val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet() assertEquals( diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt new file mode 100644 index 000000000..7a2842e1b --- /dev/null +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import android.os.Parcel +import okio.ByteString.Companion.toByteString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DataPacketParcelTest { + + @Test + fun `DataPacket parcelization round trip via writeToParcel and readParcelable`() { + val original = createFullDataPacket() + + val parcel = Parcel.obtain() + // Use writeParcelable to include class information/nullability flag needed by readParcelable + parcel.writeParcelable(original, 0) + parcel.setDataPosition(0) + + @Suppress("DEPRECATION") + val created = parcel.readParcelable(DataPacket::class.java.classLoader) + parcel.recycle() + + assertNotNull(created) + assertDataPacketsEqual(original, created!!) + } + + @Test + fun `DataPacket manual readFromParcel matches writeToParcel`() { + val original = createFullDataPacket() + + // Write using generated writeToParcel (writes content only) + val parcel = Parcel.obtain() + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + // Read using manual readFromParcel + // We start with an empty packet and populate it + val restored = DataPacket(to = "dummy", channel = 0, text = "dummy") + // Reset fields to ensure they are overwritten + restored.to = null + restored.from = null + restored.bytes = null + restored.sfppHash = null + + restored.readFromParcel(parcel) + parcel.recycle() + + assertDataPacketsEqual(original, restored) + } + + @Test + fun `DataPacket with nulls handles parcelization correctly`() { + val original = + DataPacket( + to = null, + bytes = null, + dataType = 99, + from = null, + time = 123L, + status = null, + replyId = null, + relayNode = null, + sfppHash = null, + ) + + val parcel = Parcel.obtain() + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = DataPacket(to = "dummy", channel = 0, text = "dummy") + restored.readFromParcel(parcel) + parcel.recycle() + + assertDataPacketsEqual(original, restored) + } + + private fun createFullDataPacket(): DataPacket = DataPacket( + to = "destNode", + bytes = "Hello World".toByteArray().toByteString(), + dataType = 1, + from = "srcNode", + time = 1234567890L, + id = 42, + status = MessageStatus.DELIVERED, + hopLimit = 3, + channel = 5, + wantAck = true, + hopStart = 7, + snr = 12.5f, + rssi = -80, + replyId = 101, + relayNode = 202, + relays = 1, + viaMqtt = true, + retryCount = 2, + emoji = 0x1F600, + sfppHash = "sfpp".toByteArray().toByteString(), + ) + + private fun assertDataPacketsEqual(expected: DataPacket, actual: DataPacket) { + assertEquals("to", expected.to, actual.to) + assertEquals("bytes", expected.bytes, actual.bytes) + assertEquals("dataType", expected.dataType, actual.dataType) + assertEquals("from", expected.from, actual.from) + assertEquals("time", expected.time, actual.time) + assertEquals("id", expected.id, actual.id) + assertEquals("status", expected.status, actual.status) + assertEquals("hopLimit", expected.hopLimit, actual.hopLimit) + assertEquals("channel", expected.channel, actual.channel) + assertEquals("wantAck", expected.wantAck, actual.wantAck) + assertEquals("hopStart", expected.hopStart, actual.hopStart) + assertEquals("snr", expected.snr, actual.snr, 0.001f) + assertEquals("rssi", expected.rssi, actual.rssi) + assertEquals("replyId", expected.replyId, actual.replyId) + assertEquals("relayNode", expected.relayNode, actual.relayNode) + assertEquals("relays", expected.relays, actual.relays) + assertEquals("viaMqtt", expected.viaMqtt, actual.viaMqtt) + assertEquals("retryCount", expected.retryCount, actual.retryCount) + assertEquals("emoji", expected.emoji, actual.emoji) + assertEquals("sfppHash", expected.sfppHash, actual.sfppHash) + } +} diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt index 5efd7003a..850aaca4f 100644 --- a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.model import android.os.Parcel import kotlinx.serialization.json.Json -import org.junit.Assert.assertArrayEquals +import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Test @@ -31,9 +31,9 @@ import org.robolectric.annotation.Config class DataPacketTest { @Test fun `DataPacket sfppHash is nullable and correctly set`() { - val hash = byteArrayOf(1, 2, 3, 4) + val hash = byteArrayOf(1, 2, 3, 4).toByteString() val packet = DataPacket(to = "to", channel = 0, text = "hello").copy(sfppHash = hash) - assertArrayEquals(hash, packet.sfppHash) + assertEquals(hash, packet.sfppHash) val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello") assertEquals(null, packetNoHash.sfppHash) @@ -47,7 +47,7 @@ class DataPacketTest { @Test fun `DataPacket serialization preserves sfppHash`() { - val hash = byteArrayOf(5, 6, 7, 8) + val hash = byteArrayOf(5, 6, 7, 8).toByteString() val packet = DataPacket(to = "to", channel = 0, text = "test") .copy(sfppHash = hash, status = MessageStatus.SFPP_CONFIRMED) @@ -57,17 +57,17 @@ class DataPacketTest { val decoded = json.decodeFromString(DataPacket.serializer(), encoded) assertEquals(packet.status, decoded.status) - assertArrayEquals(hash, decoded.sfppHash) + assertEquals(hash, decoded.sfppHash) } @Test fun `DataPacket equals and hashCode include sfppHash`() { - val hash1 = byteArrayOf(1, 2, 3) - val hash2 = byteArrayOf(4, 5, 6) + val hash1 = byteArrayOf(1, 2, 3).toByteString() + val hash2 = byteArrayOf(4, 5, 6).toByteString() val fixedTime = 1000L val base = DataPacket(to = "to", channel = 0, text = "text").copy(time = fixedTime) val p1 = base.copy(sfppHash = hash1) - val p2 = base.copy(sfppHash = hash1.copyOf()) // same content, different array instance + val p2 = base.copy(sfppHash = byteArrayOf(1, 2, 3).toByteString()) // same content val p3 = base.copy(sfppHash = hash2) val p4 = base.copy(sfppHash = null) @@ -81,10 +81,12 @@ class DataPacketTest { @Test fun `readFromParcel maintains alignment and updates all fields including bytes and dataType`() { + val bytes = byteArrayOf(1, 2, 3).toByteString() + val sfppHash = byteArrayOf(4, 5, 6).toByteString() val original = DataPacket( to = "recipient", - bytes = byteArrayOf(1, 2, 3), + bytes = bytes, dataType = 42, from = "sender", time = 123456789L, @@ -102,7 +104,7 @@ class DataPacketTest { viaMqtt = true, retryCount = 1, emoji = 10, - sfppHash = byteArrayOf(4, 5, 6), + sfppHash = sfppHash, ) val parcel = Parcel.obtain() @@ -114,7 +116,7 @@ class DataPacketTest { // Verify that all fields were updated correctly assertEquals("recipient", packetToUpdate.to) - assertArrayEquals(byteArrayOf(1, 2, 3), packetToUpdate.bytes) + assertEquals(bytes, packetToUpdate.bytes) assertEquals(42, packetToUpdate.dataType) assertEquals("sender", packetToUpdate.from) assertEquals(123456789L, packetToUpdate.time) @@ -132,7 +134,7 @@ class DataPacketTest { assertEquals(true, packetToUpdate.viaMqtt) assertEquals(1, packetToUpdate.retryCount) assertEquals(10, packetToUpdate.emoji) - assertArrayEquals(byteArrayOf(4, 5, 6), packetToUpdate.sfppHash) + assertEquals(sfppHash, packetToUpdate.sfppHash) parcel.recycle() } diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/NodeInfoTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/NodeInfoTest.kt index 52c497f31..0d10a6426 100644 --- a/core/model/src/test/kotlin/org/meshtastic/core/model/NodeInfoTest.kt +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/NodeInfoTest.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 org.meshtastic.core.model import androidx.core.os.LocaleListCompat @@ -22,12 +21,12 @@ import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Test -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Config +import org.meshtastic.proto.HardwareModel import java.util.Locale class NodeInfoTest { - private val model = MeshProtos.HardwareModel.ANDROID_SIM + private val model = HardwareModel.ANDROID_SIM private val node = listOf( NodeInfo(4, MeshUser("+zero", "User Zero", "U0", model)), @@ -58,9 +57,9 @@ class NodeInfoTest { @Test fun distanceStrGood() { - Assert.assertEquals(node[1].distanceStr(node[2], DisplayUnits.METRIC_VALUE), "1.1 km") - Assert.assertEquals(node[1].distanceStr(node[3], DisplayUnits.METRIC_VALUE), "111 m") - Assert.assertEquals(node[1].distanceStr(node[4], DisplayUnits.IMPERIAL_VALUE), "1.1 mi") - Assert.assertEquals(node[1].distanceStr(node[3], DisplayUnits.IMPERIAL_VALUE), "364 ft") + Assert.assertEquals(node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value), "1.1 km") + Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value), "111 m") + Assert.assertEquals(node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "1.1 mi") + Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "364 ft") } } diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt new file mode 100644 index 000000000..38a3c0b7a --- /dev/null +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt @@ -0,0 +1,332 @@ +/* + * 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 org.meshtastic.core.model.util + +import co.touchlab.kermit.Logger +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.Position +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User + +/** + * Unit tests for Wire extension functions. + * + * Tests safe decoding, size validation, and JSON marshalling extensions to ensure proper error handling and + * functionality. + */ +class WireExtensionsTest { + + private val testLogger = Logger + + @Before + fun setUp() { + // Setup test logger if needed + } + + // ===== decodeOrNull() Tests ===== + + @Test + fun `decodeOrNull with valid ByteString returns decoded message`() { + // Arrange + val position = Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15) + val encoded = Position.ADAPTER.encode(position) + val byteString = encoded.toByteString() + + // Act + val decoded = Position.ADAPTER.decodeOrNull(byteString, testLogger) + + // Assert + assertNotNull(decoded) + assertEquals(position.latitude_i, decoded!!.latitude_i) + assertEquals(position.longitude_i, decoded.longitude_i) + assertEquals(position.altitude, decoded.altitude) + } + + @Test + fun `decodeOrNull with null ByteString returns null`() { + // Act + val result = Position.ADAPTER.decodeOrNull(null as ByteString?, testLogger) + + // Assert + assertNull(result) + } + + @Test + fun `decodeOrNull with empty ByteString returns null`() { + // Act + val result = Position.ADAPTER.decodeOrNull(ByteString.EMPTY, testLogger) + + // Assert + assertNull(result) + } + + @Test + fun `decodeOrNull with valid ByteArray returns decoded message`() { + // Arrange + val position = Position(latitude_i = 371234567, longitude_i = -1220987654) + val encoded = Position.ADAPTER.encode(position) + + // Act + val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger) + + // Assert + assertNotNull(decoded) + assertEquals(position.latitude_i, decoded!!.latitude_i) + assertEquals(position.longitude_i, decoded.longitude_i) + } + + @Test + fun `decodeOrNull with null ByteArray returns null`() { + // Act + val result = Position.ADAPTER.decodeOrNull(null as ByteArray?, testLogger) + + // Assert + assertNull(result) + } + + @Test + fun `decodeOrNull with empty ByteArray returns null`() { + // Act + val result = Position.ADAPTER.decodeOrNull(ByteArray(0), testLogger) + + // Assert + assertNull(result) + } + + @Test + fun `decodeOrNull with invalid data returns null`() { + // Arrange + val invalidBytes = byteArrayOfInts(0xFF, 0xFF, 0xFF, 0xFF).toByteString() + + // Act - should not throw, should return null + val result = Position.ADAPTER.decodeOrNull(invalidBytes, testLogger) + + // Assert + assertNull(result) + } + + // ===== Size Validation Tests ===== + + @Test + fun `isWithinSizeLimit returns true for message under limit`() { + // Arrange + val position = Position(latitude_i = 371234567) + val limit = 1000 + + // Act + val isValid = Position.ADAPTER.isWithinSizeLimit(position, limit) + + // Assert + assertTrue(isValid) + } + + @Test + fun `isWithinSizeLimit returns false for message over limit`() { + // Arrange + val telemetry = + Telemetry( + device_metrics = + DeviceMetrics(voltage = 4.2f, battery_level = 85, air_util_tx = 5.0f, channel_utilization = 15.0f), + ) + val limit = 1 // Artificially low limit + + // Act + val isValid = Telemetry.ADAPTER.isWithinSizeLimit(telemetry, limit) + + // Assert + assertEquals(false, isValid) + } + + @Test + fun `sizeInBytes returns accurate encoded size`() { + // Arrange + val position = Position(latitude_i = 371234567, longitude_i = -1220987654) + + // Act + val size = Position.ADAPTER.sizeInBytes(position) + val actualEncoded = Position.ADAPTER.encode(position) + + // Assert + assertEquals(actualEncoded.size, size) + assertTrue(size > 0) + } + + @Test + fun `sizeInBytes for empty message`() { + // Arrange + val emptyPosition = Position() + + // Act + val size = Position.ADAPTER.sizeInBytes(emptyPosition) + + // Assert + assertTrue(size >= 0) + } + + @Test + fun `sizeInBytes matches wire encoding size`() { + // Arrange + val user = User(id = "12345", long_name = "Test User", short_name = "TU") + + // Act + val extensionSize = User.ADAPTER.sizeInBytes(user) + val actualEncoded = User.ADAPTER.encode(user) + + // Assert + assertEquals(extensionSize, actualEncoded.size) + } + + // ===== JSON Marshalling Tests ===== + + @Test + fun `toReadableString returns non-empty string`() { + // Arrange + val position = Position(latitude_i = 371234567, longitude_i = -1220987654) + + // Act + val readable = Position.ADAPTER.toReadableString(position) + + // Assert + assertNotNull(readable) + assertTrue(readable.isNotEmpty()) + assertTrue(readable.contains("Position")) + } + + @Test + fun `toReadableString contains field values`() { + // Arrange + val position = Position(latitude_i = 12345, longitude_i = 67890) + + // Act + val readable = Position.ADAPTER.toReadableString(position) + + // Assert + assertTrue(readable.contains("12345")) + assertTrue(readable.contains("67890")) + } + + @Test + fun `toOneLiner returns single line string`() { + // Arrange + val telemetry = Telemetry(device_metrics = DeviceMetrics(voltage = 4.2f)) + + // Act + val oneLiner = Telemetry.ADAPTER.toOneLiner(telemetry) + + // Assert + assertNotNull(oneLiner) + assertEquals(false, oneLiner.contains("\n")) + assertTrue(oneLiner.isNotEmpty()) + } + + @Test + fun `toOneLiner contains essential data`() { + // Arrange + val user = User(long_name = "Test User") + + // Act + val oneLiner = User.ADAPTER.toOneLiner(user) + + // Assert + assertTrue(oneLiner.contains("Test User")) + } + + // ===== Integration Tests ===== + + @Test + fun `decode and encode roundtrip maintains data`() { + // Arrange + val originalPosition = + Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15, precision_bits = 5) + val encoded = Position.ADAPTER.encode(originalPosition) + + // Act + val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger) + + // Assert + assertNotNull(decoded) + assertEquals(originalPosition.latitude_i, decoded!!.latitude_i) + assertEquals(originalPosition.longitude_i, decoded.longitude_i) + assertEquals(originalPosition.altitude, decoded.altitude) + assertEquals(originalPosition.precision_bits, decoded.precision_bits) + } + + @Test + fun `size checking prevents oversized messages`() { + // Arrange + val position = Position(latitude_i = 123456789, longitude_i = 987654321, altitude = 100) + val maxSize = 5 // Very small limit + + // Act + val isValid = Position.ADAPTER.isWithinSizeLimit(position, maxSize) + val actualSize = Position.ADAPTER.sizeInBytes(position) + + // Assert + assertEquals(false, isValid) + assertTrue(actualSize > maxSize) + } + + @Test + fun `multiple messages with different sizes`() { + // Arrange + val smallUser = User(short_name = "A") + val largeUser = User(long_name = "Very Long Name " + "X".repeat(100)) + + // Act + val smallSize = User.ADAPTER.sizeInBytes(smallUser) + val largeSize = User.ADAPTER.sizeInBytes(largeUser) + + // Assert + assertTrue(smallSize < largeSize) + assertTrue(largeSize > smallSize) + } + + @Test + fun `readable string format consistency`() { + // Arrange + val position = Position(latitude_i = 123456) + + // Act + val readable1 = Position.ADAPTER.toReadableString(position) + val readable2 = Position.ADAPTER.toReadableString(position) + + // Assert + assertEquals(readable1, readable2) + } + + @Test + fun `oneLiner format consistency`() { + // Arrange + val user = User(long_name = "Test") + + // Act + val line1 = User.ADAPTER.toOneLiner(user) + val line2 = User.ADAPTER.toOneLiner(user) + + // Assert + assertEquals(line1, line2) + assertEquals(false, line1.contains("\n")) + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 6fca0f3cc..4687c5aee 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -37,7 +37,6 @@ plugins { alias(libs.plugins.meshtastic.android.library) alias(libs.plugins.meshtastic.hilt) alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.protobuf) } configure { diff --git a/core/proto/build.gradle.kts b/core/proto/build.gradle.kts index 2ddab69c3..1c3efef7a 100644 --- a/core/proto/build.gradle.kts +++ b/core/proto/build.gradle.kts @@ -14,52 +14,49 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import com.android.build.api.dsl.LibraryExtension plugins { - alias(libs.plugins.meshtastic.android.library) - alias(libs.plugins.protobuf) + alias(libs.plugins.meshtastic.kmp.library) + alias(libs.plugins.wire) `maven-publish` } apply(from = rootProject.file("gradle/publishing.gradle.kts")) -afterEvaluate { - publishing { - publications { - create("release") { - from(components["googleRelease"]) - artifactId = "core-proto" - } +kotlin { + // Keep jvm() for desktop/server consumers + jvm() + + // Override minSdk for ATAK compatibility (standard is 26) + androidLibrary { minSdk = 21 } + + sourceSets { commonMain.dependencies { api(libs.wire.runtime) } } +} + +wire { + sourcePath { + srcDir("src/main/proto") + srcDir("src/main/wire-includes") + } + kotlin { + // Flattens 'oneof' fields into nullable properties on the parent class. + // This removes the intermediate sealed classes, simplifying usage and reducing method count/binary size. + // Codebase is already written to use the nullable properties (e.g. packet.decoded vs + // packet.payload_variant.decoded). + boxOneOfsMinSize = 5000 + } + root("meshtastic.*") +} + +// Modern KMP publication uses the project name as the artifactId by default. +// We rename the publications to include the 'core-' prefix for consistency. +publishing { + publications.withType().configureEach { + val baseId = artifactId + if (baseId == "proto") { + artifactId = "core-proto" + } else if (baseId.startsWith("proto-")) { + artifactId = baseId.replace("proto-", "core-proto-") } } } - -configure { - namespace = "org.meshtastic.core.proto" - - defaultConfig { - // Lowering minSdk to 21 for better compatibility with ATAK and other plugins - minSdk = 21 - } - - publishing { singleVariant("googleRelease") { withSourcesJar() } } -} - -// per protobuf-gradle-plugin docs, this is recommended for android -protobuf { - protoc { artifact = libs.protobuf.protoc.get().toString() } - generateProtoTasks { - all().forEach { task -> - task.builtins { - create("java") {} - create("kotlin") {} - } - } - } -} - -dependencies { - // This needs to be API for consuming modules - api(libs.protobuf.kotlin) -} diff --git a/core/proto/consumer-rules.pro b/core/proto/consumer-rules.pro index 92c60d123..e9dc3751a 100644 --- a/core/proto/consumer-rules.pro +++ b/core/proto/consumer-rules.pro @@ -1,6 +1,43 @@ --keep class org.meshtastic.proto.MeshProtos$DeviceMetadata --keep class org.meshtastic.proto.MeshProtos$FromRadio --keep class org.meshtastic.proto.MeshProtos$Position --keep class org.meshtastic.proto.MeshProtos$User --keep class org.meshtastic.proto.PaxcountProtos$Paxcount --keep class org.meshtastic.proto.TelemetryProtos$Telemetry +# Core proto classes required for packet handling and serialization +# FromRadio and related message types (primary packet container) +-keep class org.meshtastic.proto.FromRadio +-keep class org.meshtastic.proto.Data +-keep class org.meshtastic.proto.MeshPacket +-keep class org.meshtastic.proto.LogRecord + +# Message type payloads (handled in packet routing) +-keep class org.meshtastic.proto.AdminMessage +-keep class org.meshtastic.proto.StoreAndForward +-keep class org.meshtastic.proto.StoreForwardPlusPlus +-keep class org.meshtastic.proto.Routing + +# User and Node information +-keep class org.meshtastic.proto.User +-keep class org.meshtastic.proto.NeighborInfo +-keep class org.meshtastic.proto.Neighbor + +# Location and environment data +-keep class org.meshtastic.proto.Position +-keep class org.meshtastic.proto.Waypoint +-keep class org.meshtastic.proto.StatusMessage + +# Telemetry data types +-keep class org.meshtastic.proto.Telemetry +-keep class org.meshtastic.proto.DeviceMetrics +-keep class org.meshtastic.proto.EnvironmentMetrics +-keep class org.meshtastic.proto.AirQualityMetrics +-keep class org.meshtastic.proto.PowerMetrics +-keep class org.meshtastic.proto.LocalStats +-keep class org.meshtastic.proto.HostMetrics + +# Other data +-keep class org.meshtastic.proto.Paxcount +-keep class org.meshtastic.proto.DeviceMetadata + +# Configuration classes +-keep class org.meshtastic.proto.ChannelSet +-keep class org.meshtastic.proto.LocalConfig +-keep class org.meshtastic.proto.Config +-keep class org.meshtastic.proto.ModuleConfig +-keep class org.meshtastic.proto.Channel +-keep class org.meshtastic.proto.ClientNotification diff --git a/core/proto/src/main/wire-includes/google/protobuf/descriptor.proto b/core/proto/src/main/wire-includes/google/protobuf/descriptor.proto new file mode 100644 index 000000000..f8eb216cd --- /dev/null +++ b/core/proto/src/main/wire-includes/google/protobuf/descriptor.proto @@ -0,0 +1,921 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Author: kenton@google.com (Kenton Varda) +// Based on original Protocol Buffers design by +// Sanjay Ghemawat, Jeff Dean, and others. +// +// The messages in this file describe the definitions found in .proto files. +// A valid .proto file can be translated directly to a FileDescriptorProto +// without any other information (e.g. without reading its imports). + + +syntax = "proto2"; + +package google.protobuf; + +option go_package = "google.golang.org/protobuf/types/descriptorpb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "DescriptorProtos"; +option csharp_namespace = "Google.Protobuf.Reflection"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; + +// descriptor.proto must be optimized for speed because reflection-based +// algorithms don't work during bootstrapping. +option optimize_for = SPEED; + +// The protocol compiler can output a FileDescriptorSet containing the .proto +// files it parses. +message FileDescriptorSet { + repeated FileDescriptorProto file = 1; +} + +// Describes a complete .proto file. +message FileDescriptorProto { + optional string name = 1; // file name, relative to root of source tree + optional string package = 2; // e.g. "foo", "foo.bar", etc. + + // Names of files imported by this file. + repeated string dependency = 3; + // Indexes of the public imported files in the dependency list above. + repeated int32 public_dependency = 10; + // Indexes of the weak imported files in the dependency list. + // For Google-internal migration only. Do not use. + repeated int32 weak_dependency = 11; + + // All top-level definitions in this file. + repeated DescriptorProto message_type = 4; + repeated EnumDescriptorProto enum_type = 5; + repeated ServiceDescriptorProto service = 6; + repeated FieldDescriptorProto extension = 7; + + optional FileOptions options = 8; + + // This field contains optional information about the original source code. + // You may safely remove this entire field without harming runtime + // functionality of the descriptors -- the information is needed only by + // development tools. + optional SourceCodeInfo source_code_info = 9; + + // The syntax of the proto file. + // The supported values are "proto2" and "proto3". + optional string syntax = 12; +} + +// Describes a message type. +message DescriptorProto { + optional string name = 1; + + repeated FieldDescriptorProto field = 2; + repeated FieldDescriptorProto extension = 6; + + repeated DescriptorProto nested_type = 3; + repeated EnumDescriptorProto enum_type = 4; + + message ExtensionRange { + optional int32 start = 1; // Inclusive. + optional int32 end = 2; // Exclusive. + + optional ExtensionRangeOptions options = 3; + } + repeated ExtensionRange extension_range = 5; + + repeated OneofDescriptorProto oneof_decl = 8; + + optional MessageOptions options = 7; + + // Range of reserved tag numbers. Reserved tag numbers may not be used by + // fields or extension ranges in the same message. Reserved ranges may + // not overlap. + message ReservedRange { + optional int32 start = 1; // Inclusive. + optional int32 end = 2; // Exclusive. + } + repeated ReservedRange reserved_range = 9; + // Reserved field names, which may not be used by fields in the same message. + // A given name may only be reserved once. + repeated string reserved_name = 10; +} + +message ExtensionRangeOptions { + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +// Describes a field within a message. +message FieldDescriptorProto { + enum Type { + // 0 is reserved for errors. + // Order is weird for historical reasons. + TYPE_DOUBLE = 1; + TYPE_FLOAT = 2; + // Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT64 if + // negative values are likely. + TYPE_INT64 = 3; + TYPE_UINT64 = 4; + // Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT32 if + // negative values are likely. + TYPE_INT32 = 5; + TYPE_FIXED64 = 6; + TYPE_FIXED32 = 7; + TYPE_BOOL = 8; + TYPE_STRING = 9; + // Tag-delimited aggregate. + // Group type is deprecated and not supported in proto3. However, Proto3 + // implementations should still be able to parse the group wire format and + // treat group fields as unknown fields. + TYPE_GROUP = 10; + TYPE_MESSAGE = 11; // Length-delimited aggregate. + + // New in version 2. + TYPE_BYTES = 12; + TYPE_UINT32 = 13; + TYPE_ENUM = 14; + TYPE_SFIXED32 = 15; + TYPE_SFIXED64 = 16; + TYPE_SINT32 = 17; // Uses ZigZag encoding. + TYPE_SINT64 = 18; // Uses ZigZag encoding. + } + + enum Label { + // 0 is reserved for errors + LABEL_OPTIONAL = 1; + LABEL_REQUIRED = 2; + LABEL_REPEATED = 3; + } + + optional string name = 1; + optional int32 number = 3; + optional Label label = 4; + + // If type_name is set, this need not be set. If both this and type_name + // are set, this must be one of TYPE_ENUM, TYPE_MESSAGE or TYPE_GROUP. + optional Type type = 5; + + // For message and enum types, this is the name of the type. If the name + // starts with a '.', it is fully-qualified. Otherwise, C++-like scoping + // rules are used to find the type (i.e. first the nested types within this + // message are searched, then within the parent, on up to the root + // namespace). + optional string type_name = 6; + + // For extensions, this is the name of the type being extended. It is + // resolved in the same manner as type_name. + optional string extendee = 2; + + // For numeric types, contains the original text representation of the value. + // For booleans, "true" or "false". + // For strings, contains the default text contents (not escaped in any way). + // For bytes, contains the C escaped value. All bytes >= 128 are escaped. + optional string default_value = 7; + + // If set, gives the index of a oneof in the containing type's oneof_decl + // list. This field is a member of that oneof. + optional int32 oneof_index = 9; + + // JSON name of this field. The value is set by protocol compiler. If the + // user has set a "json_name" option on this field, that option's value + // will be used. Otherwise, it's deduced from the field's name by converting + // it to camelCase. + optional string json_name = 10; + + optional FieldOptions options = 8; + + // If true, this is a proto3 "optional". When a proto3 field is optional, it + // tracks presence regardless of field type. + // + // When proto3_optional is true, this field must be belong to a oneof to + // signal to old proto3 clients that presence is tracked for this field. This + // oneof is known as a "synthetic" oneof, and this field must be its sole + // member (each proto3 optional field gets its own synthetic oneof). Synthetic + // oneofs exist in the descriptor only, and do not generate any API. Synthetic + // oneofs must be ordered after all "real" oneofs. + // + // For message fields, proto3_optional doesn't create any semantic change, + // since non-repeated message fields always track presence. However it still + // indicates the semantic detail of whether the user wrote "optional" or not. + // This can be useful for round-tripping the .proto file. For consistency we + // give message fields a synthetic oneof also, even though it is not required + // to track presence. This is especially important because the parser can't + // tell if a field is a message or an enum, so it must always create a + // synthetic oneof. + // + // Proto2 optional fields do not set this flag, because they already indicate + // optional with `LABEL_OPTIONAL`. + optional bool proto3_optional = 17; +} + +// Describes a oneof. +message OneofDescriptorProto { + optional string name = 1; + optional OneofOptions options = 2; +} + +// Describes an enum type. +message EnumDescriptorProto { + optional string name = 1; + + repeated EnumValueDescriptorProto value = 2; + + optional EnumOptions options = 3; + + // Range of reserved numeric values. Reserved values may not be used by + // entries in the same enum. Reserved ranges may not overlap. + // + // Note that this is distinct from DescriptorProto.ReservedRange in that it + // is inclusive such that it can appropriately represent the entire int32 + // domain. + message EnumReservedRange { + optional int32 start = 1; // Inclusive. + optional int32 end = 2; // Inclusive. + } + + // Range of reserved numeric values. Reserved numeric values may not be used + // by enum values in the same enum declaration. Reserved ranges may not + // overlap. + repeated EnumReservedRange reserved_range = 4; + + // Reserved enum value names, which may not be reused. A given name may only + // be reserved once. + repeated string reserved_name = 5; +} + +// Describes a value within an enum. +message EnumValueDescriptorProto { + optional string name = 1; + optional int32 number = 2; + + optional EnumValueOptions options = 3; +} + +// Describes a service. +message ServiceDescriptorProto { + optional string name = 1; + repeated MethodDescriptorProto method = 2; + + optional ServiceOptions options = 3; +} + +// Describes a method of a service. +message MethodDescriptorProto { + optional string name = 1; + + // Input and output type names. These are resolved in the same way as + // FieldDescriptorProto.type_name, but must refer to a message type. + optional string input_type = 2; + optional string output_type = 3; + + optional MethodOptions options = 4; + + // Identifies if client streams multiple client messages + optional bool client_streaming = 5 [default = false]; + // Identifies if server streams multiple server messages + optional bool server_streaming = 6 [default = false]; +} + + +// =================================================================== +// Options + +// Each of the definitions above may have "options" attached. These are +// just annotations which may cause code to be generated slightly differently +// or may contain hints for code that manipulates protocol messages. +// +// Clients may define custom options as extensions of the *Options messages. +// These extensions may not yet be known at parsing time, so the parser cannot +// store the values in them. Instead it stores them in a field in the *Options +// message called uninterpreted_option. This field must have the same name +// across all *Options messages. We then use this field to populate the +// extensions when we build a descriptor, at which point all protos have been +// parsed and so all extensions are known. +// +// Extension numbers for custom options may be chosen as follows: +// * For options which will only be used within a single application or +// organization, or for experimental options, use field numbers 50000 +// through 99999. It is up to you to ensure that you do not use the +// same number for multiple options. +// * For options which will be published and used publicly by multiple +// independent entities, e-mail protobuf-global-extension-registry@google.com +// to reserve extension numbers. Simply provide your project name (e.g. +// Objective-C plugin) and your project website (if available) -- there's no +// need to explain how you intend to use them. Usually you only need one +// extension number. You can declare multiple options with only one extension +// number by putting them in a sub-message. See the Custom Options section of +// the docs for examples: +// https://developers.google.com/protocol-buffers/docs/proto#options +// If this turns out to be popular, a web service will be set up +// to automatically assign option numbers. + +message FileOptions { + + // Sets the Java package where classes generated from this .proto will be + // placed. By default, the proto package is used, but this is often + // inappropriate because proto packages do not normally start with backwards + // domain names. + optional string java_package = 1; + + + // Controls the name of the wrapper Java class generated for the .proto file. + // That class will always contain the .proto file's getDescriptor() method as + // well as any top-level extensions defined in the .proto file. + // If java_multiple_files is disabled, then all the other classes from the + // .proto file will be nested inside the single wrapper outer class. + optional string java_outer_classname = 8; + + // If enabled, then the Java code generator will generate a separate .java + // file for each top-level message, enum, and service defined in the .proto + // file. Thus, these types will *not* be nested inside the wrapper class + // named by java_outer_classname. However, the wrapper class will still be + // generated to contain the file's getDescriptor() method as well as any + // top-level extensions defined in the file. + optional bool java_multiple_files = 10 [default = false]; + + // This option does nothing. + optional bool java_generate_equals_and_hash = 20 [deprecated=true]; + + // If set true, then the Java2 code generator will generate code that + // throws an exception whenever an attempt is made to assign a non-UTF-8 + // byte sequence to a string field. + // Message reflection will do the same. + // However, an extension field still accepts non-UTF-8 byte sequences. + // This option has no effect on when used with the lite runtime. + optional bool java_string_check_utf8 = 27 [default = false]; + + + // Generated classes can be optimized for speed or code size. + enum OptimizeMode { + SPEED = 1; // Generate complete code for parsing, serialization, + // etc. + CODE_SIZE = 2; // Use ReflectionOps to implement these methods. + LITE_RUNTIME = 3; // Generate code using MessageLite and the lite runtime. + } + optional OptimizeMode optimize_for = 9 [default = SPEED]; + + // Sets the Go package where structs generated from this .proto will be + // placed. If omitted, the Go package will be derived from the following: + // - The basename of the package import path, if provided. + // - Otherwise, the package statement in the .proto file, if present. + // - Otherwise, the basename of the .proto file, without extension. + optional string go_package = 11; + + + + + // Should generic services be generated in each language? "Generic" services + // are not specific to any particular RPC system. They are generated by the + // main code generators in each language (without additional plugins). + // Generic services were the only kind of service generation supported by + // early versions of google.protobuf. + // + // Generic services are now considered deprecated in favor of using plugins + // that generate code specific to your particular RPC system. Therefore, + // these default to false. Old code which depends on generic services should + // explicitly set them to true. + optional bool cc_generic_services = 16 [default = false]; + optional bool java_generic_services = 17 [default = false]; + optional bool py_generic_services = 18 [default = false]; + optional bool php_generic_services = 42 [default = false]; + + // Is this file deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for everything in the file, or it will be completely ignored; in the very + // least, this is a formalization for deprecating files. + optional bool deprecated = 23 [default = false]; + + // Enables the use of arenas for the proto messages in this file. This applies + // only to generated classes for C++. + optional bool cc_enable_arenas = 31 [default = true]; + + + // Sets the objective c class prefix which is prepended to all objective c + // generated classes from this .proto. There is no default. + optional string objc_class_prefix = 36; + + // Namespace for generated classes; defaults to the package. + optional string csharp_namespace = 37; + + // By default Swift generators will take the proto package and CamelCase it + // replacing '.' with underscore and use that to prefix the types/symbols + // defined. When this options is provided, they will use this value instead + // to prefix the types/symbols defined. + optional string swift_prefix = 39; + + // Sets the php class prefix which is prepended to all php generated classes + // from this .proto. Default is empty. + optional string php_class_prefix = 40; + + // Use this option to change the namespace of php generated classes. Default + // is empty. When this option is empty, the package name will be used for + // determining the namespace. + optional string php_namespace = 41; + + // Use this option to change the namespace of php generated metadata classes. + // Default is empty. When this option is empty, the proto file name will be + // used for determining the namespace. + optional string php_metadata_namespace = 44; + + // Use this option to change the package of ruby generated classes. Default + // is empty. When this option is not set, the package name will be used for + // determining the ruby package. + optional string ruby_package = 45; + + + // The parser stores options it doesn't recognize here. + // See the documentation for the "Options" section above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. + // See the documentation for the "Options" section above. + extensions 1000 to max; + + reserved 38; +} + +message MessageOptions { + // Set true to use the old proto1 MessageSet wire format for extensions. + // This is provided for backwards-compatibility with the MessageSet wire + // format. You should not use this for any other reason: It's less + // efficient, has fewer features, and is more complicated. + // + // The message must be defined exactly as follows: + // message Foo { + // option message_set_wire_format = true; + // extensions 4 to max; + // } + // Note that the message cannot have any defined fields; MessageSets only + // have extensions. + // + // All extensions of your type must be singular messages; e.g. they cannot + // be int32s, enums, or repeated messages. + // + // Because this is an option, the above two restrictions are not enforced by + // the protocol compiler. + optional bool message_set_wire_format = 1 [default = false]; + + // Disables the generation of the standard "descriptor()" accessor, which can + // conflict with a field of the same name. This is meant to make migration + // from proto1 easier; new code should avoid fields named "descriptor". + optional bool no_standard_descriptor_accessor = 2 [default = false]; + + // Is this message deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the message, or it will be completely ignored; in the very least, + // this is a formalization for deprecating messages. + optional bool deprecated = 3 [default = false]; + + reserved 4, 5, 6; + + // Whether the message is an automatically generated map entry type for the + // maps field. + // + // For maps fields: + // map map_field = 1; + // The parsed descriptor looks like: + // message MapFieldEntry { + // option map_entry = true; + // optional KeyType key = 1; + // optional ValueType value = 2; + // } + // repeated MapFieldEntry map_field = 1; + // + // Implementations may choose not to generate the map_entry=true message, but + // use a native map in the target language to hold the keys and values. + // The reflection APIs in such implementations still need to work as + // if the field is a repeated message field. + // + // NOTE: Do not set the option in .proto files. Always use the maps syntax + // instead. The option should only be implicitly set by the proto compiler + // parser. + optional bool map_entry = 7; + + reserved 8; // javalite_serializable + reserved 9; // javanano_as_lite + + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message FieldOptions { + // The ctype option instructs the C++ code generator to use a different + // representation of the field than it normally would. See the specific + // options below. This option is not yet implemented in the open source + // release -- sorry, we'll try to include it in a future version! + optional CType ctype = 1 [default = STRING]; + enum CType { + // Default mode. + STRING = 0; + + CORD = 1; + + STRING_PIECE = 2; + } + // The packed option can be enabled for repeated primitive fields to enable + // a more efficient representation on the wire. Rather than repeatedly + // writing the tag and type for each element, the entire array is encoded as + // a single length-delimited blob. In proto3, only explicit setting it to + // false will avoid using packed encoding. + optional bool packed = 2; + + // The jstype option determines the JavaScript type used for values of the + // field. The option is permitted only for 64 bit integral and fixed types + // (int64, uint64, sint64, fixed64, sfixed64). A field with jstype JS_STRING + // is represented as JavaScript string, which avoids loss of precision that + // can happen when a large value is converted to a floating point JavaScript. + // Specifying JS_NUMBER for the jstype causes the generated JavaScript code to + // use the JavaScript "number" type. The behavior of the default option + // JS_NORMAL is implementation dependent. + // + // This option is an enum to permit additional types to be added, e.g. + // goog.math.Integer. + optional JSType jstype = 6 [default = JS_NORMAL]; + enum JSType { + // Use the default type. + JS_NORMAL = 0; + + // Use JavaScript strings. + JS_STRING = 1; + + // Use JavaScript numbers. + JS_NUMBER = 2; + } + + // Should this field be parsed lazily? Lazy applies only to message-type + // fields. It means that when the outer message is initially parsed, the + // inner message's contents will not be parsed but instead stored in encoded + // form. The inner message will actually be parsed when it is first accessed. + // + // This is only a hint. Implementations are free to choose whether to use + // eager or lazy parsing regardless of the value of this option. However, + // setting this option true suggests that the protocol author believes that + // using lazy parsing on this field is worth the additional bookkeeping + // overhead typically needed to implement it. + // + // This option does not affect the public interface of any generated code; + // all method signatures remain the same. Furthermore, thread-safety of the + // interface is not affected by this option; const methods remain safe to + // call from multiple threads concurrently, while non-const methods continue + // to require exclusive access. + // + // + // Note that implementations may choose not to check required fields within + // a lazy sub-message. That is, calling IsInitialized() on the outer message + // may return true even if the inner message has missing required fields. + // This is necessary because otherwise the inner message would have to be + // parsed in order to perform the check, defeating the purpose of lazy + // parsing. An implementation which chooses not to check required fields + // must be consistent about it. That is, for any particular sub-message, the + // implementation must either *always* check its required fields, or *never* + // check its required fields, regardless of whether or not the message has + // been parsed. + // + // As of 2021, lazy does no correctness checks on the byte stream during + // parsing. This may lead to crashes if and when an invalid byte stream is + // finally parsed upon access. + // + // TODO(b/211906113): Enable validation on lazy fields. + optional bool lazy = 5 [default = false]; + + // unverified_lazy does no correctness checks on the byte stream. This should + // only be used where lazy with verification is prohibitive for performance + // reasons. + optional bool unverified_lazy = 15 [default = false]; + + // Is this field deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for accessors, or it will be completely ignored; in the very least, this + // is a formalization for deprecating fields. + optional bool deprecated = 3 [default = false]; + + // For Google-internal migration only. Do not use. + optional bool weak = 10 [default = false]; + + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; + + reserved 4; // removed jtype +} + +message OneofOptions { + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message EnumOptions { + + // Set this option to true to allow mapping different tag names to the same + // value. + optional bool allow_alias = 2; + + // Is this enum deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the enum, or it will be completely ignored; in the very least, this + // is a formalization for deprecating enums. + optional bool deprecated = 3 [default = false]; + + reserved 5; // javanano_as_lite + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message EnumValueOptions { + // Is this enum value deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the enum value, or it will be completely ignored; in the very least, + // this is a formalization for deprecating enum values. + optional bool deprecated = 1 [default = false]; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message ServiceOptions { + + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + // Is this service deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the service, or it will be completely ignored; in the very least, + // this is a formalization for deprecating services. + optional bool deprecated = 33 [default = false]; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message MethodOptions { + + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + // Is this method deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the method, or it will be completely ignored; in the very least, + // this is a formalization for deprecating methods. + optional bool deprecated = 33 [default = false]; + + // Is this method side-effect-free (or safe in HTTP parlance), or idempotent, + // or neither? HTTP based RPC implementation may choose GET verb for safe + // methods, and PUT verb for idempotent methods instead of the default POST. + enum IdempotencyLevel { + IDEMPOTENCY_UNKNOWN = 0; + NO_SIDE_EFFECTS = 1; // implies idempotent + IDEMPOTENT = 2; // idempotent, but may have side effects + } + optional IdempotencyLevel idempotency_level = 34 + [default = IDEMPOTENCY_UNKNOWN]; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + + +// A message representing a option the parser does not recognize. This only +// appears in options protos created by the compiler::Parser class. +// DescriptorPool resolves these when building Descriptor objects. Therefore, +// options protos in descriptor objects (e.g. returned by Descriptor::options(), +// or produced by Descriptor::CopyTo()) will never have UninterpretedOptions +// in them. +message UninterpretedOption { + // The name of the uninterpreted option. Each string represents a segment in + // a dot-separated name. is_extension is true iff a segment represents an + // extension (denoted with parentheses in options specs in .proto files). + // E.g.,{ ["foo", false], ["bar.baz", true], ["moo", false] } represents + // "foo.(bar.baz).moo". + message NamePart { + required string name_part = 1; + required bool is_extension = 2; + } + repeated NamePart name = 2; + + // The value of the uninterpreted option, in whatever type the tokenizer + // identified it as during parsing. Exactly one of these should be set. + optional string identifier_value = 3; + optional uint64 positive_int_value = 4; + optional int64 negative_int_value = 5; + optional double double_value = 6; + optional bytes string_value = 7; + optional string aggregate_value = 8; +} + +// =================================================================== +// Optional source code info + +// Encapsulates information about the original source file from which a +// FileDescriptorProto was generated. +message SourceCodeInfo { + // A Location identifies a piece of source code in a .proto file which + // corresponds to a particular definition. This information is intended + // to be useful to IDEs, code indexers, documentation generators, and similar + // tools. + // + // For example, say we have a file like: + // message Foo { + // optional string foo = 1; + // } + // Let's look at just the field definition: + // optional string foo = 1; + // ^ ^^ ^^ ^ ^^^ + // a bc de f ghi + // We have the following locations: + // span path represents + // [a,i) [ 4, 0, 2, 0 ] The whole field definition. + // [a,b) [ 4, 0, 2, 0, 4 ] The label (optional). + // [c,d) [ 4, 0, 2, 0, 5 ] The type (string). + // [e,f) [ 4, 0, 2, 0, 1 ] The name (foo). + // [g,h) [ 4, 0, 2, 0, 3 ] The number (1). + // + // Notes: + // - A location may refer to a repeated field itself (i.e. not to any + // particular index within it). This is used whenever a set of elements are + // logically enclosed in a single code segment. For example, an entire + // extend block (possibly containing multiple extension definitions) will + // have an outer location whose path refers to the "extensions" repeated + // field without an index. + // - Multiple locations may have the same path. This happens when a single + // logical declaration is spread out across multiple places. The most + // obvious example is the "extend" block again -- there may be multiple + // extend blocks in the same scope, each of which will have the same path. + // - A location's span is not always a subset of its parent's span. For + // example, the "extendee" of an extension declaration appears at the + // beginning of the "extend" block and is shared by all extensions within + // the block. + // - Just because a location's span is a subset of some other location's span + // does not mean that it is a descendant. For example, a "group" defines + // both a type and a field in a single declaration. Thus, the locations + // corresponding to the type and field and their components will overlap. + // - Code which tries to interpret locations should probably be designed to + // ignore those that it doesn't understand, as more types of locations could + // be recorded in the future. + repeated Location location = 1; + message Location { + // Identifies which part of the FileDescriptorProto was defined at this + // location. + // + // Each element is a field number or an index. They form a path from + // the root FileDescriptorProto to the place where the definition occurs. + // For example, this path: + // [ 4, 3, 2, 7, 1 ] + // refers to: + // file.message_type(3) // 4, 3 + // .field(7) // 2, 7 + // .name() // 1 + // This is because FileDescriptorProto.message_type has field number 4: + // repeated DescriptorProto message_type = 4; + // and DescriptorProto.field has field number 2: + // repeated FieldDescriptorProto field = 2; + // and FieldDescriptorProto.name has field number 1: + // optional string name = 1; + // + // Thus, the above path gives the location of a field name. If we removed + // the last element: + // [ 4, 3, 2, 7 ] + // this path refers to the whole field declaration (from the beginning + // of the label to the terminating semicolon). + repeated int32 path = 1 [packed = true]; + + // Always has exactly three or four elements: start line, start column, + // end line (optional, otherwise assumed same as start line), end column. + // These are packed into a single field for efficiency. Note that line + // and column numbers are zero-based -- typically you will want to add + // 1 to each before displaying to a user. + repeated int32 span = 2 [packed = true]; + + // If this SourceCodeInfo represents a complete declaration, these are any + // comments appearing before and after the declaration which appear to be + // attached to the declaration. + // + // A series of line comments appearing on consecutive lines, with no other + // tokens appearing on those lines, will be treated as a single comment. + // + // leading_detached_comments will keep paragraphs of comments that appear + // before (but not connected to) the current element. Each paragraph, + // separated by empty lines, will be one comment element in the repeated + // field. + // + // Only the comment content is provided; comment markers (e.g. //) are + // stripped out. For block comments, leading whitespace and an asterisk + // will be stripped from the beginning of each line other than the first. + // Newlines are included in the output. + // + // Examples: + // + // optional int32 foo = 1; // Comment attached to foo. + // // Comment attached to bar. + // optional int32 bar = 2; + // + // optional string baz = 3; + // // Comment attached to baz. + // // Another line attached to baz. + // + // // Comment attached to moo. + // // + // // Another line attached to moo. + // optional double moo = 4; + // + // // Detached comment for corge. This is not leading or trailing comments + // // to moo or corge because there are blank lines separating it from + // // both. + // + // // Detached comment for corge paragraph 2. + // + // optional string corge = 5; + // /* Block comment attached + // * to corge. Leading asterisks + // * will be removed. */ + // /* Block comment attached to + // * grault. */ + // optional int32 grault = 6; + // + // // ignored detached comments. + optional string leading_comments = 3; + optional string trailing_comments = 4; + repeated string leading_detached_comments = 6; + } +} + +// Describes the relationship between generated code and its original source +// file. A GeneratedCodeInfo message is associated with only one generated +// source file, but may contain references to different source .proto files. +message GeneratedCodeInfo { + // An Annotation connects some span of text in generated code to an element + // of its generating .proto file. + repeated Annotation annotation = 1; + message Annotation { + // Identifies the element in the original source .proto file. This field + // is formatted the same as SourceCodeInfo.Location.path. + repeated int32 path = 1 [packed = true]; + + // Identifies the filesystem path to the original source .proto. + optional string source_file = 2; + + // Identifies the starting offset in bytes in the generated code + // that relates to the identified object. + optional int32 begin = 3; + + // Identifies the ending offset in bytes in the generated code that + // relates to the identified offset. The end offset should be one past + // the last relevant byte (so the length of the text = end - begin). + optional int32 end = 4; + } +} diff --git a/core/service/detekt-baseline.xml b/core/service/detekt-baseline.xml index 5e0807579..c373eea43 100644 --- a/core/service/detekt-baseline.xml +++ b/core/service/detekt-baseline.xml @@ -1,7 +1,5 @@ - + - - TooManyFunctions:MeshServiceNotifications.kt$MeshServiceNotifications - + diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt index 7fd9f950a..5af641d65 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt @@ -18,8 +18,8 @@ package org.meshtastic.core.service import android.app.Notification import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Telemetry const val SERVICE_NOTIFY_ID = 101 @@ -29,7 +29,7 @@ interface MeshServiceNotifications { fun initChannels() - fun updateServiceStateNotification(summaryString: String?, telemetry: TelemetryProtos.Telemetry?): Notification + fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification suspend fun updateMessageNotification( contactKey: String, @@ -63,11 +63,11 @@ interface MeshServiceNotifications { fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) - fun showClientNotification(clientNotification: MeshProtos.ClientNotification) + fun showClientNotification(clientNotification: ClientNotification) fun cancelMessageNotification(contactKey: String) fun cancelLowBatteryNotification(node: NodeEntity) - fun clearClientNotification(notification: MeshProtos.ClientNotification) + fun clearClientNotification(notification: ClientNotification) } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt index a1fc3d27c..3ec87bcb0 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt @@ -17,7 +17,7 @@ package org.meshtastic.core.service import org.meshtastic.core.database.model.Node -import org.meshtastic.proto.AdminProtos +import org.meshtastic.proto.SharedContact sealed class ServiceAction { data class GetDeviceMetadata(val destNum: Int) : ServiceAction() @@ -30,7 +30,7 @@ sealed class ServiceAction { data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction() - data class ImportContact(val contact: AdminProtos.SharedContact) : ServiceAction() + data class ImportContact(val contact: SharedContact) : ServiceAction() - data class SendContact(val contact: AdminProtos.SharedContact) : ServiceAction() + data class SendContact(val contact: SharedContact) : ServiceAction() } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 70c1b71df..995b68f0b 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -25,8 +25,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.withTimeoutOrNull -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.MeshPacket import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -83,11 +83,11 @@ class ServiceRepository @Inject constructor() { _connectionState.value = connectionState } - private val _clientNotification = MutableStateFlow(null) - val clientNotification: StateFlow + private val _clientNotification = MutableStateFlow(null) + val clientNotification: StateFlow get() = _clientNotification - fun setClientNotification(notification: MeshProtos.ClientNotification?) { + fun setClientNotification(notification: ClientNotification?) { Logger.e { notification?.message.orEmpty() } _clientNotification.value = notification diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 89fec2c5a..26cb8397d 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -1168,4 +1168,5 @@ Filtered Enable filtering Disable filtering + Channel URL diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml index 04634d26e..6748a79ba 100644 --- a/core/ui/detekt-baseline.xml +++ b/core/ui/detekt-baseline.xml @@ -1,4 +1,4 @@ - + @@ -6,7 +6,6 @@ MagicNumber:EditIPv4Preference.kt$16 MagicNumber:EditIPv4Preference.kt$24 MagicNumber:EditIPv4Preference.kt$8 - MagicNumber:EditListPreference.kt$12 MagicNumber:EditListPreference.kt$12345 MagicNumber:EditListPreference.kt$67890 MagicNumber:LazyColumnDragAndDropDemo.kt$50 diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt index bc798b493..daa00c998 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt @@ -46,14 +46,14 @@ import co.touchlab.kermit.Logger import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.google.protobuf.ByteString -import com.google.protobuf.Descriptors import com.google.zxing.BarcodeFormat import com.google.zxing.MultiFormatWriter import com.google.zxing.WriterException import com.journeyapps.barcodescanner.BarcodeEncoder import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.model.Node import org.meshtastic.core.strings.Res @@ -62,8 +62,8 @@ import org.meshtastic.core.strings.scan_qr_code import org.meshtastic.core.strings.share_contact import org.meshtastic.core.ui.R import org.meshtastic.core.ui.share.SharedContactDialog -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User import java.net.MalformedURLException /** @@ -76,9 +76,9 @@ import java.net.MalformedURLException @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun AddContactFAB( - sharedContact: AdminProtos.SharedContact?, + sharedContact: SharedContact?, modifier: Modifier = Modifier, - onSharedContactRequested: (AdminProtos.SharedContact?) -> Unit, + onSharedContactRequested: (SharedContact?) -> Unit, ) { val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> @@ -161,13 +161,13 @@ private fun SharedContact(contactUri: Uri) { @Composable fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) { if (contact == null) return - val sharedContact = AdminProtos.SharedContact.newBuilder().setUser(contact.user).setNodeNum(contact.num).build() + val sharedContact = SharedContact(user = contact.user, node_num = contact.num) val uri = sharedContact.getSharedContactUrl() SimpleAlertDialog( title = Res.string.share_contact, text = { Column { - Text(contact.user.longName) + Text(contact.user.long_name) SharedContact(contactUri = uri) } }, @@ -204,43 +204,41 @@ internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#" private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING private const val CAMERA_ID = 0 -/** - * Converts a URI to a [AdminProtos.SharedContact]. - * - * @throws MalformedURLException if the URI is not a valid Meshtastic contact sharing URL. - */ @Suppress("MagicNumber") @Throws(MalformedURLException::class) -fun Uri.toSharedContact(): AdminProtos.SharedContact { +fun Uri.toSharedContact(): SharedContact { if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CONTACT_SHARE_PATH, true)) { throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}") } - val url = AdminProtos.SharedContact.parseFrom(Base64.decode(fragment!!, BASE64FLAGS)) - return url.toBuilder().build() + return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString()) } -/** Converts a [AdminProtos.SharedContact] to its corresponding URI representation. */ -fun AdminProtos.SharedContact.getSharedContactUrl(): Uri { - val bytes = this.toByteArray() ?: ByteArray(0) +/** Converts a [SharedContact] to its corresponding URI representation. */ +fun SharedContact.getSharedContactUrl(): Uri { + val bytes = SharedContact.ADAPTER.encode(this) val enc = Base64.encodeToString(bytes, BASE64FLAGS) return "$URL_PREFIX$enc".toUri() } -/** Compares two [MeshProtos.User] objects and returns a string detailing the differences. */ -fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String { +/** Compares two [User] objects and returns a string detailing the differences. */ +fun compareUsers(oldUser: User, newUser: User): String { val changes = mutableListOf() - // Iterate over all fields in the User message descriptor - for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) { - val fieldName = fieldDescriptor.name - val oldValue = if (oldUser.hasField(fieldDescriptor)) oldUser.getField(fieldDescriptor) else null - val newValue = if (newUser.hasField(fieldDescriptor)) newUser.getField(fieldDescriptor) else null - - if (oldValue != newValue) { - val oldValueString = valueToString(oldValue, fieldDescriptor) - val newValueString = valueToString(newValue, fieldDescriptor) - changes.add("$fieldName: $oldValueString -> $newValueString") - } + if (oldUser.id != newUser.id) changes.add("id: ${oldUser.id} -> ${newUser.id}") + if (oldUser.long_name != newUser.long_name) changes.add("long_name: ${oldUser.long_name} -> ${newUser.long_name}") + if (oldUser.short_name != newUser.short_name) { + changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}") + } + if (oldUser.macaddr != newUser.macaddr) { + changes.add("macaddr: ${oldUser.macaddr?.base64()} -> ${newUser.macaddr?.base64()}") + } + if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}") + if (oldUser.is_licensed != newUser.is_licensed) { + changes.add("is_licensed: ${oldUser.is_licensed} -> ${newUser.is_licensed}") + } + if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}") + if (oldUser.public_key != newUser.public_key) { + changes.add("public_key: ${oldUser.public_key?.base64()} -> ${newUser.public_key?.base64()}") } return if (changes.isEmpty()) { @@ -250,52 +248,20 @@ fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String { } } -/** Converts a [MeshProtos.User] object to a string representation of its fields and values. */ -fun userFieldsToString(user: MeshProtos.User): String { +/** Converts a [User] object to a string representation of its fields and values. */ +fun userFieldsToString(user: User): String { val fieldLines = mutableListOf() - for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) { - val fieldName = fieldDescriptor.name - if (user.hasField(fieldDescriptor)) { - val value = user.getField(fieldDescriptor) - val valueString = valueToString(value, fieldDescriptor) // Using the helper from previous example - fieldLines.add("$fieldName: $valueString") - } else if (fieldDescriptor.isRepeated || fieldDescriptor.hasDefaultValue() || fieldDescriptor.hasPresence()) { - val defaultValue = fieldDescriptor.defaultValue - val valueString = - if (fieldDescriptor.isRepeated) { - "[]" // Empty list - } else if (user.hasField(fieldDescriptor)) { - valueToString(user.getField(fieldDescriptor), fieldDescriptor) - } else { - valueToString(defaultValue, fieldDescriptor) - } + fieldLines.add("id: ${user.id}") + fieldLines.add("long_name: ${user.long_name}") + fieldLines.add("short_name: ${user.short_name}") + fieldLines.add("macaddr: ${user.macaddr?.base64()}") + fieldLines.add("hw_model: ${user.hw_model}") + fieldLines.add("is_licensed: ${user.is_licensed}") + fieldLines.add("role: ${user.role}") + fieldLines.add("public_key: ${user.public_key?.base64()}") - fieldLines.add("$fieldName: $valueString") - } - } - return if (fieldLines.isEmpty()) { - "User object has no fields set." - } else { - fieldLines.joinToString("\n") - } + return fieldLines.joinToString("\n") } -private fun valueToString(value: Any?, fieldDescriptor: Descriptors.FieldDescriptor): String { - if (value == null) { - return "null" - } - return when (fieldDescriptor.type) { - Descriptors.FieldDescriptor.Type.BYTES -> { - // For ByteString, you might want to display it as hex or Base64 - // For simplicity, here we'll just show its size. - if (value is ByteString) { - Base64.encodeToString(value.toByteArray(), Base64.DEFAULT).trim() - } else { - value.toString().trim() - } - } - // Add more custom formatting for other types if needed - else -> value.toString().trim() - } -} +private fun ByteString.base64(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim() diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt index bbd4460d4..ad51ecd2e 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.protobuf.ProtocolMessageEnum @Composable fun > DropDownPreference( @@ -71,21 +70,7 @@ fun DropDownPreference( ) { var expanded by remember { mutableStateOf(false) } - val deprecatedItems: List = remember { - if (selectedItem is ProtocolMessageEnum) { - val enum = (selectedItem as? Enum<*>)?.declaringJavaClass?.enumConstants - val descriptor = (selectedItem as ProtocolMessageEnum).descriptorForType - - @Suppress("UNCHECKED_CAST") - ( - enum?.filter { entries -> descriptor.values.any { it.name == entries.name && it.options.deprecated } } - ?: emptyList() - ) - as List - } else { - emptyList() - } - } + val deprecatedItems: List = emptyList() // Protobuf-Java specific deprecation check removed Column(modifier = modifier.fillMaxWidth().padding(8.dp)) { ExposedDropdownMenuBox( expanded = expanded, diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt index e8c017e85..497137e75 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.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 org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Column @@ -43,11 +42,11 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.protobuf.ByteString +import okio.ByteString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Channel +import org.meshtastic.core.model.util.base64ToByteString import org.meshtastic.core.model.util.encodeToString -import org.meshtastic.core.model.util.toByteString import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.error import org.meshtastic.core.strings.reset @@ -88,7 +87,7 @@ fun EditBase64Preference( value = valueState, onValueChange = { valueState = it - runCatching { it.toByteString() }.onSuccess(onValueChange) + runCatching { it.base64ToByteString() }.onSuccess(onValueChange) }, modifier = Modifier.fillMaxWidth().onFocusChanged { focusState -> isFocused = focusState.isFocused }, enabled = enabled, @@ -147,7 +146,7 @@ private fun EditBase64PreferencePreview() { value = Channel.getRandomKey(), enabled = true, keyboardActions = KeyboardActions {}, - onValueChange = {}, + onValueChange = { _ -> }, onGenerateKey = {}, modifier = Modifier.padding(16.dp), ) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditListPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditListPreference.kt index 23ed2fab0..feda28017 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditListPreference.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/EditListPreference.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 org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Column @@ -39,7 +38,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.protobuf.ByteString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.add @@ -48,10 +46,8 @@ import org.meshtastic.core.strings.gpio_pin import org.meshtastic.core.strings.ignore_incoming import org.meshtastic.core.strings.name import org.meshtastic.core.strings.type -import org.meshtastic.proto.ModuleConfigProtos.RemoteHardwarePin -import org.meshtastic.proto.ModuleConfigProtos.RemoteHardwarePinType -import org.meshtastic.proto.copy -import org.meshtastic.proto.remoteHardwarePin +import org.meshtastic.proto.RemoteHardwarePin +import org.meshtastic.proto.RemoteHardwarePinType @Suppress("LongMethod") @Composable @@ -110,7 +106,7 @@ inline fun EditListPreference( trailingIcon = trailingIcon, ) } - is ByteString -> { + is okio.ByteString -> { EditBase64Preference( title = "${index + 1}/$maxCount", value = value, @@ -126,12 +122,13 @@ inline fun EditListPreference( is RemoteHardwarePin -> { EditTextPreference( title = stringResource(Res.string.gpio_pin), - value = value.gpioPin, + value = value.gpio_pin, enabled = enabled, keyboardActions = keyboardActions, - onValueChanged = { + onValueChanged = { newValue -> + val it = newValue as Int if (it in 0..255) { - listState[index] = value.copy { gpioPin = it } as T + listState[index] = value.copy(gpio_pin = it) as T onValuesChanged(listState) } }, @@ -145,8 +142,9 @@ inline fun EditListPreference( keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = keyboardActions, - onValueChanged = { - listState[index] = value.copy { name = it } as T + onValueChanged = { newValue -> + val it = newValue as String + listState[index] = value.copy(name = it) as T onValuesChanged(listState) }, trailingIcon = trailingIcon, @@ -156,11 +154,11 @@ inline fun EditListPreference( enabled = enabled, items = RemoteHardwarePinType.entries - .filter { it != RemoteHardwarePinType.UNRECOGNIZED } + .filter { it != RemoteHardwarePinType.UNKNOWN } .map { it to it.name }, selectedItem = value.type, onItemSelected = { - listState[index] = value.copy { type = it } as T + listState[index] = value.copy(type = it) as T onValuesChanged(listState) }, ) @@ -174,8 +172,8 @@ inline fun EditListPreference( val newElement = when (T::class) { Int::class -> 0 as T - ByteString::class -> ByteString.EMPTY as T - RemoteHardwarePin::class -> remoteHardwarePin {} as T + okio.ByteString::class -> okio.ByteString.EMPTY as T + RemoteHardwarePin::class -> RemoteHardwarePin() as T else -> throw IllegalArgumentException("Unsupported type: ${T::class}") } listState.add(listState.size, newElement) @@ -204,11 +202,7 @@ private fun EditListPreferencePreview() { title = "Available pins", list = listOf( - remoteHardwarePin { - gpioPin = 12 - name = "Front door" - type = RemoteHardwarePinType.DIGITAL_READ - }, + RemoteHardwarePin(gpio_pin = 12, name = "Front door", type = RemoteHardwarePinType.DIGITAL_READ), ), maxCount = 4, enabled = true, diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt index cc6d7337c..bdbaa6043 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ElevationInfo.kt @@ -29,7 +29,7 @@ import org.meshtastic.core.strings.altitude import org.meshtastic.core.strings.elevation_suffix import org.meshtastic.core.ui.icon.Elevation import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits @Composable fun ElevationInfo( diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt index 62074b48d..86014ec6b 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.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 org.meshtastic.core.ui.component import androidx.compose.foundation.layout.Box @@ -38,9 +37,9 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.meshtastic.core.database.model.Node -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.User @Composable fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit)? = null) { @@ -53,12 +52,12 @@ fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit Modifier.width(IntrinsicSize.Min) .defaultMinSize(minWidth = 72.dp, minHeight = 32.dp) .padding(horizontal = 8.dp) - .semantics { contentDescription = node.user.shortName.ifEmpty { "Node" } }, + .semantics { contentDescription = node.user.short_name.ifEmpty { "Node" } }, contentAlignment = Alignment.Center, ) { Text( modifier = Modifier.fillMaxWidth(), - text = node.user.shortName.ifEmpty { "???" }, + text = node.user.short_name.ifEmpty { "???" }, fontSize = MaterialTheme.typography.labelLarge.fontSize, textDecoration = TextDecoration.LineThrough.takeIf { node.isIgnored }, textAlign = TextAlign.Center, @@ -80,15 +79,14 @@ fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit @Preview @Composable private fun NodeChipPreview() { - val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build() + val user = User(short_name = "\uD83E\uDEE0", long_name = "John Doe") val node = Node( num = 13444, user = user, isIgnored = false, - paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(), - environmentMetrics = - TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(), + paxcounter = Paxcount(ble = 10, wifi = 5), + environmentMetrics = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f), ) NodeChip(node = node) } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt index 6328b27a7..ef0da254b 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt @@ -51,7 +51,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import com.google.protobuf.ByteString +import okio.ByteString import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Channel diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt index 716d2d8e2..e5f16a870 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SecurityIcon.kt @@ -80,9 +80,9 @@ import org.meshtastic.core.ui.icon.Warning import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow -import org.meshtastic.proto.AppOnlyProtos -import org.meshtastic.proto.ChannelProtos.ChannelSettings -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config.LoRaConfig private const val PRECISE_POSITION_BITS = 32 @@ -279,15 +279,15 @@ fun SecurityIcon( /** Extension property to check if the channel uses a low entropy PSK (not securely encrypted). */ val Channel.isLowEntropyKey: Boolean - get() = settings.psk.size() <= 1 + get() = settings.psk.size <= 1 /** Extension property to check if the channel has precise location enabled. */ val Channel.isPreciseLocation: Boolean - get() = settings.moduleSettings.positionPrecision == PRECISE_POSITION_BITS + get() = settings.module_settings?.position_precision == PRECISE_POSITION_BITS /** Extension property to check if MQTT is enabled for the channel. */ val Channel.isMqttEnabled: Boolean - get() = settings.uplinkEnabled + get() = settings.uplink_enabled ?: false /** * Overload for [SecurityIcon] that takes a [Channel] object to determine its security state. @@ -343,7 +343,7 @@ fun SecurityIcon( */ @Composable fun SecurityIcon( - channelSet: AppOnlyProtos.ChannelSet, + channelSet: ChannelSet, channelIndex: Int, baseContentDescription: String = stringResource(Res.string.security_icon_description), externalOnClick: (() -> Unit)? = null, @@ -369,17 +369,19 @@ fun SecurityIcon( */ @Composable fun SecurityIcon( - channelSet: AppOnlyProtos.ChannelSet, + channelSet: ChannelSet, channelName: String, baseContentDescription: String = stringResource(Res.string.security_icon_description), externalOnClick: (() -> Unit)? = null, ) { val channelByNameMap = - remember(channelSet) { channelSet.settingsList.associateBy { Channel(it, channelSet.loraConfig).name } } + remember(channelSet) { + channelSet.settings.associateBy { Channel(it, channelSet.lora_config ?: Channel.default.loraConfig).name } + } channelByNameMap[channelName]?.let { channelSetting -> SecurityIcon( - channel = Channel(channelSetting, channelSet.loraConfig), + channel = Channel(channelSetting, channelSet.lora_config ?: Channel.default.loraConfig), baseContentDescription = baseContentDescription, externalOnClick = externalOnClick, ) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index 3ba1aa12a..21efe1bd5 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -62,13 +62,13 @@ fun SignalInfo( IconInfo( icon = MeshtasticIcons.ChannelUtilization, contentDescription = stringResource(Res.string.channel_utilization), - text = "%.1f%%".format(node.deviceMetrics.channelUtilization), + text = "%.1f%%".format(node.deviceMetrics.channel_utilization), contentColor = contentColor, ) IconInfo( icon = MeshtasticIcons.AirUtilization, contentDescription = stringResource(Res.string.air_utilization), - text = "%.1f%%".format(node.deviceMetrics.airUtilTx), + text = "%.1f%%".format(node.deviceMetrics.air_util_tx), contentColor = contentColor, ) } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt index 782fd7f60..4fd2cb94d 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt @@ -17,16 +17,16 @@ package org.meshtastic.core.ui.component.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.protobuf.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceMetrics.Companion.currentTime -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.deviceMetrics -import org.meshtastic.proto.environmentMetrics -import org.meshtastic.proto.paxcount -import org.meshtastic.proto.position -import org.meshtastic.proto.user +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.Position +import org.meshtastic.proto.User import kotlin.random.Random class NodePreviewParameterProvider : PreviewParameterProvider { @@ -34,32 +34,26 @@ class NodePreviewParameterProvider : PreviewParameterProvider { Node( num = 1955, user = - user { - id = "mickeyMouseId" - longName = "Mickey Mouse" - shortName = "MM" - hwModel = MeshProtos.HardwareModel.TBEAM - role = ConfigProtos.Config.DeviceConfig.Role.ROUTER - }, - position = - position { - latitudeI = 338125110 - longitudeI = -1179189760 - altitude = 138 - satsInView = 4 - }, + User( + id = "mickeyMouseId", + long_name = "Mickey Mouse", + short_name = "MM", + hw_model = HardwareModel.TBEAM, + role = Config.DeviceConfig.Role.ROUTER, + ), + position = Position(latitude_i = 338125110, longitude_i = -1179189760, altitude = 138, sats_in_view = 4), lastHeard = currentTime(), channel = 0, snr = 12.5F, rssi = -42, deviceMetrics = - deviceMetrics { - channelUtilization = 2.4F - airUtilTx = 3.5F - batteryLevel = 85 - voltage = 3.7F - uptimeSeconds = 3600 - }, + DeviceMetrics( + channel_utilization = 2.4F, + air_util_tx = 3.5F, + battery_level = 85, + voltage = 3.7F, + uptime_seconds = 3600, + ), isFavorite = true, hopsAway = 0, ) @@ -68,67 +62,55 @@ class NodePreviewParameterProvider : PreviewParameterProvider { mickeyMouse.copy( num = Random.nextInt(), user = - user { - longName = "Minnie Mouse" - shortName = "MiMo" - id = "minnieMouseId" - hwModel = MeshProtos.HardwareModel.HELTEC_V3 - }, + User( + long_name = "Minnie Mouse", + short_name = "MiMo", + id = "minnieMouseId", + hw_model = HardwareModel.HELTEC_V3, + ), snr = 12.5F, rssi = -42, - position = position {}, + position = Position(), hopsAway = 1, ) private val donaldDuck = Node( num = Random.nextInt(), - position = - position { - latitudeI = 338052347 - longitudeI = -1179208460 - altitude = 121 - satsInView = 66 - }, + position = Position(latitude_i = 338052347, longitude_i = -1179208460, altitude = 121, sats_in_view = 66), lastHeard = currentTime() - 300, channel = 0, snr = 12.5F, rssi = -42, deviceMetrics = - deviceMetrics { - channelUtilization = 2.4F - airUtilTx = 3.5F - batteryLevel = 85 - voltage = 3.7F - uptimeSeconds = 3600 - }, + DeviceMetrics( + channel_utilization = 2.4F, + air_util_tx = 3.5F, + battery_level = 85, + voltage = 3.7F, + uptime_seconds = 3600, + ), user = - user { - id = "donaldDuckId" - longName = "Donald Duck, the Grand Duck of the Ducks" - shortName = "DoDu" - hwModel = MeshProtos.HardwareModel.HELTEC_V3 - publicKey = ByteString.copyFrom(ByteArray(32) { 1 }) - }, + User( + id = "donaldDuckId", + long_name = "Donald Duck, the Grand Duck of the Ducks", + short_name = "DoDu", + hw_model = HardwareModel.HELTEC_V3, + public_key = ByteArray(32) { 1 }.toByteString(), + ), environmentMetrics = - environmentMetrics { - temperature = 28.0F - relativeHumidity = 50.0F - barometricPressure = 1013.25F - gasResistance = 0.0F - voltage = 3.7F - current = 0.0F - iaq = 100 - barometricPressure = 1013.25F - soilTemperature = 28.0F - soilMoisture = 50 - }, - paxcounter = - paxcount { - wifi = 30 - ble = 39 - uptime = 420 - }, + EnvironmentMetrics( + temperature = 28.0F, + relative_humidity = 50.0F, + barometric_pressure = 1013.25F, + gas_resistance = 0.0F, + voltage = 3.7F, + current = 0.0F, + iaq = 100, + soil_temperature = 28.0F, + soil_moisture = 50, + ), + paxcounter = Paxcount(wifi = 30, ble = 39, uptime = 420), isFavorite = true, hopsAway = 2, ) @@ -136,14 +118,9 @@ class NodePreviewParameterProvider : PreviewParameterProvider { private val unknown = donaldDuck.copy( user = - user { - id = "myId" - longName = "Meshtastic myId" - shortName = "myId" - hwModel = MeshProtos.HardwareModel.UNSET - }, - environmentMetrics = environmentMetrics {}, - paxcounter = paxcount {}, + User(id = "myId", long_name = "Meshtastic myId", short_name = "myId", hw_model = HardwareModel.UNSET), + environmentMetrics = EnvironmentMetrics(), + paxcounter = Paxcount(), ) private val almostNothing = Node(num = Random.nextInt()) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.kt index a3e297ce4..0941b68af 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.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,29 +14,27 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - @file:Suppress("MatchingDeclarationName") package org.meshtastic.core.ui.component.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import org.meshtastic.core.database.model.Node -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.User /** Simple [PreviewParameterProvider] that provides true and false values. */ class BooleanProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf(false, true) } -private val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build() +private val user = User(short_name = "\uD83E\uDEE0", long_name = "John Doe") val previewNode = Node( num = 13444, user = user, isIgnored = false, - paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(), - environmentMetrics = - TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(), + paxcounter = Paxcount(ble = 10, wifi = 5), + environmentMetrics = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f), ) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index 40526e30e..851abbe65 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -60,10 +60,7 @@ import org.meshtastic.core.strings.new_channel_rcvd import org.meshtastic.core.strings.replace import org.meshtastic.core.strings.replace_channels_and_settings_description import org.meshtastic.core.ui.component.ChannelSelection -import org.meshtastic.proto.AppOnlyProtos.ChannelSet -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset -import org.meshtastic.proto.channelSet -import org.meshtastic.proto.copy +import org.meshtastic.proto.ChannelSet @Composable fun ScannedQrCodeDialog( @@ -91,7 +88,7 @@ fun ScannedQrCodeDialog( onDismiss: () -> Unit, onConfirm: (ChannelSet) -> Unit, ) { - var shouldReplace by remember { mutableStateOf(incoming.hasLoraConfig()) } + var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) } val channelSet = remember(shouldReplace) { @@ -99,67 +96,65 @@ fun ScannedQrCodeDialog( // When replacing, apply the incoming LoRa configuration but preserve certain // locally safe fields such as MQTT flags and TX power. This prevents QR codes // from unintentionally overriding device-specific power limits (e.g. E22 caps). - incoming.copy { - loraConfig = - loraConfig.copy { - configOkToMqtt = channels.loraConfig.configOkToMqtt - txPower = channels.loraConfig.txPower - } - } + incoming.copy( + lora_config = + incoming.lora_config?.copy( + config_ok_to_mqtt = channels.lora_config?.config_ok_to_mqtt ?: false, + tx_power = channels.lora_config?.tx_power ?: 0, + ), + ) } else { - channels.copy { - // To guarantee consistent ordering, using a LinkedHashSet which iterates through - // its entries according to the order an item was *first* inserted. - // https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/ - val result = LinkedHashSet(settings + incoming.settingsList) - settings.clear() - settings.addAll(result) - } + // To guarantee consistent ordering, using a LinkedHashSet which iterates through + // its entries according to the order an item was *first* inserted. + val result = (channels.settings + incoming.settings).distinct() + channels.copy(settings = result) } } - val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name + val modemPresetName = Channel(loraConfig = channelSet.lora_config ?: Channel.default.loraConfig).name /* Holds selections made by the user */ val channelSelections = - remember(channelSet) { mutableStateListOf(elements = Array(size = channelSet.settingsCount, init = { true })) } + remember(channelSet) { mutableStateListOf(elements = Array(size = channelSet.settings.size, init = { true })) } val selectedChannelSet = - channelSet.copy { - // When adding (not replacing), include all previous channels + selected new channels. - // Since 'channelSet.settings' already contains the merged distinct list, we just filter it. - val result = - settings.filterIndexed { i, _ -> - val isExisting = !shouldReplace && i < channels.settingsCount + if (shouldReplace) { + channelSet.copy( + settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }, + ) + } else { + channelSet.copy( + settings = + channelSet.settings.filterIndexed { i, _ -> + val isExisting = i < channels.settings.size isExisting || channelSelections.getOrNull(i) == true - } - settings.clear() - settings.addAll(result) + }, + ) } // Compute LoRa configuration changes when in replace mode val loraChanges = remember(shouldReplace, channels, incoming) { - if (shouldReplace && incoming.hasLoraConfig()) { - val current = channels.loraConfig - val new = incoming.loraConfig + if (shouldReplace && incoming.lora_config != null) { + val current = channels.lora_config + val new = incoming.lora_config val changes = mutableListOf() - if (current.hopLimit != new.hopLimit) { - changes.add("Hop Limit: ${current.hopLimit} -> ${new.hopLimit}") + if (current?.hop_limit != new?.hop_limit) { + changes.add("Hop Limit: ${current?.hop_limit} -> ${new?.hop_limit}") } - if (current.getRegion() != new.getRegion()) { - val currentRegionDesc = current.getRegion()?.name ?: "Unknown" - val newRegionDesc = new.getRegion()?.name ?: "Unknown" + if (current?.region != new?.region) { + val currentRegionDesc = current?.region?.name ?: "Unknown" + val newRegionDesc = new?.region?.name ?: "Unknown" changes.add("Region: $currentRegionDesc -> $newRegionDesc") } - if (current.modemPreset != new.modemPreset) { - val currentPresetDesc = ModemPreset.forNumber(current.modemPreset.number)?.name ?: "Unknown" - val newPresetDesc = ModemPreset.forNumber(new.modemPreset.number)?.name ?: "Unknown" + if (current?.modem_preset != new?.modem_preset) { + val currentPresetDesc = current?.modem_preset?.name ?: "Unknown" + val newPresetDesc = new?.modem_preset?.name ?: "Unknown" changes.add("Modem Preset: $currentPresetDesc -> $newPresetDesc") } - if (current.usePreset != new.usePreset) { - changes.add("Use Preset: ${current.usePreset} -> ${new.usePreset}") + if (current?.use_preset != new?.use_preset) { + changes.add("Use Preset: ${current?.use_preset} -> ${new?.use_preset}") } changes @@ -204,16 +199,16 @@ fun ScannedQrCodeDialog( ) } - itemsIndexed(channelSet.settingsList) { index, channel -> - val isExisting = !shouldReplace && index < channels.settingsCount - val channelObj = Channel(channel, channelSet.loraConfig) + itemsIndexed(channelSet.settings) { index, channel -> + val isExisting = !shouldReplace && index < channels.settings.size + val channelObj = Channel(channel, channelSet.lora_config ?: Channel.default.loraConfig) ChannelSelection( index = index, title = channel.name.ifEmpty { modemPresetName }, enabled = !isExisting, isSelected = if (isExisting) true else channelSelections[index], onSelected = { - if (it || selectedChannelSet.settingsCount > 1) { + if (it || selectedChannelSet.settings.size > 1) { channelSelections[index] = it } }, @@ -256,7 +251,7 @@ fun ScannedQrCodeDialog( OutlinedButton( onClick = { shouldReplace = true }, modifier = Modifier.height(48.dp).weight(1f), - enabled = incoming.hasLoraConfig(), + enabled = incoming.lora_config != null, colors = if (shouldReplace) selectedColors else unselectedColors, ) { Text(text = stringResource(Res.string.replace)) @@ -285,7 +280,7 @@ fun ScannedQrCodeDialog( onDismiss() onConfirm(selectedChannelSet) }, - enabled = selectedChannelSet.settingsCount in 1..8, + enabled = selectedChannelSet.settings.size in 1..8, ) { Text( text = stringResource(Res.string.accept), @@ -306,16 +301,8 @@ fun ScannedQrCodeDialog( @Composable private fun ScannedQrCodeDialogPreview() { ScannedQrCodeDialog( - channels = - channelSet { - settings.add(Channel.default.settings) - loraConfig = Channel.default.loraConfig - }, - incoming = - channelSet { - settings.add(Channel.default.settings) - loraConfig = Channel.default.loraConfig - }, + channels = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig), + incoming = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig), onDismiss = {}, onConfirm = {}, ) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index bb4b90322..f8f7e07aa 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.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 org.meshtastic.core.ui.qr import android.os.RemoteException @@ -27,12 +26,10 @@ import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.AppOnlyProtos -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.channelSet -import org.meshtastic.proto.config +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig import javax.inject.Inject @HiltViewModel @@ -43,23 +40,24 @@ constructor( private val serviceRepository: ServiceRepository, ) : ViewModel() { - val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {}) + val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet()) - private val localConfig = - radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance()) + private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch { - getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel) - radioConfigRepository.replaceAllSettings(channelSet.settingsList) + fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) + radioConfigRepository.replaceAllSettings(channelSet.settings) - val newConfig = config { lora = channelSet.loraConfig } - if (localConfig.value.lora != newConfig.lora) setConfig(newConfig) + val loraConfig = channelSet.lora_config + if (loraConfig != null && localConfig.value.lora != loraConfig) { + setConfig(Config(lora = loraConfig)) + } } - private fun setChannel(channel: ChannelProtos.Channel) { + private fun setChannel(channel: Channel) { try { - serviceRepository.meshService?.setChannel(channel.toByteArray()) + serviceRepository.meshService?.setChannel(Channel.ADAPTER.encode(channel)) } catch (ex: RemoteException) { Logger.e(ex) { "Set channel error" } } @@ -68,7 +66,7 @@ constructor( // Set the radio config (also updates our saved copy in preferences) private fun setConfig(config: Config) { try { - serviceRepository.meshService?.setConfig(config.toByteArray()) + serviceRepository.meshService?.setConfig(Config.ADAPTER.encode(config)) } catch (ex: RemoteException) { Logger.e(ex) { "Set config error" } } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.kt index f975ad3e1..65873083a 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactDialog.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 org.meshtastic.core.ui.share import androidx.compose.foundation.layout.Column @@ -35,18 +34,19 @@ import org.meshtastic.core.strings.public_key_changed import org.meshtastic.core.ui.component.SimpleAlertDialog import org.meshtastic.core.ui.component.compareUsers import org.meshtastic.core.ui.component.userFieldsToString -import org.meshtastic.proto.AdminProtos +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User /** A dialog for importing a shared contact that was scanned from a QR code. */ @Composable fun SharedContactDialog( - sharedContact: AdminProtos.SharedContact, + sharedContact: SharedContact, onDismiss: () -> Unit, viewModel: SharedContactViewModel = hiltViewModel(), ) { val unfilteredNodes by viewModel.unfilteredNodes.collectAsStateWithLifecycle() - val nodeNum = sharedContact.nodeNum + val nodeNum = sharedContact.node_num val node = unfilteredNodes.find { it.num == nodeNum } SimpleAlertDialog( @@ -55,16 +55,18 @@ fun SharedContactDialog( Column { if (node != null) { Text(text = stringResource(Res.string.import_known_shared_contact_text)) - if (node.user.publicKey.size() > 0 && node.user.publicKey != sharedContact.user?.publicKey) { + if ( + (node.user.public_key?.size ?: 0) > 0 && node.user.public_key != sharedContact.user?.public_key + ) { Text( text = stringResource(Res.string.public_key_changed), color = MaterialTheme.colorScheme.error, ) } HorizontalDivider() - Text(text = compareUsers(node.user, sharedContact.user)) + Text(text = compareUsers(node.user, sharedContact.user ?: User())) } else { - Text(text = userFieldsToString(sharedContact.user)) + Text(text = userFieldsToString(sharedContact.user ?: User())) } } }, diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt index 5079eac6e..2c467cb66 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.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 org.meshtastic.core.ui.share import androidx.lifecycle.ViewModel @@ -27,7 +26,7 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.AdminProtos +import org.meshtastic.proto.SharedContact import javax.inject.Inject @HiltViewModel @@ -41,6 +40,6 @@ constructor( val unfilteredNodes: StateFlow> = nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList()) - fun addSharedContact(sharedContact: AdminProtos.SharedContact) = + fun addSharedContact(sharedContact: SharedContact) = viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.ImportContact(sharedContact)) } } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt index 71ab1b46a..17b9f94ab 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt @@ -22,39 +22,39 @@ import androidx.compose.ui.platform.LocalContext import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.unknown_age -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ChannelProtos.ChannelSettings -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.MeshProtos.Position -import org.meshtastic.proto.channel -import org.meshtastic.proto.channelSettings +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.Position import kotlin.time.Duration.Companion.days private const val SECONDS_TO_MILLIS = 1000L @Composable -fun MeshProtos.Position.formatPositionTime(): String { +fun Position.formatPositionTime(): String { val currentTime = System.currentTimeMillis() val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds - val isOlderThanSixMonths = time * SECONDS_TO_MILLIS < sixMonthsAgo + val isOlderThanSixMonths = (time ?: 0) * SECONDS_TO_MILLIS < sixMonthsAgo val timeText = if (isOlderThanSixMonths) { stringResource(Res.string.unknown_age) } else { DateUtils.formatDateTime( LocalContext.current, - time * SECONDS_TO_MILLIS, + (time ?: 0) * SECONDS_TO_MILLIS, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL, ) } return timeText } -fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) { - runCatching { Position.parseFrom(decoded.payload) }.getOrNull() -} else { - null +fun MeshPacket.toPosition(): Position? { + val decoded = decoded ?: return null + return if (decoded.want_response != true) { + decoded.payload.let { runCatching { Position.ADAPTER.decode(it) }.getOrNull() } + } else { + null + } } /** @@ -65,20 +65,20 @@ fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) { * @param old The current [ChannelSettings] list (required when disabling unused channels). * @return A [Channel] list containing only the modified channels. */ -fun getChannelList(new: List, old: List): List = buildList { +fun getChannelList(new: List, old: List): List = buildList { for (i in 0..maxOf(old.lastIndex, new.lastIndex)) { if (old.getOrNull(i) != new.getOrNull(i)) { add( - channel { + Channel( role = - when (i) { - 0 -> ChannelProtos.Channel.Role.PRIMARY - in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY - else -> ChannelProtos.Channel.Role.DISABLED - } - index = i - settings = new.getOrNull(i) ?: channelSettings {} - }, + when (i) { + 0 -> Channel.Role.PRIMARY + in 1..new.lastIndex -> Channel.Role.SECONDARY + else -> Channel.Role.DISABLED + }, + index = i, + settings = new.getOrNull(i) ?: ChannelSettings(), + ), ) } } diff --git a/feature/firmware/detekt-baseline.xml b/feature/firmware/detekt-baseline.xml index a75de14db..e919ed3c6 100644 --- a/feature/firmware/detekt-baseline.xml +++ b/feature/firmware/detekt-baseline.xml @@ -1,10 +1,7 @@ - + TooGenericExceptionCaught:FirmwareUpdateViewModel.kt$FirmwareUpdateViewModel$e: Exception - TooGenericExceptionCaught:UpdateHandler.kt$FirmwareRetriever$e: Exception - TooGenericExceptionCaught:UpdateHandler.kt$OtaUpdateHandler$e: Exception - TooGenericExceptionCaught:UpdateHandler.kt$UsbUpdateHandler$e: Exception diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index c60cc7a76..fc25cd754 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -184,6 +184,7 @@ constructor( FirmwareUpdateMethod.Usb } } + radioPrefs.isBle() -> FirmwareUpdateMethod.Ble radioPrefs.isTcp() -> FirmwareUpdateMethod.Wifi else -> FirmwareUpdateMethod.Unknown @@ -451,7 +452,7 @@ constructor( private suspend fun checkBatteryLevel(): Boolean { val node = nodeRepository.ourNodeInfo.value ?: return true - val level = node.batteryLevel + val level = node.batteryLevel ?: 1 val isBatteryLow = level in 1..MIN_BATTERY_LEVEL if (isBatteryLow) { @@ -463,7 +464,7 @@ constructor( private suspend fun getDeviceHardware(ourNode: MyNodeEntity): DeviceHardware? { val nodeInfo = nodeRepository.ourNodeInfo.value - val hwModelInt = nodeInfo?.user?.hwModel?.number + val hwModelInt = nodeInfo?.user?.hw_model?.value val target = ourNode.pioEnv return if (hwModelInt != null) { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 4b4c4f809..036ac0fe5 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -133,10 +133,8 @@ import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.model.CustomTileSource import org.meshtastic.feature.map.model.MarkerWithLabel import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.proto.MeshProtos.Position -import org.meshtastic.proto.MeshProtos.Waypoint -import org.meshtastic.proto.copy -import org.meshtastic.proto.waypoint +import org.meshtastic.proto.Position +import org.meshtastic.proto.Waypoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver @@ -325,7 +323,7 @@ fun MapView( LaunchedEffect(selectedWaypointId, waypoints) { if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) { waypoints[selectedWaypointId]?.data?.waypoint?.let { pt -> - val geoPoint = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) + val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) map.controller.setCenter(geoPoint) map.controller.setZoom(WAYPOINT_ZOOM) } @@ -396,7 +394,8 @@ fun MapView( fun MapView.onNodesChanged(nodes: Collection): List { val nodesWithPosition = nodes.filter { it.validPosition != null } val ourNode = mapViewModel.ourNodeInfo.value - val displayUnits = mapViewModel.config.display.units + val displayUnits = + mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> if ( @@ -410,9 +409,9 @@ fun MapView( val (p, u) = node.position to node.user val nodePosition = GeoPoint(node.latitude, node.longitude) - MarkerWithLabel(mapView = this, label = "${u.shortName} ${formatAgo(p.time)}").apply { + MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time)}").apply { id = u.id - title = u.longName + title = u.long_name snippet = com.meshtastic.core.strings.getString( Res.string.map_node_popup_details, @@ -436,7 +435,7 @@ fun MapView( if (!mapFilterStateValue.showPrecisionCircle) { setPrecisionBits(0) } else { - setPrecisionBits(p.precisionBits) + setPrecisionBits(p.precision_bits ?: 0) } setOnLongClickListener { navigateToNodeDetails(node.num) @@ -456,10 +455,10 @@ fun MapView( Logger.d { "User deleted waypoint ${waypoint.id} for me" } mapViewModel.deleteWaypoint(waypoint.id) } - if (waypoint.lockedTo in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { + if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { builder.setPositiveButton(com.meshtastic.core.strings.getString(Res.string.delete_for_everyone)) { _, _ -> Logger.d { "User deleted waypoint ${waypoint.id} for everyone" } - mapViewModel.sendWaypoint(waypoint.copy { expire = 1 }) + mapViewModel.sendWaypoint(waypoint.copy(expire = 1)) mapViewModel.deleteWaypoint(waypoint.id) } } @@ -484,7 +483,7 @@ fun MapView( Logger.d { "marker long pressed id=$id" } val waypoint = waypoints[id]?.data?.waypoint ?: return // edit only when unlocked or lockedTo myNodeNum - if (waypoint.lockedTo in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { + if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { showEditWaypointDialog = waypoint } else { showDeleteMarkerDialog(waypoint) @@ -494,7 +493,7 @@ fun MapView( fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) { com.meshtastic.core.strings.getString(Res.string.you) } else { - mapViewModel.getUser(id).longName + mapViewModel.getUser(id).long_name } @Suppress("MagicNumber") @@ -502,20 +501,20 @@ fun MapView( return waypoints.mapNotNull { waypoint -> val pt = waypoint.data.waypoint ?: return@mapNotNull null if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState - val lock = if (pt.lockedTo != 0) "\uD83D\uDD12" else "" + val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else "" val time = DateUtils.formatDateTime( context, waypoint.received_time, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL, ) - val label = pt.name + " " + formatAgo((waypoint.received_time / 1000).toInt()) - val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon)) + val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt()) + val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!)) val now = System.currentTimeMillis() - val expireTimeMillis = pt.expire * 1000L + val expireTimeMillis = (pt.expire ?: 0) * 1000L val expireTimeStr = when { - pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never" + (pt.expire ?: 0) == 0 || pt.expire == Int.MAX_VALUE -> "Never" expireTimeMillis <= now -> "Expired" else -> DateUtils.getRelativeTimeSpanString( @@ -533,7 +532,7 @@ fun MapView( "[$time] ${pt.description} " + com.meshtastic.core.strings.getString(Res.string.expires) + ": $expireTimeStr" - position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) + position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) if (selectedWaypointId == pt.id) { showInfoWindow() } @@ -557,10 +556,8 @@ fun MapView( val enabled = isConnected && downloadRegionBoundingBox == null if (enabled) { - showEditWaypointDialog = waypoint { - latitudeI = (p.latitude * 1e7).toInt() - longitudeI = (p.longitude * 1e7).toInt() - } + showEditWaypointDialog = + Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt()) } return true } @@ -895,14 +892,22 @@ fun MapView( onSendClicked = { waypoint -> Logger.d { "User clicked send waypoint ${waypoint.id}" } showEditWaypointDialog = null + + val newId = + if (waypoint.id == 0) mapViewModel.generatePacketId() ?: return@EditWaypointDialog else waypoint.id + val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name + val newExpire = if ((waypoint.expire ?: 0) == 0) Int.MAX_VALUE else (waypoint.expire ?: Int.MAX_VALUE) + val newLockedTo = if ((waypoint.locked_to ?: 0) != 0) mapViewModel.myNodeNum ?: 0 else 0 + val newIcon = if ((waypoint.icon ?: 0) == 0) 128205 else waypoint.icon + mapViewModel.sendWaypoint( - waypoint.copy { - if (id == 0) id = mapViewModel.generatePacketId() ?: return@EditWaypointDialog - if (name == "") name = "Dropped Pin" - if (expire == 0) expire = Int.MAX_VALUE - lockedTo = if (waypoint.lockedTo != 0) mapViewModel.myNodeNum ?: 0 else 0 - if (waypoint.icon == 0) icon = 128205 - }, + waypoint.copy( + id = newId, + name = newName, + expire = newExpire, + locked_to = newLockedTo, + icon = newIcon, + ), ) }, onDeleteClicked = { waypoint -> diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt index 0c78bc7f0..b82c32272 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.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 org.meshtastic.feature.map import android.graphics.Color @@ -26,7 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import org.meshtastic.core.ui.R -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Position import org.osmdroid.util.GeoPoint import org.osmdroid.views.MapView import org.osmdroid.views.overlay.CopyrightOverlay @@ -125,15 +124,15 @@ fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () return polyline } -fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List { +fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List { val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation_24) val markers = positions.map { Marker(this).apply { icon = navIcon - rotation = (it.groundTrack * 1e-5).toFloat() + rotation = ((it.ground_track ?: 0) * 1e-5).toFloat() setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7) + position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) setOnMarkerClickListener { _, _ -> onClick() true diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 2a560cd5e..2029e058d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -31,7 +31,7 @@ import org.meshtastic.core.navigation.MapRoutes import org.meshtastic.core.prefs.map.MapPrefs import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig +import org.meshtastic.proto.LocalConfig import javax.inject.Inject @Suppress("LongParameterList") @@ -57,8 +57,7 @@ constructor( mapPrefs.mapStyle = value } - val localConfig = - radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance()) + val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) val config get() = localConfig.value diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index bc7851d90..766401ae4 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -76,9 +76,7 @@ import org.meshtastic.core.strings.waypoint_new import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.emoji.EmojiPickerDialog import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.MeshProtos.Waypoint -import org.meshtastic.proto.copy -import org.meshtastic.proto.waypoint +import org.meshtastic.proto.Waypoint import java.util.Calendar @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -95,15 +93,16 @@ fun EditWaypointDialog( val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit @Suppress("MagicNumber") - val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon + val emoji = if ((waypointInput.icon ?: 0) == 0) 128205 else waypointInput.icon!! var showEmojiPickerView by remember { mutableStateOf(false) } // Get current context for dialogs val context = LocalContext.current val calendar = remember { Calendar.getInstance().apply { - if (waypoint.expire != 0 && waypoint.expire != Int.MAX_VALUE) { - timeInMillis = waypoint.expire * 1000L + val expire = waypoint.expire ?: 0 + if (expire != 0 && expire != Int.MAX_VALUE) { + timeInMillis = expire * 1000L } else { timeInMillis = System.currentTimeMillis() @Suppress("MagicNumber") @@ -121,7 +120,7 @@ fun EditWaypointDialog( // State to hold selected date and time var selectedDate by remember { mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { + if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) { dateFormat.format(calendar.time) } else { "" @@ -130,7 +129,7 @@ fun EditWaypointDialog( } var selectedTime by remember { mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { + if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) { timeFormat.format(calendar.time) } else { "" @@ -139,8 +138,8 @@ fun EditWaypointDialog( } var epochTime by remember { mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - waypointInput.expire * 1000L + if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) { + (waypointInput.expire ?: 0) * 1000L } else { null }, @@ -164,14 +163,14 @@ fun EditWaypointDialog( ) EditTextPreference( title = stringResource(Res.string.name), - value = waypointInput.name, + value = waypointInput.name ?: "", maxSize = 29, enabled = true, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy { name = it } }, + onValueChanged = { waypointInput = waypointInput.copy(name = it) }, trailingIcon = { IconButton(onClick = { showEmojiPickerView = true }) { Text( @@ -187,14 +186,14 @@ fun EditWaypointDialog( ) EditTextPreference( title = stringResource(Res.string.description), - value = waypointInput.description, + value = waypointInput.description ?: "", maxSize = 99, enabled = true, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy { description = it } }, + onValueChanged = { waypointInput = waypointInput.copy(description = it) }, ) Row( modifier = Modifier.fillMaxWidth().size(48.dp), @@ -204,8 +203,8 @@ fun EditWaypointDialog( Text(stringResource(Res.string.locked)) Switch( modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.lockedTo != 0, - onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } }, + checked = (waypointInput.locked_to ?: 0) != 0, + onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, ) } val datePickerDialog = @@ -229,7 +228,7 @@ fun EditWaypointDialog( calendar.set(Calendar.MINUTE, selectedMinute) epochTime = calendar.timeInMillis selectedTime = timeFormat.format(calendar.time) - waypointInput = waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() } + waypointInput = waypointInput.copy(expire = (calendar.timeInMillis / 1000).toInt()) }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), @@ -247,7 +246,7 @@ fun EditWaypointDialog( Text(stringResource(Res.string.expires)) Switch( modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0, + checked = waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0, onCheckedChange = { isChecked -> if (isChecked) { // Default to now if not already set @@ -256,18 +255,17 @@ fun EditWaypointDialog( } selectedDate = dateFormat.format(calendar.time) selectedTime = timeFormat.format(calendar.time) - waypointInput = - waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() } + waypointInput = waypointInput.copy(expire = (calendar.timeInMillis / 1000).toInt()) } else { selectedDate = "" selectedTime = "" - waypointInput = waypointInput.copy { expire = Int.MAX_VALUE } + waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) } }, ) } - if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) { + if (waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), @@ -308,7 +306,7 @@ fun EditWaypointDialog( Button( modifier = modifier.weight(1f), onClick = { onDeleteClicked(waypointInput) }, - enabled = waypointInput.name.isNotEmpty(), + enabled = !(waypointInput.name.isNullOrEmpty()), ) { Text(stringResource(Res.string.delete)) } @@ -322,7 +320,7 @@ fun EditWaypointDialog( } else { EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { showEmojiPickerView = false - waypointInput = waypointInput.copy { icon = it.codePointAt(0) } + waypointInput = waypointInput.copy(icon = it.codePointAt(0)) } } } @@ -334,13 +332,13 @@ private fun EditWaypointFormPreview() { AppTheme { EditWaypointDialog( waypoint = - waypoint { - id = 123 - name = "Test 123" - description = "This is only a test" - icon = 128169 - expire = (System.currentTimeMillis() / 1000 + 8 * 3600).toInt() - }, + Waypoint( + id = 123, + name = "Test 123", + description = "This is only a test", + icon = 128169, + expire = (System.currentTimeMillis() / 1000 + 8 * 3600).toInt(), + ), onSendClicked = {}, onDeleteClicked = {}, onDismissRequest = {}, diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index c79e813e7..7455a03b6 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.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 org.meshtastic.feature.map.node import androidx.compose.foundation.layout.fillMaxSize @@ -39,7 +38,7 @@ private const val DEG_D = 1e-7 fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { val density = LocalDensity.current val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - val geoPoints = positionLogs.map { GeoPoint(it.latitudeI * DEG_D, it.longitudeI * DEG_D) } + val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) } val mapView = rememberMapViewWithLifecycle( diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index e2fe84d33..bfbab10ed 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -119,11 +119,9 @@ import org.meshtastic.feature.map.component.NodeClusterMarkers import org.meshtastic.feature.map.component.WaypointMarkers import org.meshtastic.feature.map.model.NodeClusterItem import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.MeshProtos.Position -import org.meshtastic.proto.MeshProtos.Waypoint -import org.meshtastic.proto.copy -import org.meshtastic.proto.waypoint +import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Position +import org.meshtastic.proto.Waypoint import kotlin.math.abs import kotlin.math.max @@ -295,12 +293,12 @@ fun MapView( val nodeClusterItems = displayNodes.map { node -> - val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D) + val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D) NodeClusterItem( node = node, nodePosition = latLng, - nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}", - nodeSnippet = "${node.user.longName}", + nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}", + nodeSnippet = "${node.user.long_name}", ) } val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() @@ -438,10 +436,11 @@ fun MapView( MapProperties(mapType = effectiveGoogleMapType, isMyLocationEnabled = hasLocationPermission), onMapLongClick = { latLng -> if (isConnected) { - val newWaypoint = waypoint { - latitudeI = (latLng.latitude / DEG_D).toInt() - longitudeI = (latLng.longitude / DEG_D).toInt() - } + val newWaypoint = + Waypoint( + latitude_i = (latLng.latitude / DEG_D).toInt(), + longitude_i = (latLng.longitude / DEG_D).toInt(), + ) editingWaypoint = newWaypoint } }, @@ -617,18 +616,18 @@ fun MapView( onSendClicked = { updatedWp -> var finalWp = updatedWp if (updatedWp.id == 0) { - finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 } + finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) } - if (updatedWp.icon == 0) { - finalWp = finalWp.copy { icon = 0x1F4CD } + if ((updatedWp.icon ?: 0) == 0) { + finalWp = finalWp.copy(icon = 0x1F4CD) } mapViewModel.sendWaypoint(finalWp) editingWaypoint = null }, onDeleteClicked = { wpToDelete -> - if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) { - val deleteMarkerWp = wpToDelete.copy { expire = 1 } + if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { + val deleteMarkerWp = wpToDelete.copy(expire = 1) mapViewModel.sendWaypoint(deleteMarkerWp) } mapViewModel.deleteWaypoint(wpToDelete.id) @@ -683,25 +682,25 @@ fun MapView( followPhoneBearing = followPhoneBearing, ) } - if (showLayersBottomSheet) { - ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { - CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked) - } + } + if (showLayersBottomSheet) { + ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { + CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked) } - showClusterItemsDialog?.let { - ClusterItemsListDialog( - items = it, - onDismiss = { showClusterItemsDialog = null }, - onItemClick = { item -> - navigateToNodeDetails(item.node.num) - showClusterItemsDialog = null - }, - ) - } - if (showCustomTileManagerSheet) { - ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) { - CustomTileProviderManagerSheet(mapViewModel = mapViewModel) - } + } + showClusterItemsDialog?.let { + ClusterItemsListDialog( + items = it, + onDismiss = { showClusterItemsDialog = null }, + onItemClick = { item -> + navigateToNodeDetails(item.node.num) + showClusterItemsDialog = null + }, + ) + } + if (showCustomTileManagerSheet) { + ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) { + CustomTileProviderManagerSheet(mapViewModel = mapViewModel) } } } @@ -763,25 +762,28 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU Card { Column(modifier = Modifier.padding(8.dp)) { - PositionRow(label = stringResource(Res.string.latitude), value = "%.5f".format(position.latitudeI * DEG_D)) + PositionRow( + label = stringResource(Res.string.latitude), + value = "%.5f".format((position.latitude_i ?: 0) * DEG_D), + ) PositionRow( label = stringResource(Res.string.longitude), - value = "%.5f".format(position.longitudeI * DEG_D), + value = "%.5f".format((position.longitude_i ?: 0) * DEG_D), ) - PositionRow(label = stringResource(Res.string.sats), value = position.satsInView.toString()) + PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "") PositionRow( label = stringResource(Res.string.alt), - value = position.altitude.metersIn(displayUnits).toString(displayUnits), + value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits), ) PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits)) PositionRow( label = stringResource(Res.string.heading), - value = "%.0f°".format(position.groundTrack * HEADING_DEG), + value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), ) PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime()) @@ -791,13 +793,13 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU @Composable private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String { - val speedInMps = position.groundSpeed + val speedInMps = position.ground_speed ?: 0 val mpsText = "%d m/s".format(speedInMps) val speedText = if (speedInMps > 10) { when (displayUnits) { - DisplayUnits.METRIC -> "%.1f Km/h".format(position.groundSpeed.mpsToKmph()) - DisplayUnits.IMPERIAL -> "%.1f mph".format(position.groundSpeed.mpsToMph()) + DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) + DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) else -> mpsText // Fallback or handle UNRECOGNIZED } } else { @@ -806,11 +808,11 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S return speedText } -internal fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D) +internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) private fun Node.toLatLng(): LatLng? = this.position.toLatLng() -private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D) +private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) private fun offsetPolyline( points: List, diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 3e4a19d18..f7f7179e8 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -58,7 +58,7 @@ import org.meshtastic.core.prefs.map.GoogleMapsPrefs import org.meshtastic.core.prefs.map.MapPrefs import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.Config import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -133,8 +133,8 @@ constructor( val displayUnits = radioConfigRepository.deviceProfileFlow - .mapNotNull { it.config.display.units } - .stateInWhileSubscribed(initialValue = ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC) + .mapNotNull { it.config?.display?.units } + .stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC) fun addCustomTileProvider(name: String, urlTemplate: String) { viewModelScope.launch { @@ -270,7 +270,7 @@ constructor( val wpMap = waypoints.first { it.containsKey(wpId) } wpMap[wpId]?.let { packet -> val waypoint = packet.data.waypoint!! - val latLng = LatLng(waypoint.latitudeI / 1e7, waypoint.longitudeI / 1e7) + val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) } } diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index 0dea2a46f..b68b455bd 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -74,8 +74,7 @@ import org.meshtastic.core.strings.time import org.meshtastic.core.strings.waypoint_edit import org.meshtastic.core.strings.waypoint_new import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.proto.MeshProtos.Waypoint -import org.meshtastic.proto.copy +import org.meshtastic.proto.Waypoint import java.util.Calendar import java.util.TimeZone @@ -92,7 +91,7 @@ fun EditWaypointDialog( var waypointInput by remember { mutableStateOf(waypoint) } val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit val defaultEmoji = 0x1F4CD // 📍 Round Pushpin - val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon + val currentEmojiCodepoint = if ((waypointInput.icon ?: 0) == 0) defaultEmoji else waypointInput.icon!! var showEmojiPickerView by remember { mutableStateOf(false) } val context = LocalContext.current @@ -102,7 +101,7 @@ fun EditWaypointDialog( var selectedDateString by remember { mutableStateOf("") } var selectedTimeString by remember { mutableStateOf("") } var isExpiryEnabled by remember { - mutableStateOf(waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) + mutableStateOf((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) } val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } @@ -111,15 +110,16 @@ fun EditWaypointDialog( timeFormat.timeZone = TimeZone.getDefault() LaunchedEffect(waypointInput.expire, isExpiryEnabled) { + val expireValue = waypointInput.expire ?: 0 if (isExpiryEnabled) { - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - calendar.timeInMillis = waypointInput.expire * 1000L + if (expireValue != 0 && expireValue != Int.MAX_VALUE) { + calendar.timeInMillis = expireValue * 1000L selectedDateString = dateFormat.format(calendar.time) selectedTimeString = timeFormat.format(calendar.time) } else { // If enabled but not set, default to 8 hours from now calendar.timeInMillis = System.currentTimeMillis() calendar.add(Calendar.HOUR_OF_DAY, 8) - waypointInput = waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() } + waypointInput = waypointInput.copy(expire = (calendar.timeInMillis / 1000).toInt()) } } else { selectedDateString = "" @@ -141,8 +141,8 @@ fun EditWaypointDialog( text = { Column(modifier = modifier.fillMaxWidth()) { OutlinedTextField( - value = waypointInput.name, - onValueChange = { waypointInput = waypointInput.copy { name = it.take(29) } }, + value = waypointInput.name ?: "", + onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) }, label = { Text(stringResource(Res.string.name)) }, singleLine = true, keyboardOptions = @@ -162,8 +162,8 @@ fun EditWaypointDialog( ) Spacer(modifier = Modifier.size(8.dp)) OutlinedTextField( - value = waypointInput.description, - onValueChange = { waypointInput = waypointInput.copy { description = it.take(99) } }, + value = waypointInput.description ?: "", + onValueChange = { waypointInput = waypointInput.copy(description = it.take(99)) }, label = { Text(stringResource(Res.string.description)) }, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), @@ -187,8 +187,8 @@ fun EditWaypointDialog( Text(stringResource(Res.string.locked)) } Switch( - checked = waypointInput.lockedTo != 0, - onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } }, + checked = (waypointInput.locked_to ?: 0) != 0, + onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, ) } Spacer(modifier = Modifier.size(8.dp)) @@ -210,17 +210,17 @@ fun EditWaypointDialog( onCheckedChange = { checked -> isExpiryEnabled = checked if (checked) { + val expireValue = waypointInput.expire ?: 0 // Default to 8 hours from now if not already set - if (waypointInput.expire == 0 || waypointInput.expire == Int.MAX_VALUE) { + if (expireValue == 0 || expireValue == Int.MAX_VALUE) { val cal = Calendar.getInstance() cal.timeInMillis = System.currentTimeMillis() cal.add(Calendar.HOUR_OF_DAY, 8) - waypointInput = - waypointInput.copy { expire = (cal.timeInMillis / 1000).toInt() } + waypointInput = waypointInput.copy(expire = (cal.timeInMillis / 1000).toInt()) } // LaunchedEffect will update date/time strings } else { - waypointInput = waypointInput.copy { expire = Int.MAX_VALUE } + waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) } }, ) @@ -229,8 +229,9 @@ fun EditWaypointDialog( if (isExpiryEnabled) { val currentCalendar = Calendar.getInstance().apply { - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - timeInMillis = waypointInput.expire * 1000L + val expireValue = waypointInput.expire ?: 0 + if (expireValue != 0 && expireValue != Int.MAX_VALUE) { + timeInMillis = expireValue * 1000L } else { timeInMillis = System.currentTimeMillis() add(Calendar.HOUR_OF_DAY, 8) // Default if re-enabling @@ -247,14 +248,14 @@ fun EditWaypointDialog( context, { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> val tempCal = Calendar.getInstance() - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - tempCal.timeInMillis = waypointInput.expire * 1000L + val expireValue = waypointInput.expire ?: 0 + if (expireValue != 0 && expireValue != Int.MAX_VALUE) { + tempCal.timeInMillis = expireValue * 1000L } else { tempCal.add(Calendar.HOUR_OF_DAY, 8) } tempCal.set(selectedYear, selectedMonth, selectedDay) - waypointInput = - waypointInput.copy { expire = (tempCal.timeInMillis / 1000).toInt() } + waypointInput = waypointInput.copy(expire = (tempCal.timeInMillis / 1000).toInt()) }, year, month, @@ -267,15 +268,15 @@ fun EditWaypointDialog( { _: TimePicker, selectedHour: Int, selectedMinute: Int -> // Keep the existing date part val tempCal = Calendar.getInstance() - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - tempCal.timeInMillis = waypointInput.expire * 1000L + val expireValue = waypointInput.expire ?: 0 + if (expireValue != 0 && expireValue != Int.MAX_VALUE) { + tempCal.timeInMillis = expireValue * 1000L } else { tempCal.add(Calendar.HOUR_OF_DAY, 8) } tempCal.set(Calendar.HOUR_OF_DAY, selectedHour) tempCal.set(Calendar.MINUTE, selectedMinute) - waypointInput = - waypointInput.copy { expire = (tempCal.timeInMillis / 1000).toInt() } + waypointInput = waypointInput.copy(expire = (tempCal.timeInMillis / 1000).toInt()) }, hour, minute, @@ -324,7 +325,10 @@ fun EditWaypointDialog( TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) { Text(stringResource(Res.string.cancel)) } - Button(onClick = { onSendClicked(waypointInput) }, enabled = waypointInput.name.isNotBlank()) { + Button( + onClick = { onSendClicked(waypointInput) }, + enabled = (waypointInput.name ?: "").isNotBlank(), + ) { Text(stringResource(Res.string.send)) } } @@ -335,7 +339,7 @@ fun EditWaypointDialog( } else { EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji -> showEmojiPickerView = false - waypointInput = waypointInput.copy { icon = selectedEmoji.codePointAt(0) } + waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0)) } } } diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/WaypointMarkers.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/WaypointMarkers.kt index b5a376e10..1776a4039 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/WaypointMarkers.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/WaypointMarkers.kt @@ -29,18 +29,18 @@ import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.locked import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Waypoint private const val DEG_D = 1e-7 @Composable fun WaypointMarkers( - displayableWaypoints: List, + displayableWaypoints: List, mapFilterState: BaseMapViewModel.MapFilterState, myNodeNum: Int, isConnected: Boolean, unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor, - onEditWaypointRequest: (MeshProtos.Waypoint) -> Unit, + onEditWaypointRequest: (Waypoint) -> Unit, selectedWaypointId: Int? = null, ) { val scope = rememberCoroutineScope() @@ -48,7 +48,9 @@ fun WaypointMarkers( if (mapFilterState.showWaypoints) { displayableWaypoints.forEach { waypoint -> val markerState = - rememberUpdatedMarkerState(position = LatLng(waypoint.latitudeI * DEG_D, waypoint.longitudeI * DEG_D)) + rememberUpdatedMarkerState( + position = LatLng((waypoint.latitude_i ?: 0) * DEG_D, (waypoint.longitude_i ?: 0) * DEG_D), + ) LaunchedEffect(selectedWaypointId) { if (selectedWaypointId == waypoint.id) { @@ -59,16 +61,16 @@ fun WaypointMarkers( Marker( state = markerState, icon = - if (waypoint.icon == 0) { + if ((waypoint.icon ?: 0) == 0) { unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin) } else { - unicodeEmojiToBitmapProvider(waypoint.icon) + unicodeEmojiToBitmapProvider(waypoint.icon!!) }, - title = waypoint.name.replace('\n', ' ').replace('\b', ' '), - snippet = waypoint.description.replace('\n', ' ').replace('\b', ' '), + title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '), + snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '), visible = true, onInfoWindowClick = { - if (waypoint.lockedTo == 0 || waypoint.lockedTo == myNodeNum || !isConnected) { + if ((waypoint.locked_to ?: 0) == 0 || waypoint.locked_to == myNodeNum || !isConnected) { onEditWaypointRequest(waypoint) } else { scope.launch { context.showToast(Res.string.locked) } diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt index c1f171ad6..796e2fcc7 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.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 org.meshtastic.feature.map.model import com.google.android.gms.maps.model.LatLng @@ -45,6 +44,6 @@ data class NodeClusterItem(val node: Node, val nodePosition: LatLng, val nodeTit 18 to 91.182212, 19 to 45.58554, ) - return precisionMap[this.node.position.precisionBits] + return precisionMap[this.node.position.precision_bits ?: 0] } } diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index 9263f0efa..430e2c91d 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.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 org.meshtastic.feature.map.node import androidx.compose.foundation.layout.Box @@ -36,7 +35,7 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) Scaffold( topBar = { MainAppBar( - title = node?.user?.longName ?: "", + title = node?.user?.long_name ?: "", ourNode = null, showNodeChip = false, canNavigateUp = true, diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 24f964399..d44523a78 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -43,7 +43,9 @@ import org.meshtastic.core.strings.one_hour import org.meshtastic.core.strings.two_days import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import org.meshtastic.proto.Waypoint import java.util.concurrent.TimeUnit @Suppress("MagicNumber") @@ -93,7 +95,8 @@ abstract class BaseMapViewModel( list .associateBy { packet -> packet.data.waypoint!!.id } .filterValues { - it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 + val expire = it.data.waypoint!!.expire ?: 0 + expire == 0 || expire > System.currentTimeMillis() / 1000 } } .stateInWhileSubscribed(initialValue = emptyMap()) @@ -122,9 +125,9 @@ abstract class BaseMapViewModel( fun getNodeByNum(nodeNum: Int): Node? = nodeRepository.nodeDBbyNum.value[nodeNum] - open fun getUser(userId: String?): MeshProtos.User = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) + open fun getUser(userId: String?): User = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) - fun getUser(nodeNum: Int): MeshProtos.User = nodeRepository.getUser(nodeNum) + fun getUser(nodeNum: Int): User = nodeRepository.getUser(nodeNum) fun getNodeOrFallback(nodeNum: Int): Node = getNodeByNum(nodeNum) ?: Node(num = nodeNum, user = getUser(nodeNum)) @@ -160,7 +163,7 @@ abstract class BaseMapViewModel( fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) } - fun sendWaypoint(wpt: MeshProtos.Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) val channel = contactKey[0].digitToIntOrNull() val dest = if (channel != null) contactKey.substring(1) else contactKey @@ -215,7 +218,7 @@ data class TracerouteNodeSelection( fun BaseMapViewModel.tracerouteNodeSelection( tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, + tracerouteNodePositions: Map, nodes: List, ): TracerouteNodeSelection { val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet() diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt index 22c171a3e..582cd2ce2 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.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 org.meshtastic.feature.map.node import androidx.lifecycle.SavedStateHandle @@ -35,8 +34,8 @@ import org.meshtastic.core.prefs.map.MapPrefs import org.meshtastic.core.ui.util.toPosition import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.model.CustomTileSource -import org.meshtastic.proto.MeshProtos.Position -import org.meshtastic.proto.Portnums.PortNum +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position import javax.inject.Inject @HiltViewModel @@ -61,13 +60,13 @@ constructor( val positionLogs: StateFlow> = meshLogRepository - .getMeshPacketsFrom(destNum!!, PortNum.POSITION_APP_VALUE) + .getMeshPacketsFrom(destNum!!, PortNum.POSITION_APP.value) .map { packets -> packets .mapNotNull { it.toPosition() } .asFlow() .distinctUntilChanged { old, new -> - old.time == new.time || (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI) + old.time == new.time || (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) } .toList() } diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 658f7cb52..80d3e144a 100644 --- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -147,7 +147,7 @@ class MessageItemTest { // Verify that the node containing the message text exists and matches the text composeTestRule - .onNodeWithContentDescription("Message from ${testNode.user?.longName}: Hello World") + .onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World") .assertIsDisplayed() } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index 1f13e6da9..996587b5a 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -141,7 +141,7 @@ import org.meshtastic.core.ui.component.SharedContactDialog import org.meshtastic.core.ui.component.smartScrollToIndex import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.feature.messaging.component.RetryConfirmationDialog -import org.meshtastic.proto.AppOnlyProtos +import org.meshtastic.proto.ChannelSet import java.nio.charset.StandardCharsets private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200 @@ -227,7 +227,7 @@ fun MessageScreen( remember(nodeId, channelName, viewModel) { when (nodeId) { DataPacket.ID_BROADCAST -> channelName - else -> viewModel.getUser(nodeId).longName + else -> viewModel.getUser(nodeId).long_name } } @@ -535,7 +535,7 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(Res.string.replying_to, replyingToNodeUser?.shortName ?: unknownUserText), + text = stringResource(Res.string.replying_to, replyingToNodeUser?.short_name ?: unknownUserText), style = MaterialTheme.typography.labelMedium, ) Text( @@ -711,7 +711,7 @@ private fun MessageTopBar( channelIndex: Int?, mismatchKey: Boolean, onNavigateBack: () -> Unit, - channels: AppOnlyProtos.ChannelSet?, + channels: ChannelSet?, channelIndexParam: Int?, showQuickChat: Boolean, onToggleQuickChat: () -> Unit, diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index d69e53b3e..9aa81b528 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -520,7 +520,7 @@ internal fun MessageStatusDialog( remember(message.relayNode, nodes, ourNode) { derivedStateOf { message.relayNode?.let { relayNodeId -> - Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.longName + Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.long_name } } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index f9259219c..c58a64ca2 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -49,9 +49,9 @@ import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.ConfigProtos.Config.DeviceConfig.Role -import org.meshtastic.proto.channelSet -import org.meshtastic.proto.sharedContact +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.Config.DeviceConfig.Role +import org.meshtastic.proto.SharedContact import javax.inject.Inject @Suppress("LongParameterList", "TooManyFunctions") @@ -78,7 +78,7 @@ constructor( val nodeList: StateFlow> = nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList()) - val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(channelSet {}) + val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(ChannelSet()) private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat) val showQuickChat: StateFlow = _showQuickChat @@ -190,7 +190,7 @@ constructor( // if the destination is a node, we need to ensure it's a // favorite so it does not get removed from the on-device node database. if (channel == null) { // no channel specified, so we assume it's a direct message - val fwVersion = ourNodeInfo.value?.metadata?.firmwareVersion + val fwVersion = ourNodeInfo.value?.metadata?.firmware_version val destNode = nodeRepository.getNode(dest) val isClientBase = ourNodeInfo.value?.user?.role == Role.CLIENT_BASE @@ -239,11 +239,8 @@ constructor( private fun sendSharedContact(node: Node) = viewModelScope.launch { try { - val contact = sharedContact { - nodeNum = node.num - user = node.user - manuallyVerified = node.manuallyVerified - } + val contact = + SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified) serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact)) } catch (ex: RemoteException) { Logger.e(ex) { "Send shared contact error" } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index ab9486c7a..5c6ba9f4d 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -234,7 +234,7 @@ internal fun MessageItem( ) { NodeChip(node = node, onClick = onClickChip, modifier = Modifier.height(28.dp)) Text( - text = node.user.longName, + text = node.user.long_name, overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.labelMedium, @@ -267,7 +267,7 @@ internal fun MessageItem( ) .then(messageModifier) .semantics(mergeDescendants = true) { - val senderName = if (message.fromLocal) ourNode.user.longName else node.user.longName + val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name contentDescription = "Message from $senderName: ${message.text}" }, color = containerColor, @@ -427,7 +427,7 @@ private fun OriginalMessageSnippet( modifier = Modifier.size(16.dp), ) Text( - text = originalMessageNode.user.shortName, + text = originalMessageNode.user.short_name, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, maxLines = 1, diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 042a9c151..aeb72693e 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -80,7 +80,7 @@ import org.meshtastic.core.ui.icon.Hops import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.feature.messaging.DeliveryInfo -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.User @Composable private fun ReactionItem( @@ -218,7 +218,7 @@ internal fun ReactionDialog( val relayNodeName = reaction.relayNode?.let { relayNodeId -> - Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.longName + Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.long_name } DeliveryInfo( @@ -268,9 +268,9 @@ internal fun ReactionDialog( val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL val displayName = if (isLocal) { - "${reaction.user.longName} (${stringResource(Res.string.you)})" + "${reaction.user.long_name} (${stringResource(Res.string.you)})" } else { - reaction.user.longName + reaction.user.long_name } Text(text = displayName, style = MaterialTheme.typography.titleMedium) Row(verticalAlignment = Alignment.CenterVertically) { @@ -343,7 +343,7 @@ private fun ReactionRowPreview() { listOf( Reaction( replyId = 1, - user = MeshProtos.User.getDefaultInstance(), + user = User(), emoji = "\uD83D\uDE42", timestamp = 1L, snr = -1.0f, @@ -352,7 +352,7 @@ private fun ReactionRowPreview() { ), Reaction( replyId = 1, - user = MeshProtos.User.getDefaultInstance(), + user = User(), emoji = "\uD83D\uDE42", timestamp = 1L, snr = -1.0f, diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml index 3f154d934..5e7845d73 100644 --- a/feature/node/detekt-baseline.xml +++ b/feature/node/detekt-baseline.xml @@ -1,11 +1,16 @@ - + - CommentWrapping:SignalMetrics.kt$Metric.SNR$/* Selected 12 as the max to get 4 equal vertical sections. */ - LongMethod:EnvironmentMetrics.kt$@Composable fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) + CyclomaticComplexMethod:CompassViewModel.kt$CompassViewModel$@Suppress("ReturnCount") private fun calculatePositionalAccuracyMeters(): Float? + CyclomaticComplexMethod:DeviceMetrics.kt$@Suppress("LongMethod") @Composable private fun DeviceMetricsChart( modifier: Modifier = Modifier, telemetries: List<Telemetry>, legendData: List<LegendData>, vicoScrollState: VicoScrollState, selectedX: Double?, onPointSelected: (Double) -> Unit, ) + CyclomaticComplexMethod:NodeDetailActions.kt$NodeDetailActions$fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction) + CyclomaticComplexMethod:NodeDetailViewModel.kt$NodeDetailViewModel$fun handleNodeMenuAction(action: NodeMenuAction) + CyclomaticComplexMethod:PowerMetrics.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5 MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7 + TooGenericExceptionCaught:PaxMetrics.kt$e: Exception + UnusedPrivateProperty:NodeDetailScreen.kt$val loadingMessage = stringResource(Res.string.loading) diff --git a/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt b/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt index 2a596b056..bb4c0fbe8 100644 --- a/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt +++ b/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.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 org.meshtastic.feature.node.component import androidx.compose.foundation.isSystemInDarkTheme @@ -69,7 +68,7 @@ internal fun InlineMap(node: Node, modifier: Modifier = Modifier) { ), cameraPositionState = cameraState, ) { - val precisionMeters = precisionBitsToMeters(node.position.precisionBits) + val precisionMeters = precisionBitsToMeters(node.position.precision_bits ?: 0) val latLng = LatLng(node.latitude, node.longitude) if (precisionMeters > 0) { Circle( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt index 2d40218d4..b9910ff06 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassUiState.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 org.meshtastic.feature.node.compass import androidx.compose.ui.graphics.Color -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config private const val DEFAULT_TARGET_COLOR_HEX = 0xFFFF9800 @@ -44,6 +43,6 @@ data class CompassUiState( val angularErrorDeg: Float? = null, val isAligned: Boolean = false, val hasTargetPosition: Boolean = true, - val displayUnits: DisplayUnits = DisplayUnits.METRIC, + val displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC, val targetAltitude: Int? = null, ) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index 715ddddab..719d6c043 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.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 org.meshtastic.feature.node.compass import android.hardware.GeomagneticField @@ -36,7 +35,8 @@ import org.meshtastic.core.model.util.bearing import org.meshtastic.core.model.util.latLongToMeter import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.ui.component.precisionBitsToMeters -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config +import org.meshtastic.proto.Position import javax.inject.Inject import kotlin.math.abs import kotlin.math.atan2 @@ -68,17 +68,18 @@ constructor( private var updatesJob: Job? = null private var targetPosition: Pair? = null - private var targetPositionProto: org.meshtastic.proto.MeshProtos.Position? = null + private var targetPositionProto: Position? = null private var targetPositionTimeSec: Long? = null - fun start(node: Node, displayUnits: DisplayUnits) { + fun start(node: Node, displayUnits: Config.DisplayConfig.DisplayUnits) { val targetPos = node.validPosition?.let { node.latitude to node.longitude } targetPosition = targetPos targetPositionProto = node.position val targetColor = Color(node.colors.second) - val targetName = node.user.longName.ifBlank { node.user.shortName.ifBlank { node.num.toString() } } + val targetName = + (node.user.long_name ?: "").ifBlank { (node.user.short_name ?: "").ifBlank { node.num.toString() } } targetPositionTimeSec = - node.position.timestamp.takeIf { it > 0 }?.toLong() ?: node.position.time.takeIf { it > 0 }?.toLong() + node.position.timestamp?.takeIf { it > 0 }?.toLong() ?: node.position.time?.takeIf { it > 0 }?.toLong() _uiState.update { it.copy( @@ -216,17 +217,16 @@ constructor( val positionTime = targetPositionTimeSec if (positionTime == null || positionTime <= 0) return null - val gpsAccuracyMm = position.gpsAccuracy.toFloat() + val gpsAccuracyMm = (position.gps_accuracy ?: 0).toFloat() + val pdop = position.PDOP ?: 0 + val hdop = position.HDOP ?: 0 + val vdop = position.VDOP ?: 0 val dop: Float? = when { - position.getPDOP() > 0 -> position.getPDOP() / HUNDRED - position.getHDOP() > 0 && position.getVDOP() > 0 -> - sqrt( - (position.getHDOP() / HUNDRED).toDouble().pow(2.0) + - (position.getVDOP() / HUNDRED).toDouble().pow(2.0), - ) - .toFloat() - position.getHDOP() > 0 -> position.getHDOP() / HUNDRED + pdop > 0 -> pdop / HUNDRED + hdop > 0 && vdop > 0 -> + sqrt((hdop / HUNDRED).toDouble().pow(2.0) + (vdop / HUNDRED).toDouble().pow(2.0)).toFloat() + hdop > 0 -> hdop / HUNDRED else -> null } @@ -235,8 +235,9 @@ constructor( } // Fallback: infer radius from precision bits if provided - if (position.precisionBits > 0) { - return precisionBitsToMeters(position.precisionBits).toFloat() + val precisionBits = position.precision_bits ?: 0 + if (precisionBits > 0) { + return precisionBitsToMeters(precisionBits).toFloat() } return null diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index 8737957e2..21ca38a60 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -49,7 +49,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.FirmwareEdition @Composable fun AdministrationSection( @@ -82,7 +82,7 @@ fun AdministrationSection( } } - val firmwareVersion = node.metadata?.firmwareVersion + val firmwareVersion = node.metadata?.firmware_version val firmwareEdition = metricsState.firmwareEdition if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) { FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect) @@ -92,7 +92,7 @@ fun AdministrationSection( @Composable private fun FirmwareSection( metricsState: MetricsState, - firmwareEdition: MeshProtos.FirmwareEdition?, + firmwareEdition: FirmwareEdition?, firmwareVersion: String?, onFirmwareSelect: (FirmwareRelease) -> Unit, ) { @@ -101,7 +101,7 @@ private fun FirmwareSection( firmwareEdition?.let { edition -> val icon = when (edition) { - MeshProtos.FirmwareEdition.VANILLA -> Icons.Rounded.Icecream + FirmwareEdition.VANILLA -> Icons.Rounded.Icecream else -> Icons.Rounded.ForkLeft } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt index 55648c9c8..3736a6f2d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/ElevationInfo.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 org.meshtastic.feature.node.component import androidx.compose.material3.MaterialTheme @@ -30,13 +29,13 @@ import org.meshtastic.core.strings.altitude import org.meshtastic.core.strings.elevation_suffix import org.meshtastic.core.ui.icon.Elevation import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config @Composable fun ElevationInfo( modifier: Modifier = Modifier, altitude: Int, - system: DisplayUnits, + system: Config.DisplayConfig.DisplayUnits, suffix: String = stringResource(Res.string.elevation_suffix), contentColor: Color = MaterialTheme.colorScheme.onSurface, ) { @@ -52,5 +51,5 @@ fun ElevationInfo( @Composable @Preview private fun ElevationInfoPreview() { - MaterialTheme { ElevationInfo(altitude = 100, system = DisplayUnits.METRIC, suffix = "ASL") } + MaterialTheme { ElevationInfo(altitude = 100, system = Config.DisplayConfig.DisplayUnits.METRIC, suffix = "ASL") } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt index 2143f1916..84978557d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -59,92 +59,78 @@ import org.meshtastic.core.strings.weight import org.meshtastic.core.strings.wind import org.meshtastic.feature.node.model.DrawableMetricInfo import org.meshtastic.feature.node.model.VectorMetricInfo -import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.Config @Suppress("CyclomaticComplexMethod", "LongMethod") @Composable internal fun EnvironmentMetrics( node: Node, - displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits, + displayUnits: Config.DisplayConfig.DisplayUnits, isFahrenheit: Boolean = false, ) { val vectorMetrics = remember(node.environmentMetrics, isFahrenheit, displayUnits) { buildList { with(node.environmentMetrics) { - if (!temperature.isNaN()) { - add( - VectorMetricInfo( - Res.string.temperature, - temperature.toTempString(isFahrenheit), - Icons.Rounded.Thermostat, - ), - ) + temperature?.let { temp -> + if (!temp.isNaN()) { + add( + VectorMetricInfo( + Res.string.temperature, + temp.toTempString(isFahrenheit), + Icons.Rounded.Thermostat, + ), + ) + } } - if (hasRelativeHumidity()) { - add( - VectorMetricInfo( - Res.string.humidity, - "%.0f%%".format(relativeHumidity), - Icons.Rounded.WaterDrop, - ), - ) + relative_humidity?.let { rh -> + add(VectorMetricInfo(Res.string.humidity, "%.0f%%".format(rh), Icons.Rounded.WaterDrop)) } - if (hasBarometricPressure()) { - add( - VectorMetricInfo( - Res.string.pressure, - "%.0f hPa".format(barometricPressure), - Icons.Rounded.Speed, - ), - ) + barometric_pressure?.let { bp -> + add(VectorMetricInfo(Res.string.pressure, "%.0f hPa".format(bp), Icons.Rounded.Speed)) } - if (hasGasResistance()) { - add( - VectorMetricInfo( - Res.string.gas_resistance, - "%.0f MΩ".format(gasResistance), - Icons.Rounded.BlurOn, - ), - ) + gas_resistance?.let { gr -> + add(VectorMetricInfo(Res.string.gas_resistance, "%.0f MΩ".format(gr), Icons.Rounded.BlurOn)) } - if (hasVoltage()) { - add(VectorMetricInfo(Res.string.voltage, "%.2fV".format(voltage), Icons.Rounded.Bolt)) + voltage?.let { v -> + add(VectorMetricInfo(Res.string.voltage, "%.2fV".format(v), Icons.Rounded.Bolt)) } - if (hasCurrent()) { - add(VectorMetricInfo(Res.string.current, "%.1fmA".format(current), Icons.Rounded.Power)) + current?.let { c -> + add(VectorMetricInfo(Res.string.current, "%.1fmA".format(c), Icons.Rounded.Power)) } - if (hasIaq()) add(VectorMetricInfo(Res.string.iaq, iaq.toString(), Icons.Rounded.Air)) - if (hasDistance()) { + iaq?.let { i -> add(VectorMetricInfo(Res.string.iaq, i.toString(), Icons.Rounded.Air)) } + distance?.let { d -> add( VectorMetricInfo( Res.string.distance, - distance.toSmallDistanceString(displayUnits), + d.toSmallDistanceString(displayUnits), Icons.Rounded.Height, ), ) } - if (hasLux()) add(VectorMetricInfo(Res.string.lux, "%.0f lx".format(lux), Icons.Rounded.LightMode)) - if (hasUvLux()) { - add(VectorMetricInfo(Res.string.uv_lux, "%.0f lx".format(uvLux), Icons.Rounded.LightMode)) + lux?.let { l -> + add(VectorMetricInfo(Res.string.lux, "%.0f lx".format(l), Icons.Rounded.LightMode)) } - if (hasWindSpeed()) { + uv_lux?.let { uvl -> + add(VectorMetricInfo(Res.string.uv_lux, "%.0f lx".format(uvl), Icons.Rounded.LightMode)) + } + wind_speed?.let { ws -> @Suppress("MagicNumber") - val normalizedBearing = (windDirection + 180) % 360 + val normalizedBearing = ((wind_direction ?: 0) + 180) % 360 add( VectorMetricInfo( Res.string.wind, - windSpeed.toSpeedString(displayUnits), + ws.toFloat().toSpeedString(displayUnits), Icons.Outlined.Navigation, normalizedBearing.toFloat(), ), ) } - if (hasWeight()) { - add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(weight), Icons.Rounded.Scale)) + weight?.let { w -> + add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(w), Icons.Rounded.Scale)) } - if (hasTemperature() && hasRelativeHumidity()) { - val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity) + if (temperature != null && relative_humidity != null) { + val dewPoint = UnitConversions.calculateDewPoint(temperature!!, relative_humidity!!) if (!dewPoint.isNaN()) { add( DrawableMetricInfo( @@ -155,29 +141,31 @@ internal fun EnvironmentMetrics( ) } } - if (hasSoilTemperature() && !soilTemperature.isNaN()) { - add( - DrawableMetricInfo( - Res.string.soil_temperature, - soilTemperature.toTempString(isFahrenheit), - org.meshtastic.feature.node.R.drawable.soil_temperature, - ), - ) + soil_temperature?.let { st -> + if (!st.isNaN()) { + add( + DrawableMetricInfo( + Res.string.soil_temperature, + st.toTempString(isFahrenheit), + org.meshtastic.feature.node.R.drawable.soil_temperature, + ), + ) + } } - if (hasSoilMoisture()) { + soil_moisture?.let { sm -> add( DrawableMetricInfo( Res.string.soil_moisture, - "%d%%".format(soilMoisture), + "%d%%".format(sm), org.meshtastic.feature.node.R.drawable.soil_moisture, ), ) } - if (hasRadiation()) { + radiation?.let { r -> add( DrawableMetricInfo( Res.string.radiation, - "%.1f µR/h".format(radiation), + "%.1f µR/h".format(r), org.meshtastic.feature.node.R.drawable.ic_filled_radioactive_24, ), ) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt index c3b2f147a..b42643955 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt @@ -52,12 +52,15 @@ import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.icon import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config import java.net.URLEncoder @OptIn(ExperimentalFoundationApi::class) @Composable -fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.METRIC) { +fun LinkedCoordinatesItem( + node: Node, + displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC, +) { val context = LocalContext.current val clipboard: Clipboard = LocalClipboard.current val coroutineScope = rememberCoroutineScope() @@ -91,7 +94,7 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits. supportingText = "$ago • $coordinates$elevationText", trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(), onClick = { - val label = URLEncoder.encode(node.user.longName, "utf-8") + val label = URLEncoder.encode(node.user.long_name ?: "", "utf-8") val uri = "geo:0,0?q=${node.latitude},${node.longitude}&z=17&label=$label".toUri() val intent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 5e5f072c3..19de1f177 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -148,8 +148,8 @@ private fun MainNodeDetails(node: Node) { SectionDivider() MqttAndVerificationRow(node) } - val publicKey = node.publicKey ?: node.user.publicKey - if (!publicKey.isEmpty) { + val publicKey = node.publicKey ?: node.user.public_key + if (publicKey != null && publicKey.size > 0) { SectionDivider() PublicKeyItem(publicKey.toByteArray()) } @@ -161,13 +161,13 @@ private fun NameAndRoleRow(node: Node) { Row(modifier = Modifier.fillMaxWidth()) { InfoItem( label = stringResource(Res.string.short_name), - value = node.user.shortName.ifEmpty { "???" }, + value = (node.user.short_name ?: "").ifEmpty { "???" }, icon = MeshtasticIcons.Person, modifier = Modifier.weight(1f), ) InfoItem( label = stringResource(Res.string.role), - value = node.user.role.name, + value = node.user.role?.name ?: "", icon = MeshtasticIcons.Role, modifier = Modifier.weight(1f), ) @@ -219,14 +219,14 @@ private fun UserAndUptimeRow(node: Node) { Row(modifier = Modifier.fillMaxWidth()) { InfoItem( label = stringResource(Res.string.user_id), - value = node.user.id, + value = node.user.id ?: "", icon = MeshtasticIcons.Person, modifier = Modifier.weight(1f), ) - if (node.deviceMetrics.uptimeSeconds > 0) { + if ((node.deviceMetrics.uptime_seconds ?: 0) > 0) { InfoItem( label = stringResource(Res.string.uptime), - value = formatUptime(node.deviceMetrics.uptimeSeconds), + value = formatUptime(node.deviceMetrics.uptime_seconds!!), icon = MeshtasticIcons.ArrowCircleUp, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 4e447b58a..c90dc22c6 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -73,7 +73,7 @@ import org.meshtastic.core.ui.component.SoilTemperatureInfo import org.meshtastic.core.ui.component.TemperatureInfo import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig +import org.meshtastic.proto.Config private const val ACTIVE_ALPHA = 0.5f private const val INACTIVE_ALPHA = 0.2f @@ -95,7 +95,7 @@ fun NodeItem( val isFavorite = remember(thatNode) { thatNode.isFavorite } val isMuted = remember(thatNode) { thatNode.isMuted } val isIgnored = thatNode.isIgnored - val originalLongName = thatNode.user.longName.ifEmpty { stringResource(Res.string.unknown_username) } + val originalLongName = (thatNode.user.long_name ?: "").ifEmpty { stringResource(Res.string.unknown_username) } @Suppress("MagicNumber") val longName = @@ -107,7 +107,10 @@ fun NodeItem( } } val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num } - val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) } + val system = + remember(distanceUnits) { + Config.DisplayConfig.DisplayUnits.fromValue(distanceUnits) ?: Config.DisplayConfig.DisplayUnits.METRIC + } val distance = remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) } @@ -135,7 +138,7 @@ fun NodeItem( val unmessageable = remember(thatNode) { when { - thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable + thatNode.user.is_unmessagable != null -> thatNode.user.is_unmessagable!! else -> thatNode.user.role.isUnmessageableRole() } } @@ -190,7 +193,7 @@ private fun NodeItemHeader( NodeKeyStatusIcon( hasPKC = thatNode.hasPKC, mismatchKey = thatNode.mismatchKey, - publicKey = thatNode.user.publicKey, + publicKey = thatNode.user.public_key, modifier = Modifier.size(32.dp), ) Text( @@ -216,7 +219,7 @@ private fun NodeItemHeader( private fun NodeItemMetrics( thatNode: Node, distance: String?, - system: DisplayConfig.DisplayUnits, + system: Config.DisplayConfig.DisplayUnits, contentColor: Color, ) { Row( @@ -224,8 +227,12 @@ private fun NodeItemMetrics( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - if (thatNode.batteryLevel > 0 || thatNode.voltage > 0f) { - MaterialBatteryInfo(level = thatNode.batteryLevel, voltage = thatNode.voltage, contentColor = contentColor) + if ((thatNode.batteryLevel ?: 0) > 0 || (thatNode.voltage ?: 0f) > 0f) { + MaterialBatteryInfo( + level = thatNode.batteryLevel ?: 0, + voltage = thatNode.voltage ?: 0f, + contentColor = contentColor, + ) } else { Spacer(modifier = Modifier.weight(1f)) } @@ -235,13 +242,13 @@ private fun NodeItemMetrics( } thatNode.validPosition?.let { position -> ElevationInfo( - altitude = position.altitude, + altitude = position.altitude ?: 0, system = system, suffix = stringResource(Res.string.elevation_suffix), contentColor = contentColor, ) } - val satCount = thatNode.validPosition?.satsInView ?: 0 + val satCount = thatNode.validPosition?.sats_in_view ?: 0 if (satCount > 0) { SatelliteCountInfo(satCount = satCount, contentColor = contentColor) } @@ -255,49 +262,49 @@ private fun NodeItemMetrics( private fun NodeItemEnvironment(thatNode: Node, tempInFahrenheit: Boolean, contentColor: Color) { val env = thatNode.environmentMetrics val pax = thatNode.paxcounter - if (thatNode.hasEnvironmentMetrics || pax.ble != 0 || pax.wifi != 0) { + if (thatNode.hasEnvironmentMetrics || (pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) { FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (pax.ble != 0 || pax.wifi != 0) { - PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor) + if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) { + PaxcountInfo(pax = "B:${pax.ble ?: 0} W:${pax.wifi ?: 0}", contentColor = contentColor) } - if (env.temperature != 0f) { + if ((env.temperature ?: 0f) != 0f) { val temp = if (tempInFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(env.temperature)) + "%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f)) } else { - "%.1f°C".format(env.temperature) + "%.1f°C".format(env.temperature ?: 0f) } TemperatureInfo(temp = temp, contentColor = contentColor) } - if (env.relativeHumidity != 0f) { - HumidityInfo(humidity = "%.0f%%".format(env.relativeHumidity), contentColor = contentColor) + if ((env.relative_humidity ?: 0f) != 0f) { + HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor) } - if (env.barometricPressure != 0f) { - PressureInfo(pressure = "%.1fhPa".format(env.barometricPressure), contentColor = contentColor) + if ((env.barometric_pressure ?: 0f) != 0f) { + PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor) } - if (env.soilTemperature != 0f) { + if ((env.soil_temperature ?: 0f) != 0f) { val temp = if (tempInFahrenheit) { - "%.1f°F".format(celsiusToFahrenheit(env.soilTemperature)) + "%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f)) } else { - "%.1f°C".format(env.soilTemperature) + "%.1f°C".format(env.soil_temperature ?: 0f) } SoilTemperatureInfo(temp = temp, contentColor = contentColor) } - if (env.soilMoisture != 0 && env.soilTemperature != 0f) { - SoilMoistureInfo(moisture = "${env.soilMoisture}%", contentColor = contentColor) + if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) { + SoilMoistureInfo(moisture = "${env.soil_moisture}%", contentColor = contentColor) } - if (env.voltage != 0f) { - PowerInfo(value = "%.2fV".format(env.voltage), contentColor = contentColor) + if ((env.voltage ?: 0f) != 0f) { + PowerInfo(value = "%.2fV".format(env.voltage ?: 0f), contentColor = contentColor) } - if (env.current != 0f) { - PowerInfo(value = "%.1fmA".format(env.current), contentColor = contentColor) + if ((env.current ?: 0f) != 0f) { + PowerInfo(value = "%.1fmA".format(env.current ?: 0f), contentColor = contentColor) } - if (env.iaq != 0) { + if ((env.iaq ?: 0) != 0) { AirQualityInfo(iaq = "${env.iaq}", contentColor = contentColor) } } @@ -311,9 +318,9 @@ private fun NodeItemFooter(thatNode: Node, contentColor: Color) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - HardwareInfo(hwModel = thatNode.user.hwModel.name, contentColor = contentColor) - RoleInfo(role = thatNode.user.role.name, contentColor = contentColor) - NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor) + HardwareInfo(hwModel = thatNode.user.hw_model?.name ?: "", contentColor = contentColor) + RoleInfo(role = thatNode.user.role?.name ?: "", contentColor = contentColor) + NodeIdInfo(id = (thatNode.user.id ?: "").ifEmpty { "???" }, contentColor = contentColor) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt index 4be04a1f4..6125b2b4d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt @@ -54,7 +54,7 @@ fun NodeActionDialogs( text = stringResource( if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add, - node.user.longName, + node.user.long_name ?: "", ), onConfirm = { onDismissMenuRequest() @@ -69,7 +69,7 @@ fun NodeActionDialogs( text = stringResource( if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add, - node.user.longName, + node.user.long_name ?: "", ), onConfirm = { onDismissMenuRequest() @@ -82,7 +82,10 @@ fun NodeActionDialogs( SimpleAlertDialog( title = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications, text = - stringResource(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.longName), + stringResource( + if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, + node.user.long_name ?: "", + ), onConfirm = { onDismissMenuRequest() onConfirmMute(node) @@ -93,7 +96,7 @@ fun NodeActionDialogs( if (displayRemoveDialog) { SimpleAlertDialog( title = Res.string.remove, - text = Res.string.remove_node_text, + text = stringResource(Res.string.remove_node_text), onConfirm = { onDismissMenuRequest() onConfirmRemove(node) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 1a6f26c6a..7d90a80f5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -55,7 +55,7 @@ import org.meshtastic.core.strings.position import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config private const val EXCHANGE_BUTTON_WEIGHT = 1.1f private const val COMPASS_BUTTON_WEIGHT = 0.9f @@ -148,7 +148,7 @@ private fun PositionMap(node: Node, distance: String?) { private fun PositionActionButtons( node: Node, hasValidPosition: Boolean, - displayUnits: DisplayUnits, + displayUnits: Config.DisplayConfig.DisplayUnits, onAction: (NodeDetailAction) -> Unit, ) { Row( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt index d44a0276b..2f7b9553f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt @@ -46,17 +46,17 @@ internal fun PowerMetrics(node: Node) { remember(node.powerMetrics) { buildList { with(node.powerMetrics) { - if (ch1Voltage != 0f) { - add(VectorMetricInfo(Res.string.channel_1, "%.2fV".format(ch1Voltage), Icons.Rounded.Bolt)) - add(VectorMetricInfo(Res.string.channel_1, "%.1fmA".format(ch1Current), Icons.Rounded.Power)) + if ((ch1_voltage ?: 0f) != 0f) { + add(VectorMetricInfo(Res.string.channel_1, "%.2fV".format(ch1_voltage), Icons.Rounded.Bolt)) + add(VectorMetricInfo(Res.string.channel_1, "%.1fmA".format(ch1_current), Icons.Rounded.Power)) } - if (ch2Voltage != 0f) { - add(VectorMetricInfo(Res.string.channel_2, "%.2fV".format(ch2Voltage), Icons.Rounded.Bolt)) - add(VectorMetricInfo(Res.string.channel_2, "%.1fmA".format(ch2Current), Icons.Rounded.Power)) + if ((ch2_voltage ?: 0f) != 0f) { + add(VectorMetricInfo(Res.string.channel_2, "%.2fV".format(ch2_voltage), Icons.Rounded.Bolt)) + add(VectorMetricInfo(Res.string.channel_2, "%.1fmA".format(ch2_current), Icons.Rounded.Power)) } - if (ch3Voltage != 0f) { - add(VectorMetricInfo(Res.string.channel_3, "%.2fV".format(ch3Voltage), Icons.Rounded.Bolt)) - add(VectorMetricInfo(Res.string.channel_3, "%.1fmA".format(ch3Current), Icons.Rounded.Power)) + if ((ch3_voltage ?: 0f) != 0f) { + add(VectorMetricInfo(Res.string.channel_3, "%.2fV".format(ch3_voltage), Icons.Rounded.Bolt)) + add(VectorMetricInfo(Res.string.channel_3, "%.1fmA".format(ch3_current), Icons.Rounded.Power)) } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt index 092eb5a4d..c43829787 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt @@ -37,15 +37,20 @@ constructor( is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node) is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node) is NodeMenuAction.RequestUserInfo -> - nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.longName) + nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.long_name ?: "") is NodeMenuAction.RequestNeighborInfo -> - nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.longName) + nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.long_name ?: "") is NodeMenuAction.RequestPosition -> - nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.longName) + nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.long_name ?: "") is NodeMenuAction.RequestTelemetry -> - nodeRequestActions.requestTelemetry(scope, action.node.num, action.node.user.longName, action.type) + nodeRequestActions.requestTelemetry( + scope, + action.node.num, + action.node.user.long_name ?: "", + action.type, + ) is NodeMenuAction.TraceRoute -> - nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.longName) + nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.long_name ?: "") else -> {} } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt index 54c500aa1..ec2856fcc 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt @@ -143,7 +143,7 @@ private fun NodeDetailScaffold( modifier = modifier, topBar = { MainAppBar( - title = node?.user?.longName ?: "", + title = node?.user?.long_name ?: "", ourNode = uiState.ourNode, showNodeChip = false, canNavigateUp = true, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index ff21843f3..16f102964 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -54,13 +54,15 @@ import org.meshtastic.core.strings.fallback_node_name import org.meshtastic.core.ui.util.toPosition import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.metrics.EnvironmentMetricsState -import org.meshtastic.feature.node.metrics.safeNumber import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.MetricsState -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.Portnums.PortNum -import org.meshtastic.proto.TelemetryProtos.Telemetry +import org.meshtastic.proto.Config +import org.meshtastic.proto.FirmwareEdition +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User import javax.inject.Inject data class NodeDetailUiState( @@ -110,35 +112,43 @@ constructor( val telemetryFlow = meshLogRepository.getTelemetryFrom(nodeId).distinctUntilChanged() val packetsFlow = meshLogRepository.getMeshPacketsFrom(nodeId).distinctUntilChanged() val posPacketsFlow = - meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP_VALUE).distinctUntilChanged() + meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value).distinctUntilChanged() val paxLogsFlow = - meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP_VALUE).distinctUntilChanged() + meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value).distinctUntilChanged() val trReqsFlow = meshLogRepository - .getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE) + .getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value) .map { logs -> logs.filter { log -> - with(log.fromRadio.packet) { - hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId - } + val pkt = log.fromRadio.packet + val decoded = pkt?.decoded + pkt != null && + decoded != null && + decoded.want_response == true && + pkt.from == 0 && + pkt.to == nodeId } } .distinctUntilChanged() val trResFlow = - meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP_VALUE).distinctUntilChanged() + meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value).distinctUntilChanged() val niReqsFlow = meshLogRepository - .getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE) + .getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value) .map { logs -> logs.filter { log -> - with(log.fromRadio.packet) { - hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId - } + val pkt = log.fromRadio.packet + val decoded = pkt?.decoded + pkt != null && + decoded != null && + decoded.want_response == true && + pkt.from == 0 && + pkt.to == nodeId } } .distinctUntilChanged() val niResFlow = - meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP_VALUE).distinctUntilChanged() + meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value).distinctUntilChanged() combine( nodeRepository.ourNodeInfo, @@ -154,7 +164,7 @@ constructor( trResFlow, niReqsFlow, niResFlow, - meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged(), + meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(), firmwareReleaseRepository.stableRelease, firmwareReleaseRepository.alphaRelease, nodeRequestActions.lastTracerouteTimes, @@ -167,16 +177,16 @@ constructor( ourNode = args[0] as Node?, ourNodeNum = args[1] as Int?, myInfo = (args[3] as MyNodeEntity?)?.toMyNodeInfo(), - profile = args[4] as org.meshtastic.proto.ClientOnlyProtos.DeviceProfile, + profile = args[4] as org.meshtastic.proto.DeviceProfile, telemetry = args[5] as List, - packets = args[6] as List, - positionPackets = args[7] as List, + packets = args[6] as List, + positionPackets = args[7] as List, paxLogs = args[8] as List, tracerouteRequests = args[9] as List, tracerouteResults = args[10] as List, neighborInfoRequests = args[11] as List, neighborInfoResults = args[12] as List, - firmwareEdition = args[13] as MeshProtos.FirmwareEdition?, + firmwareEditionArg = args[13] as? FirmwareEdition, stable = args[14] as FirmwareRelease?, alpha = args[15] as FirmwareRelease?, lastTracerouteTime = (args[16] as Map)[nodeId], @@ -185,12 +195,12 @@ constructor( } .flatMapLatest { data -> val pioEnv = if (data.nodeId == data.ourNodeNum) data.myInfo?.pioEnv else null - val hwModel = data.actualNode.user.hwModel.safeNumber() + val hwModel = data.actualNode.user.hw_model?.value ?: 0 flow { val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, pioEnv).getOrNull() - val moduleConfig = data.profile.moduleConfig - val displayUnits = data.profile.config.display.units + val moduleConfig = data.profile.module_config + val displayUnits = data.profile.config?.display?.units val metricsState = MetricsState( @@ -198,22 +208,22 @@ constructor( isLocal = data.nodeId == data.ourNodeNum, deviceHardware = hw, reportedTarget = pioEnv, - isManaged = data.profile.config.security.isManaged, + isManaged = data.profile.config?.security?.is_managed ?: false, isFahrenheit = - moduleConfig.telemetry.environmentDisplayFahrenheit || + moduleConfig?.telemetry?.environment_display_fahrenheit == true || (displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL), - displayUnits = displayUnits, - deviceMetrics = data.telemetry.filter { it.hasDeviceMetrics() }, - powerMetrics = data.telemetry.filter { it.hasPowerMetrics() }, - hostMetrics = data.telemetry.filter { it.hasHostMetrics() }, - signalMetrics = data.packets.filter { it.rxTime > 0 }, + displayUnits = displayUnits ?: Config.DisplayConfig.DisplayUnits.METRIC, + deviceMetrics = data.telemetry.filter { it.device_metrics != null }, + powerMetrics = data.telemetry.filter { it.power_metrics != null }, + hostMetrics = data.telemetry.filter { it.host_metrics != null }, + signalMetrics = data.packets.filter { (it.rx_time ?: 0) > 0 }, positionLogs = data.positionPackets.mapNotNull { it.toPosition() }, paxMetrics = data.paxLogs, tracerouteRequests = data.tracerouteRequests, tracerouteResults = data.tracerouteResults, neighborInfoRequests = data.neighborInfoRequests, neighborInfoResults = data.neighborInfoResults, - firmwareEdition = data.firmwareEdition, + firmwareEdition = data.firmwareEditionArg, latestStableFirmware = data.stable ?: FirmwareRelease(), latestAlphaFirmware = data.alpha ?: FirmwareRelease(), ) @@ -222,10 +232,11 @@ constructor( EnvironmentMetricsState( environmentMetrics = data.telemetry.filter { - it.hasEnvironmentMetrics() && - it.environmentMetrics.hasRelativeHumidity() && - it.environmentMetrics.hasTemperature() && - !it.environmentMetrics.temperature.isNaN() + val em = it.environment_metrics + em != null && + em.relative_humidity != null && + em.temperature != null && + em.temperature!!.isNaN().not() }, ) @@ -275,20 +286,24 @@ constructor( is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node) is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node) is NodeMenuAction.RequestUserInfo -> - nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.longName) + nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name ?: "") is NodeMenuAction.RequestNeighborInfo -> - nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.longName) + nodeRequestActions.requestNeighborInfo( + viewModelScope, + action.node.num, + action.node.user.long_name ?: "", + ) is NodeMenuAction.RequestPosition -> - nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.longName) + nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.long_name ?: "") is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry( viewModelScope, action.node.num, - action.node.user.longName, + action.node.user.long_name ?: "", action.type, ) is NodeMenuAction.TraceRoute -> - nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.longName) + nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.long_name ?: "") else -> {} } } @@ -306,17 +321,12 @@ constructor( } @Suppress("MagicNumber") - private fun createFallbackNode(nodeNum: Int): Node { + private suspend fun createFallbackNode(nodeNum: Int): Node { val userId = DataPacket.nodeNumToDefaultId(nodeNum) val safeUserId = userId.padStart(4, '0').takeLast(4) val longName = "${getString(Res.string.fallback_node_name)}_$safeUserId" val defaultUser = - MeshProtos.User.newBuilder() - .setId(userId) - .setLongName(longName) - .setShortName(safeUserId) - .setHwModel(MeshProtos.HardwareModel.UNSET) - .build() + User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET) return Node(num = nodeNum, user = defaultUser) } } @@ -327,16 +337,16 @@ private data class NodeDetailUiStateData( val ourNode: Node?, val ourNodeNum: Int?, val myInfo: MyNodeInfo?, - val profile: org.meshtastic.proto.ClientOnlyProtos.DeviceProfile, + val profile: org.meshtastic.proto.DeviceProfile, val telemetry: List, - val packets: List, - val positionPackets: List, + val packets: List, + val positionPackets: List, val paxLogs: List, val tracerouteRequests: List, val tracerouteResults: List, val neighborInfoRequests: List, val neighborInfoResults: List, - val firmwareEdition: MeshProtos.FirmwareEdition?, + val firmwareEditionArg: FirmwareEdition?, val stable: FirmwareRelease?, val alpha: FirmwareRelease?, val lastTracerouteTime: Long?, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 16dfedbd6..0b8b1926b 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -83,7 +83,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.feature.node.component.NodeActionDialogs import org.meshtastic.feature.node.component.NodeFilterTextField import org.meshtastic.feature.node.component.NodeItem -import org.meshtastic.proto.AdminProtos +import org.meshtastic.proto.SharedContact @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -134,8 +134,7 @@ fun NodeListScreen( }, floatingActionButton = { val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false - val sharedContact: AdminProtos.SharedContact? by - viewModel.sharedContactRequested.collectAsStateWithLifecycle(null) + val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null) AddContactFAB( sharedContact = sharedContact, modifier = diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index bc9c50621..07d161324 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -35,8 +35,8 @@ import org.meshtastic.core.database.model.NodeSortOption import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.model.isEffectivelyUnmessageable -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.Config +import org.meshtastic.proto.SharedContact import javax.inject.Inject @HiltViewModel @@ -59,7 +59,7 @@ constructor( val connectionState = serviceRepository.connectionState - private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null) + private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null) val sharedContactRequested = _sharedContactRequested.asStateFlow() private val nodeSortOption = nodeFilterPreferences.nodeSortOption @@ -99,8 +99,8 @@ constructor( NodesUiState( sort = sort, filter = nodeFilter, - distanceUnits = profile.config.display.units.number, - tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit, + distanceUnits = profile.config?.display?.units?.value ?: 0, + tempInFahrenheit = profile.module_config?.telemetry?.environment_display_fahrenheit ?: false, ) } .stateInWhileSubscribed(initialValue = NodesUiState()) @@ -124,10 +124,10 @@ constructor( val role = node.user.role val infrastructureRoles = listOf( - ConfigProtos.Config.DeviceConfig.Role.ROUTER, - ConfigProtos.Config.DeviceConfig.Role.REPEATER, - ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE, - ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE, + Config.DeviceConfig.Role.ROUTER, + Config.DeviceConfig.Role.REPEATER, + Config.DeviceConfig.Role.ROUTER_LATE, + Config.DeviceConfig.Role.CLIENT_BASE, ) role !in infrastructureRoles && !node.isEffectivelyUnmessageable } else { @@ -151,7 +151,7 @@ constructor( nodeFilterPreferences.setNodeSort(sort) } - fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) { + fun setSharedContactRequested(sharedContact: SharedContact?) { _sharedContactRequested.value = sharedContact } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index a83a60773..b6d6d0bc7 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -180,7 +180,7 @@ fun BaseMetricScreen( Scaffold( topBar = { MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", subtitle = stringResource(titleRes) + " (${data.size} ${stringResource(Res.string.logs)})", ourNode = null, showNodeChip = false, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 641678821..7fc17e597 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -81,21 +81,20 @@ import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.TelemetryProtos.Telemetry +import org.meshtastic.proto.Telemetry private enum class Device(val color: Color) { BATTERY(Green) { - override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat() + override fun getValue(telemetry: Telemetry): Float = (telemetry.device_metrics?.battery_level ?: 0).toFloat() }, VOLTAGE(Gold) { - override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.voltage + override fun getValue(telemetry: Telemetry): Float = telemetry.device_metrics?.voltage ?: 0f }, CH_UTIL(Purple) { - override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.channelUtilization + override fun getValue(telemetry: Telemetry): Float = telemetry.device_metrics?.channel_utilization ?: 0f }, AIR_UTIL(Cyan) { - override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.airUtilTx + override fun getValue(telemetry: Telemetry): Float = telemetry.device_metrics?.air_util_tx ?: 0f }, ; abstract fun getValue(telemetry: Telemetry): Float @@ -125,10 +124,10 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat val state by viewModel.state.collectAsStateWithLifecycle() val data = state.deviceMetrics - val hasBattery = remember(data) { data.any { it.deviceMetrics.hasBatteryLevel() } } - val hasVoltage = remember(data) { data.any { it.deviceMetrics.hasVoltage() } } - val hasChUtil = remember(data) { data.any { it.deviceMetrics.hasChannelUtilization() } } - val hasAirUtil = remember(data) { data.any { it.deviceMetrics.hasAirUtilTx() } } + val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } } + val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } } + val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } } + val hasAirUtil = remember(data) { data.any { it.device_metrics?.air_util_tx != null } } val filteredLegendData = remember(hasBattery, hasVoltage, hasChUtil, hasAirUtil) { @@ -173,7 +172,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat telemetryType = TelemetryType.DEVICE, titleRes = Res.string.device_metrics_log, data = data, - timeProvider = { it.time.toDouble() }, + timeProvider = { (it.time ?: 0).toDouble() }, infoData = infoItems, chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> DeviceMetricsChart( @@ -190,8 +189,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat itemsIndexed(data) { _, telemetry -> DeviceMetricsCard( telemetry = telemetry, - isSelected = telemetry.time.toDouble() == selectedX, - onClick = { onCardClick(telemetry.time.toDouble()) }, + isSelected = (telemetry.time ?: 0).toDouble() == selectedX, + onClick = { onCardClick((telemetry.time ?: 0).toDouble()) }, ) } } @@ -235,16 +234,28 @@ private fun DeviceMetricsChart( modelProducer.runTransaction { /* Series for Left Axis (0-100%) */ lineSeries { - series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.batteryLevel }) - val chUtilData = telemetries.filter { !it.deviceMetrics.channelUtilization.isNaN() } - series(x = chUtilData.map { it.time }, y = chUtilData.map { it.deviceMetrics.channelUtilization }) - val airUtilData = telemetries.filter { !it.deviceMetrics.airUtilTx.isNaN() } - series(x = airUtilData.map { it.time }, y = airUtilData.map { it.deviceMetrics.airUtilTx }) + series( + x = telemetries.map { it.time ?: 0 }, + y = telemetries.map { it.device_metrics?.battery_level ?: 0 }, + ) + val chUtilData = telemetries.filter { it.device_metrics?.channel_utilization != null } + series( + x = chUtilData.map { it.time ?: 0 }, + y = chUtilData.map { it.device_metrics?.channel_utilization ?: 0f }, + ) + val airUtilData = telemetries.filter { it.device_metrics?.air_util_tx != null } + series( + x = airUtilData.map { it.time ?: 0 }, + y = airUtilData.map { it.device_metrics?.air_util_tx ?: 0f }, + ) } /* Series for Right Axis (Voltage) */ lineSeries { - val voltageData = telemetries.filter { !it.deviceMetrics.voltage.isNaN() } - series(x = voltageData.map { it.time }, y = voltageData.map { it.deviceMetrics.voltage }) + val voltageData = telemetries.filter { it.device_metrics?.voltage != null } + series( + x = voltageData.map { it.time ?: 0 }, + y = voltageData.map { it.device_metrics?.voltage ?: 0f }, + ) } } } @@ -317,17 +328,17 @@ private fun DeviceMetricsChartPreview() { val now = (System.currentTimeMillis() / 1000).toInt() val telemetries = List(20) { i -> - Telemetry.newBuilder() - .setTime(now - (19 - i) * 60 * 60) // 1-hour intervals, oldest first - .setDeviceMetrics( - TelemetryProtos.DeviceMetrics.newBuilder() - .setBatteryLevel(80 - i) - .setVoltage(3.7f - i * 0.02f) - .setChannelUtilization(10f + i * 2) - .setAirUtilTx(5f + i) - .setUptimeSeconds(3600 + i * 300), - ) - .build() + Telemetry( + time = now - (19 - i) * 60 * 60, // 1-hour intervals, oldest first + device_metrics = + org.meshtastic.proto.DeviceMetrics( + battery_level = 80 - i, + voltage = 3.7f - i * 0.02f, + channel_utilization = 10f + i * 2, + air_util_tx = 5f + i, + uptime_seconds = 3600 + i * 300, + ), + ) } AppTheme { DeviceMetricsChart( @@ -345,8 +356,8 @@ private fun DeviceMetricsChartPreview() { @Composable @Suppress("LongMethod") private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { - val deviceMetrics = telemetry.deviceMetrics - val time = telemetry.time * MS_PER_SEC + val deviceMetrics = telemetry.device_metrics + val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC Card( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, @@ -371,15 +382,18 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick ) Row(verticalAlignment = Alignment.CenterVertically) { - if (deviceMetrics.hasBatteryLevel()) { + if (deviceMetrics?.battery_level != null) { MetricIndicator(Device.BATTERY.color) Spacer(Modifier.width(4.dp)) } - if (deviceMetrics.hasVoltage()) { + if (deviceMetrics?.voltage != null) { MetricIndicator(Device.VOLTAGE.color) Spacer(Modifier.width(8.dp)) } - MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage) + MaterialBatteryInfo( + level = deviceMetrics?.battery_level ?: 0, + voltage = deviceMetrics?.voltage ?: 0f, + ) } } @@ -388,28 +402,31 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick /* Channel Utilization and Air Utilization Tx */ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(verticalAlignment = Alignment.CenterVertically) { - if (deviceMetrics.hasChannelUtilization()) { + if (deviceMetrics?.channel_utilization != null) { MetricIndicator(Device.CH_UTIL.color) Spacer(Modifier.width(4.dp)) Text( - text = "Ch: %.1f%%".format(deviceMetrics.channelUtilization), + text = "Ch: %.1f%%".format(deviceMetrics.channel_utilization ?: 0f), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) Spacer(Modifier.width(12.dp)) } - if (deviceMetrics.hasAirUtilTx()) { + if (deviceMetrics?.air_util_tx != null) { MetricIndicator(Device.AIR_UTIL.color) Spacer(Modifier.width(4.dp)) Text( - text = "Air: %.1f%%".format(deviceMetrics.airUtilTx), + text = "Air: %.1f%%".format(deviceMetrics.air_util_tx ?: 0f), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } } Text( - text = stringResource(Res.string.uptime) + ": " + formatUptime(deviceMetrics.uptimeSeconds), + text = + stringResource(Res.string.uptime) + + ": " + + formatUptime(deviceMetrics?.uptime_seconds ?: 0), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -426,17 +443,17 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick private fun DeviceMetricsCardPreview() { val now = (System.currentTimeMillis() / 1000).toInt() val telemetry = - Telemetry.newBuilder() - .setTime(now) - .setDeviceMetrics( - TelemetryProtos.DeviceMetrics.newBuilder() - .setBatteryLevel(75) - .setVoltage(3.65f) - .setChannelUtilization(22.5f) - .setAirUtilTx(12.0f) - .setUptimeSeconds(7200), - ) - .build() + Telemetry( + time = now, + device_metrics = + org.meshtastic.proto.DeviceMetrics( + battery_level = 75, + voltage = 3.65f, + channel_utilization = 22.5f, + air_util_tx = 12.0f, + uptime_seconds = 7200, + ), + ) AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) } } @@ -447,17 +464,17 @@ private fun DeviceMetricsScreenPreview() { val now = (System.currentTimeMillis() / 1000).toInt() val telemetries = List(24) { i -> - Telemetry.newBuilder() - .setTime(now - (23 - i) * 60 * 60) // 1-hour intervals, oldest first - .setDeviceMetrics( - TelemetryProtos.DeviceMetrics.newBuilder() - .setBatteryLevel(85 - i * 2) // Battery decreases over time - .setVoltage(3.8f - i * 0.01f) // Voltage decreases slightly - .setChannelUtilization(15f + i * 1.5f) // Channel utilization increases - .setAirUtilTx(8f + i * 0.8f) // Air utilization increases - .setUptimeSeconds(3600 + i * 3600), // Uptime increases by 1 hour each - ) - .build() + Telemetry( + time = now - (23 - i) * 60 * 60, // 1-hour intervals, oldest first + device_metrics = + org.meshtastic.proto.DeviceMetrics( + battery_level = 85 - i * 2, // Battery decreases over time + voltage = 3.8f - i * 0.01f, // Voltage decreases slightly + channel_utilization = 15f + i * 1.5f, // Channel utilization increases + air_util_tx = 8f + i * 0.8f, // Air utilization increases + uptime_seconds = 3600 + i * 3600, // Uptime increases by 1 hour each + ), + ) } AppTheme { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index b2cf20c25..4b397c7cd 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -42,7 +42,7 @@ import org.meshtastic.core.strings.soil_moisture import org.meshtastic.core.strings.soil_temperature import org.meshtastic.core.strings.temperature import org.meshtastic.core.strings.uv_lux -import org.meshtastic.proto.TelemetryProtos.Telemetry +import org.meshtastic.proto.Telemetry @Suppress("MagicNumber") private val LEGEND_DATA_1 = @@ -137,7 +137,7 @@ fun EnvironmentMetricsChart( val pressureData = telemetries.filter { val v = Environment.BAROMETRIC_PRESSURE.getValue(it) - v != null && !v.isNaN() + it.time != 0 && v != null && !v.isNaN() } series( x = pressureData.map { it.time }, @@ -152,7 +152,7 @@ fun EnvironmentMetricsChart( val metricData = telemetries.filter { val v = metric.getValue(it) - v != null && !v.isNaN() + it.time != 0 && v != null && !v.isNaN() } series(x = metricData.map { it.time }, y = metricData.map { metric.getValue(it)!! }) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index ec14998cc..750b3159d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -90,9 +90,7 @@ import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS -import org.meshtastic.proto.TelemetryProtos -import org.meshtastic.proto.TelemetryProtos.Telemetry -import org.meshtastic.proto.copy +import org.meshtastic.proto.Telemetry @Suppress("LongMethod") @Composable @@ -122,15 +120,13 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa val processedTelemetries: List = if (state.isFahrenheit) { data.map { telemetry -> - val temperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.temperature) - val soilTemperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.soilTemperature) - telemetry.copy { - environmentMetrics = - telemetry.environmentMetrics.copy { - temperature = temperatureFahrenheit - soilTemperature = soilTemperatureFahrenheit - } - } + val em = telemetry.environment_metrics ?: return@map telemetry + val temperatureFahrenheit = em.temperature?.let { celsiusToFahrenheit(it) } + val soilTemperatureFahrenheit = em.soil_temperature?.let { celsiusToFahrenheit(it) } + telemetry.copy( + environment_metrics = + em.copy(temperature = temperatureFahrenheit, soil_temperature = soilTemperatureFahrenheit), + ) } } else { data @@ -141,7 +137,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa Scaffold( topBar = { MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", subtitle = stringResource(Res.string.env_metrics_log) + " (${processedTelemetries.size} ${stringResource(Res.string.logs)})", @@ -185,7 +181,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa selectedX = selectedX, onPointSelected = { x -> selectedX = x - val index = processedTelemetries.indexOfFirst { it.time.toDouble() == x } + val index = processedTelemetries.indexOfFirst { (it.time ?: 0).toDouble() == x } if (index != -1) { coroutineScope.launch { lazyListState.animateScrollToItem(index) } } @@ -198,12 +194,12 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa EnvironmentMetricsCard( telemetry = telemetry, environmentDisplayFahrenheit = state.isFahrenheit, - isSelected = telemetry.time.toDouble() == selectedX, + isSelected = (telemetry.time ?: 0).toDouble() == selectedX, onClick = { - selectedX = telemetry.time.toDouble() + selectedX = (telemetry.time ?: 0).toDouble() coroutineScope.launch { vicoScrollState.animateScroll( - Scroll.Absolute.x(telemetry.time.toDouble(), SCROLL_BIAS), + Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), SCROLL_BIAS), ) } }, @@ -217,7 +213,10 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa } @Composable -private fun TemperatureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) { +private fun TemperatureDisplay( + envMetrics: org.meshtastic.proto.EnvironmentMetrics, + environmentDisplayFahrenheit: Boolean, +) { envMetrics.temperature?.let { temperature -> if (!temperature.isNaN()) { val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C" @@ -235,9 +234,9 @@ private fun TemperatureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e } @Composable -private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { - val hasHumidity = envMetrics.relativeHumidity?.let { !it.isNaN() } == true - val hasPressure = envMetrics.barometricPressure?.let { !it.isNaN() && it > 0 } == true +private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + val hasHumidity = envMetrics.relative_humidity?.let { !it.isNaN() } == true + val hasPressure = envMetrics.barometric_pressure?.let { !it.isNaN() && it > 0 } == true if (hasHumidity || hasPressure) { Row( @@ -245,7 +244,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.Env horizontalArrangement = Arrangement.SpaceBetween, ) { if (hasHumidity) { - val humidity = envMetrics.relativeHumidity!! + val humidity = envMetrics.relative_humidity!! Row(verticalAlignment = Alignment.CenterVertically) { MetricIndicator(Environment.HUMIDITY.color) Spacer(Modifier.width(4.dp)) @@ -258,7 +257,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.Env } } if (hasPressure) { - val pressure = envMetrics.barometricPressure!! + val pressure = envMetrics.barometric_pressure!! Row(verticalAlignment = Alignment.CenterVertically) { MetricIndicator(Environment.BAROMETRIC_PRESSURE.color) Spacer(Modifier.width(4.dp)) @@ -275,15 +274,18 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.Env } @Composable -private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) { +private fun SoilMetricsDisplay( + envMetrics: org.meshtastic.proto.EnvironmentMetrics, + environmentDisplayFahrenheit: Boolean, +) { if ( - envMetrics.soilTemperature != null || - (envMetrics.soilMoisture != null && envMetrics.soilMoisture != Int.MIN_VALUE) + envMetrics.soil_temperature != null || + (envMetrics.soil_moisture != null && envMetrics.soil_moisture != Int.MIN_VALUE) ) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { val soilTemperatureTextFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C" val soilMoistureTextFormat = "%s %d%%" - envMetrics.soilMoisture?.let { soilMoistureValue -> + envMetrics.soil_moisture?.let { soilMoistureValue -> if (soilMoistureValue != Int.MIN_VALUE) { Row(verticalAlignment = Alignment.CenterVertically) { MetricIndicator(Environment.SOIL_MOISTURE.color) @@ -300,7 +302,7 @@ private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e } } } - envMetrics.soilTemperature?.let { soilTemperature -> + envMetrics.soil_temperature?.let { soilTemperature -> if (!soilTemperature.isNaN()) { Row(verticalAlignment = Alignment.CenterVertically) { MetricIndicator(Environment.SOIL_TEMPERATURE.color) @@ -322,9 +324,9 @@ private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e } @Composable -private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { - val hasLux = envMetrics.lux != null && !envMetrics.lux.isNaN() - val hasUvLux = envMetrics.uvLux != null && !envMetrics.uvLux.isNaN() +private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + val hasLux = envMetrics.lux != null && !envMetrics.lux!!.isNaN() + val hasUvLux = envMetrics.uv_lux != null && !envMetrics.uv_lux!!.isNaN() if (hasLux || hasUvLux) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -341,7 +343,7 @@ private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { } } if (hasUvLux) { - val uvLuxValue = envMetrics.uvLux!! + val uvLuxValue = envMetrics.uv_lux!! Row(verticalAlignment = Alignment.CenterVertically) { MetricIndicator(Environment.UV_LUX.color) Spacer(Modifier.width(4.dp)) @@ -357,9 +359,9 @@ private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { } @Composable -private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { - val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage.isNaN() - val hasCurrent = envMetrics.current != null && !envMetrics.current.isNaN() +private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage!!.isNaN() + val hasCurrent = envMetrics.current != null && !envMetrics.current!!.isNaN() if (hasVoltage || hasCurrent) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -384,9 +386,9 @@ private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics } @Composable -private fun GasCompositionDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { +private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val iaqValue = envMetrics.iaq - val gasResistance = envMetrics.gasResistance + val gasResistance = envMetrics.gas_resistance if ((iaqValue != null && iaqValue != Int.MIN_VALUE) || (gasResistance?.isFinite() == true)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -419,7 +421,7 @@ private fun GasCompositionDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics } @Composable -private fun RadiationDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { +private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { envMetrics.radiation?.let { radiation -> if (!radiation.isNaN() && radiation > 0f) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -441,8 +443,8 @@ private fun EnvironmentMetricsCard( isSelected: Boolean, onClick: () -> Unit, ) { - val envMetrics = telemetry.environmentMetrics - val time = telemetry.time * MS_PER_SEC + val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics() + val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC Card( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, @@ -465,8 +467,8 @@ private fun EnvironmentMetricsCard( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) { - val envMetrics = telemetry.environmentMetrics - val time = telemetry.time * MS_PER_SEC + val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics() + val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { /* Time and Temperature */ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -493,27 +495,23 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa @Preview(showBackground = true) @Composable private fun PreviewEnvironmentMetricsContent() { - // Build a fake EnvironmentMetrics using the generated proto builder APIs val fakeEnvMetrics = - TelemetryProtos.EnvironmentMetrics.newBuilder() - .setTemperature(22.5f) - .setRelativeHumidity(55.0f) - .setBarometricPressure(1013.25f) - .setSoilMoisture(33) - .setSoilTemperature(18.0f) - .setLux(100.0f) - .setUvLux(100.0f) - .setVoltage(3.7f) - .setCurrent(0.12f) - .setIaq(100) - .setRadiation(0.15f) - .setGasResistance(1200.0f) - .build() + org.meshtastic.proto.EnvironmentMetrics( + temperature = 22.5f, + relative_humidity = 55.0f, + barometric_pressure = 1013.25f, + soil_moisture = 33, + soil_temperature = 18.0f, + lux = 100.0f, + uv_lux = 100.0f, + voltage = 3.7f, + current = 0.12f, + iaq = 100, + radiation = 0.15f, + gas_resistance = 1200.0f, + ) val fakeTelemetry = - TelemetryProtos.Telemetry.newBuilder() - .setTime((System.currentTimeMillis() / 1000).toInt()) - .setEnvironmentMetrics(fakeEnvMetrics) - .build() + Telemetry(time = (System.currentTimeMillis() / 1000).toInt(), environment_metrics = fakeEnvMetrics) MaterialTheme { Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt index 76134db50..4333ffc30 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -27,58 +27,57 @@ import org.meshtastic.core.ui.theme.GraphColors.Orange import org.meshtastic.core.ui.theme.GraphColors.Pink import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.core.ui.theme.GraphColors.Red -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.Telemetry @Suppress("MagicNumber") enum class Environment(val color: Color) { TEMPERATURE(Red) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.temperature + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.temperature }, HUMIDITY(Blue) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.relativeHumidity + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.relative_humidity }, SOIL_TEMPERATURE(Pink) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.soilTemperature + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_temperature }, SOIL_MOISTURE(Purple) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = - telemetry.environmentMetrics.soilMoisture.toFloat() + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_moisture?.toFloat() }, BAROMETRIC_PRESSURE(Green) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.barometric_pressure }, GAS_RESISTANCE(InfantryBlue) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.gas_resistance }, IAQ(Cyan) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq.toFloat() + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.iaq?.toFloat() }, LUX(Gold) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.lux }, UV_LUX(Orange) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.uvLux + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.uv_lux }, ; - abstract fun getValue(telemetry: TelemetryProtos.Telemetry): Float? + abstract fun getValue(telemetry: Telemetry): Float? } /** - * @param metrics the [List] of [TelemetryProtos.Telemetry] + * @param metrics the [List] of [Telemetry] * @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted * @param leftMinMax [Pair] with the min and max of the barometric pressure * @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ * @param times [Pair] with the oldest and newest times in that order */ data class EnvironmentGraphingData( - val metrics: List, + val metrics: List, val shouldPlot: List, val leftMinMax: Pair = Pair(0f, 0f), val rightMinMax: Pair = Pair(0f, 0f), val times: Pair = Pair(0, 0), ) -data class EnvironmentMetricsState(val environmentMetrics: List = emptyList()) { +data class EnvironmentMetricsState(val environmentMetrics: List = emptyList()) { fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() /** @@ -99,7 +98,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List() // Temperature - val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature.takeIf { !it.isNaN() } } + val temperatures = telemetries.mapNotNull { it.environment_metrics?.temperature?.takeIf { !it.isNaN() } } if (temperatures.isNotEmpty()) { var minTempValue = temperatures.minOf { it } var maxTempValue = temperatures.maxOf { it } @@ -114,7 +113,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List. */ - package org.meshtastic.feature.node.metrics import co.touchlab.kermit.Logger -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.HardwareModel /** * Safely extracts the hardware model number from a HardwareModel enum. @@ -31,8 +30,8 @@ import org.meshtastic.proto.MeshProtos * @return The hardware model number, or the fallback value if the enum is unknown */ @Suppress("detekt:SwallowedException") -fun MeshProtos.HardwareModel.safeNumber(fallbackValue: Int = -1): Int = try { - this.number +fun HardwareModel.safeNumber(fallbackValue: Int = -1): Int = try { + this.value } catch (e: IllegalArgumentException) { Logger.w { "Unknown hardware model enum value: $this, using fallback value: $fallbackValue" } fallbackValue diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 414d2beb5..93bfd9ae3 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -72,7 +72,8 @@ import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.HostMetrics +import org.meshtastic.proto.Telemetry import java.text.DecimalFormat @OptIn(ExperimentalFoundationApi::class) @@ -97,7 +98,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o Scaffold( topBar = { MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -126,8 +127,8 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongMethod", "MagicNumber") @Composable -fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Telemetry) { - val hostMetrics = telemetry.hostMetrics +fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) { + val hostMetrics = telemetry.host_metrics val time = telemetry.time * CommonCharts.MS_PER_SEC Card( modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }), @@ -144,74 +145,86 @@ fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Te text = DATE_TIME_FORMAT.format(time), style = MaterialTheme.typography.titleMediumEmphasized, ) - LogLine( - label = stringResource(Res.string.uptime), - value = formatUptime(hostMetrics.uptimeSeconds), - modifier = Modifier.fillMaxWidth(), - ) - LogLine( - label = stringResource(Res.string.free_memory), - value = formatBytes(hostMetrics.freememBytes), - modifier = Modifier.fillMaxWidth(), - ) - LogLine( - label = stringResource(Res.string.disk_free_indexed, 1), - value = formatBytes(hostMetrics.diskfree1Bytes), - modifier = Modifier.fillMaxWidth(), - ) - if (hostMetrics.hasDiskfree2Bytes()) { + hostMetrics?.uptime_seconds?.let { + LogLine( + label = stringResource(Res.string.uptime), + value = formatUptime(it), + modifier = Modifier.fillMaxWidth(), + ) + } + hostMetrics?.freemem_bytes?.let { + LogLine( + label = stringResource(Res.string.free_memory), + value = formatBytes(it), + modifier = Modifier.fillMaxWidth(), + ) + } + hostMetrics?.diskfree1_bytes?.let { + LogLine( + label = stringResource(Res.string.disk_free_indexed, 1), + value = formatBytes(it), + modifier = Modifier.fillMaxWidth(), + ) + } + hostMetrics?.diskfree2_bytes?.let { LogLine( label = stringResource(Res.string.disk_free_indexed, 2), - value = formatBytes(hostMetrics.diskfree2Bytes), + value = formatBytes(it), modifier = Modifier.fillMaxWidth(), ) } - if (hostMetrics.hasDiskfree3Bytes()) { + hostMetrics?.diskfree3_bytes?.let { LogLine( label = stringResource(Res.string.disk_free_indexed, 3), - value = formatBytes(hostMetrics.diskfree3Bytes), + value = formatBytes(it), modifier = Modifier.fillMaxWidth(), ) } - LogLine( - label = stringResource(Res.string.load_indexed, 1), - value = (hostMetrics.load1 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load1 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - LogLine( - label = stringResource(Res.string.load_indexed, 5), - value = (hostMetrics.load5 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load5 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - LogLine( - label = stringResource(Res.string.load_indexed, 15), - value = (hostMetrics.load15 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load15 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - if (hostMetrics.hasUserString()) { + hostMetrics?.load1?.let { + LogLine( + label = stringResource(Res.string.load_indexed, 1), + value = (hostMetrics.load1 / 100.0).toString(), + modifier = Modifier.fillMaxWidth(), + ) + LinearProgressIndicator( + progress = { hostMetrics.load1 / 10000.0f }, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } + hostMetrics?.load5?.let { + LogLine( + label = stringResource(Res.string.load_indexed, 5), + value = (hostMetrics.load5 / 100.0).toString(), + modifier = Modifier.fillMaxWidth(), + ) + LinearProgressIndicator( + progress = { hostMetrics.load5 / 10000.0f }, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } + hostMetrics?.load15?.let { + LogLine( + label = stringResource(Res.string.load_indexed, 15), + value = (hostMetrics.load15 / 100.0).toString(), + modifier = Modifier.fillMaxWidth(), + ) + LinearProgressIndicator( + progress = { hostMetrics.load15 / 10000.0f }, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } + hostMetrics?.user_string?.let { Text(text = stringResource(Res.string.user_string), style = MaterialTheme.typography.bodyMedium) - Text(text = hostMetrics.userString, style = TextStyle(fontFamily = FontFamily.Monospace)) + Text(text = it, style = TextStyle(fontFamily = FontFamily.Monospace)) } } } @@ -257,21 +270,17 @@ fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String { @Composable private fun HostMetricsItemPreview() { val hostMetrics = - TelemetryProtos.HostMetrics.newBuilder() - .setUptimeSeconds(3600) - .setFreememBytes(2048000) - .setDiskfree1Bytes(104857600) - .setDiskfree2Bytes(2097915200) - .setDiskfree3Bytes(44444) - .setLoad1(30) - .setLoad5(75) - .setLoad15(19) - .setUserString("test") - .build() - val logs = - TelemetryProtos.Telemetry.newBuilder() - .setTime((System.currentTimeMillis() / 1000L).toInt()) - .setHostMetrics(hostMetrics) - .build() + HostMetrics( + uptime_seconds = 3600, + freemem_bytes = 2048000, + diskfree1_bytes = 104857600, + diskfree2_bytes = 2097915200, + diskfree3_bytes = 44444, + load1 = 30, + load5 = 75, + load15 = 19, + user_string = "test", + ) + val logs = Telemetry(time = (System.currentTimeMillis() / 1000).toInt(), host_metrics = hostMetrics) AppTheme { HostMetricsItem(telemetry = logs) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 97e09dc7d..436cb3e47 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -62,11 +62,11 @@ import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.model.MetricsState -import org.meshtastic.proto.ConfigProtos.Config -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.MeshProtos.MeshPacket -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.Portnums.PortNum +import org.meshtastic.proto.Config +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.User import java.io.BufferedWriter import java.io.FileNotFoundException import java.io.FileWriter @@ -76,8 +76,9 @@ import javax.inject.Inject private const val DEFAULT_ID_SUFFIX_LENGTH = 4 -private fun MeshPacket.hasValidSignal(): Boolean = - rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0) +private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 && + ((rx_snr ?: 0f) != 0f && (rx_rssi ?: 0) != 0) && + ((hop_start ?: 0) > 0 && (hop_start ?: 0) - (hop_limit ?: 0) == 0) @Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel @@ -104,10 +105,10 @@ constructor( private val tracerouteOverlayCache = MutableStateFlow>(emptyMap()) private fun MeshLog.hasValidTraceroute(): Boolean = - with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum } + with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum } private fun MeshLog.hasValidNeighborInfo(): Boolean = - with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum } + with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum } /** * Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from @@ -118,12 +119,7 @@ constructor( val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH) val longName = getString(Res.string.fallback_node_name) + " $safeUserId" val defaultUser = - MeshProtos.User.newBuilder() - .setId(userId) - .setLongName(longName) - .setShortName(safeUserId) - .setHwModel(MeshProtos.HardwareModel.UNSET) - .build() + User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET) return Node(num = nodeNum, user = defaultUser) } @@ -189,7 +185,7 @@ constructor( } fun clearPosition() = viewModelScope.launch(dispatchers.io) { - destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) } + destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) } } fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) } @@ -209,28 +205,28 @@ constructor( nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null) fun requestUserInfo() { - destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.longName ?: "") } + destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "") } } fun requestPosition() { - destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.longName ?: "") } + destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "") } } fun requestTelemetry(type: TelemetryType) { destNum?.let { - nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.longName ?: "", type) + nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.long_name ?: "", type) } } fun requestTraceroute() { destNum?.let { - nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.longName ?: "") + nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.long_name ?: "") } } fun requestNeighborInfo() { destNum?.let { - nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.longName ?: "") + nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "") } } @@ -262,10 +258,10 @@ constructor( // Create a fallback node if not found in database (for hidden clients, etc.) val actualNode = node ?: createFallbackNode(currentDestNum) val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null + val hwModel = actualNode.user.hw_model?.value ?: 0 val deviceHardware = - actualNode.user.hwModel.safeNumber().let { - deviceHardwareRepository.getDeviceHardwareByModel(it, target = pioEnv) - } + deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv) + _state.update { state -> state.copy( node = actualNode, @@ -279,15 +275,15 @@ constructor( launch { radioConfigRepository.deviceProfileFlow.collect { profile -> - val moduleConfig = profile.moduleConfig - val displayUnits = profile.config.display.units + val moduleConfig = profile.module_config + val displayUnits = profile.config?.display?.units _state.update { state -> state.copy( - isManaged = profile.config.security.isManaged, + isManaged = profile.config?.security?.is_managed ?: false, isFahrenheit = - moduleConfig.telemetry.environmentDisplayFahrenheit || + moduleConfig?.telemetry?.environment_display_fahrenheit == true || (displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL), - displayUnits = displayUnits, + displayUnits = displayUnits ?: Config.DisplayConfig.DisplayUnits.METRIC, ) } } @@ -297,19 +293,19 @@ constructor( meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry -> _state.update { state -> state.copy( - deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, - powerMetrics = telemetry.filter { it.hasPowerMetrics() }, - hostMetrics = telemetry.filter { it.hasHostMetrics() }, + deviceMetrics = telemetry.filter { it.device_metrics != null }, + powerMetrics = telemetry.filter { it.power_metrics != null }, + hostMetrics = telemetry.filter { it.host_metrics != null }, ) } _environmentState.update { state -> state.copy( environmentMetrics = telemetry.filter { - it.hasEnvironmentMetrics() && - it.environmentMetrics.hasRelativeHumidity() && - it.environmentMetrics.hasTemperature() && - !it.environmentMetrics.temperature.isNaN() + it.environment_metrics != null && + it.environment_metrics?.relative_humidity != null && + it.environment_metrics?.temperature != null && + it.environment_metrics?.temperature?.isNaN()?.not() == true }, ) } @@ -326,8 +322,8 @@ constructor( launch { combine( - meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE), - meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP_VALUE), + meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value), + meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP.value), ) { request, response -> _state.update { state -> state.copy( @@ -341,8 +337,8 @@ constructor( launch { combine( - meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE), - meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP_VALUE), + meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value), + meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP.value), ) { request, response -> _state.update { state -> state.copy( @@ -357,7 +353,7 @@ constructor( launch { meshLogRepository.getMeshPacketsFrom( currentDestNum, - PortNum.POSITION_APP_VALUE, + PortNum.POSITION_APP.value, ).collect { packets -> val distinctPositions = packets @@ -365,7 +361,7 @@ constructor( .asFlow() .distinctUntilChanged { old, new -> old.time == new.time || - (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI) + (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) } .toList() _state.update { state -> state.copy(positionLogs = distinctPositions) } @@ -373,10 +369,7 @@ constructor( } launch { - meshLogRepository.getLogsFrom( - currentDestNum, - Portnums.PortNum.PAXCOUNTER_APP_VALUE, - ).collect { logs -> + meshLogRepository.getLogsFrom(currentDestNum, PortNum.PAXCOUNTER_APP.value).collect { logs -> _state.update { state -> state.copy(paxMetrics = logs) } } } @@ -396,7 +389,7 @@ constructor( launch { meshLogRepository .getMyNodeInfo() - .map { it?.firmwareEdition } + .map { it?.firmware_edition } .distinctUntilChanged() .collect { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } @@ -426,13 +419,13 @@ constructor( val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault()) positions.forEach { position -> - val rxDateTime = dateFormat.format(position.time * 1000L) - val latitude = position.latitudeI * 1e-7 - val longitude = position.longitudeI * 1e-7 + val rxDateTime = dateFormat.format((position.time ?: 0).toLong() * 1000L) + val latitude = (position.latitude_i ?: 0) * 1e-7 + val longitude = (position.longitude_i ?: 0) * 1e-7 val altitude = position.altitude - val satsInView = position.satsInView - val speed = position.groundSpeed - val heading = "%.2f".format(position.groundTrack * 1e-5) + val satsInView = position.sats_in_view + val speed = position.ground_speed + val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5) // date,time,latitude,longitude,altitude,satsInView,speed,heading writer.appendLine( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt index bd866ad71..6d6b5fe1e 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt @@ -93,7 +93,8 @@ fun NeighborInfoLogScreen( } } - fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } + fun getUsername(nodeNum: Int): String = + with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" } var showDialog by remember { mutableStateOf(null) } val context = LocalContext.current @@ -115,7 +116,7 @@ fun NeighborInfoLogScreen( topBar = { val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState() MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", subtitle = stringResource(Res.string.neighbor_info), ourNode = null, showNodeChip = false, @@ -142,9 +143,9 @@ fun NeighborInfoLogScreen( ) { items(state.neighborInfoRequests, key = { it.uuid }) { log -> val result = - remember(state.neighborInfoResults, log.fromRadio.packet.id) { + remember(state.neighborInfoResults, log.fromRadio.packet?.id) { state.neighborInfoResults.find { - it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id + it.fromRadio.packet?.decoded?.request_id == log.fromRadio.packet?.id } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 85f84352d..5a1f7db02 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -85,10 +85,10 @@ import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.theme.GraphColors.Orange import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.feature.node.detail.NodeRequestEffect -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.Portnums.PortNum +import org.meshtastic.proto.PortNum import java.text.DateFormat import java.util.Date +import org.meshtastic.proto.Paxcount as ProtoPaxcount private enum class PaxSeries(val color: Color, val legendRes: StringResource) { PAX(Color.Gray, Res.string.pax), @@ -137,7 +137,7 @@ private fun PaxMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(1f)) { + when (color.copy(alpha = 1f)) { bleColor -> "BLE: %.0f".format(value) wifiColor -> "WiFi: %.0f".format(value) paxColor -> "PAX: %.0f".format(value) @@ -210,7 +210,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav } val dateFormat = DateFormat.getDateTimeInstance() - // Only show logs that can be decoded as PaxcountProtos.Paxcount + // Only show logs that can be decoded as ProtoPaxcount val paxMetrics = state.paxMetrics.mapNotNull { log -> val pax = decodePaxFromLog(log) @@ -225,7 +225,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav paxMetrics .map { val t = (it.first.received_date / 1000).toInt() - Triple(t, it.second.ble, it.second.wifi) + Triple(t, it.second.ble ?: 0, it.second.wifi ?: 0) } .sortedBy { it.first } val totalSeries = graphData.map { it.first to (it.second + it.third) } @@ -235,7 +235,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav Scaffold( topBar = { MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", subtitle = stringResource(Res.string.pax_metrics_log) + " (${paxMetrics.size} ${stringResource(Res.string.logs)})", @@ -324,19 +324,18 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav } @Suppress("MagicNumber", "CyclomaticComplexMethod") -fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? { - var result: PaxcountProtos.Paxcount? = null +fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? { + var result: ProtoPaxcount? = null // First, try to parse from the binary fromRadio field (robust, like telemetry) try { val packet = log.fromRadio.packet - if (packet != null && packet.hasDecoded() && packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) { - val pax = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload) - if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) result = pax + val decoded = packet?.decoded + if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) { + val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload) + if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) result = pax } - } catch (e: com.google.protobuf.InvalidProtocolBufferException) { + } catch (e: Exception) { android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from binary data", e) - } catch (e: IllegalArgumentException) { - android.util.Log.e("PaxMetrics", "Invalid argument while parsing Paxcount from binary data", e) } // Fallback: Try direct base64 or bytes from raw_message if (result == null) { @@ -344,16 +343,14 @@ fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? { val base64 = log.raw_message.trim() if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) { val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) - val pax = PaxcountProtos.Paxcount.parseFrom(bytes) + val pax = ProtoPaxcount.ADAPTER.decode(bytes) result = pax } else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) { val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val pax = PaxcountProtos.Paxcount.parseFrom(bytes) + val pax = ProtoPaxcount.ADAPTER.decode(bytes) result = pax } - } catch (e: IllegalArgumentException) { - android.util.Log.e("PaxMetrics", "Invalid Base64 or hex input", e) - } catch (e: com.google.protobuf.InvalidProtocolBufferException) { + } catch (e: Exception) { android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from decoded data", e) } } @@ -395,13 +392,7 @@ fun PaxcountInfo( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun PaxMetricsItem( - log: MeshLog, - pax: PaxcountProtos.Paxcount, - dateFormat: DateFormat, - isSelected: Boolean, - onClick: () -> Unit, -) { +fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isSelected: Boolean, onClick: () -> Unit) { Card( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, @@ -429,19 +420,19 @@ fun PaxMetricsItem( Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { MetricIndicator(PaxSeries.PAX.color) Spacer(Modifier.width(4.dp)) - Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge) + Text(text = "PAX: ${(pax.ble ?: 0) + (pax.wifi ?: 0)}", style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.width(8.dp)) MetricIndicator(PaxSeries.BLE.color) Spacer(Modifier.width(4.dp)) - Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge) + Text(text = "B:${pax.ble ?: 0}", style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.width(8.dp)) MetricIndicator(PaxSeries.WIFI.color) Spacer(Modifier.width(4.dp)) - Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge) + Text(text = "W:${pax.wifi ?: 0}", style = MaterialTheme.typography.bodyLarge) } Text( - text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime), + text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime ?: 0), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.End, ) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt index 79e3af580..3554780da 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt @@ -83,8 +83,8 @@ import org.meshtastic.core.ui.icon.Save import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.feature.node.detail.NodeRequestEffect -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Config +import org.meshtastic.proto.Position @Composable private fun RowScope.PositionText(text: String, weight: Float) { @@ -121,18 +121,18 @@ const val DEG_D = 1e-7 const val HEADING_DEG = 1e-5 @Composable -fun PositionItem(compactWidth: Boolean, position: MeshProtos.Position, system: DisplayUnits) { +fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - PositionText("%.5f".format(position.latitudeI * DEG_D), WEIGHT_20) - PositionText("%.5f".format(position.longitudeI * DEG_D), WEIGHT_20) - PositionText(position.satsInView.toString(), WEIGHT_10) - PositionText(position.altitude.metersIn(system).toString(system), WEIGHT_15) + PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20) + PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20) + PositionText(position.sats_in_view.toString(), WEIGHT_10) + PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15) if (!compactWidth) { - PositionText("${position.groundSpeed} Km/h", WEIGHT_15) - PositionText("%.0f°".format(position.groundTrack * HEADING_DEG), WEIGHT_15) + PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15) + PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15) } PositionText(position.formatPositionTime(), WEIGHT_40) } @@ -199,7 +199,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU Scaffold( topBar = { MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -255,8 +255,8 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU @Composable private fun ColumnScope.PositionList( compactWidth: Boolean, - positions: List, - displayUnits: DisplayUnits, + positions: List, + displayUnits: Config.DisplayConfig.DisplayUnits, ) { LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { items(positions) { position -> PositionItem(compactWidth, position, displayUnits) } @@ -265,20 +265,20 @@ private fun ColumnScope.PositionList( @Suppress("MagicNumber") private val testPosition = - MeshProtos.Position.newBuilder() - .apply { - latitudeI = 297604270 - longitudeI = -953698040 - altitude = 1230 - satsInView = 7 - time = (System.currentTimeMillis() / 1000).toInt() - } - .build() + Position( + latitude_i = 297604270, + longitude_i = -953698040, + altitude = 1230, + sats_in_view = 7, + time = (System.currentTimeMillis() / 1000).toInt(), + ) @Preview(showBackground = true) @Composable private fun PositionItemPreview() { - AppTheme { PositionItem(compactWidth = false, position = testPosition, system = DisplayUnits.METRIC) } + AppTheme { + PositionItem(compactWidth = false, position = testPosition, system = Config.DisplayConfig.DisplayUnits.METRIC) + } } @PreviewScreenSizes diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index 717a1dd5e..97f5a933e 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -92,7 +92,7 @@ import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.proto.TelemetryProtos.Telemetry +import org.meshtastic.proto.Telemetry private enum class PowerMetric(val color: Color) { CURRENT(InfantryBlue), @@ -149,7 +149,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate Scaffold( topBar = { MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", subtitle = stringResource(Res.string.power_metrics_log) + " (${data.size} ${stringResource(Res.string.logs)})", ourNode = null, @@ -192,7 +192,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate selectedX = selectedX, onPointSelected = { x -> selectedX = x - val index = data.indexOfFirst { it.time.toDouble() == x } + val index = data.indexOfFirst { (it.time ?: 0).toDouble() == x } if (index != -1) { coroutineScope.launch { lazyListState.animateScrollToItem(index) } } @@ -204,12 +204,12 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate itemsIndexed(data) { _, telemetry -> PowerMetricsCard( telemetry = telemetry, - isSelected = telemetry.time.toDouble() == selectedX, + isSelected = (telemetry.time ?: 0).toDouble() == selectedX, onClick = { - selectedX = telemetry.time.toDouble() + selectedX = (telemetry.time ?: 0).toDouble() coroutineScope.launch { vicoScrollState.animateScroll( - Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f), + Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), 0.5f), ) } }, @@ -255,14 +255,14 @@ private fun PowerMetricsChart( lineSeries { val currentData = telemetries.filter { !retrieveCurrent(selectedChannel, it).isNaN() } series( - x = currentData.map { it.time }, + x = currentData.map { it.time ?: 0 }, y = currentData.map { retrieveCurrent(selectedChannel, it) }, ) } lineSeries { val voltageData = telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() } series( - x = voltageData.map { it.time }, + x = voltageData.map { it.time ?: 0 }, y = voltageData.map { retrieveVoltage(selectedChannel, it) }, ) } @@ -319,7 +319,7 @@ private fun PowerMetricsChart( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { - val time = telemetry.time * MS_PER_SEC + val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC Card( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, @@ -348,26 +348,17 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: Spacer(modifier = Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) { - PowerChannelColumn( - Res.string.channel_1, - telemetry.powerMetrics.ch1Voltage, - telemetry.powerMetrics.ch1Current, - ) - } - if (telemetry.powerMetrics.hasCh2Current() || telemetry.powerMetrics.hasCh2Voltage()) { - PowerChannelColumn( - Res.string.channel_2, - telemetry.powerMetrics.ch2Voltage, - telemetry.powerMetrics.ch2Current, - ) - } - if (telemetry.powerMetrics.hasCh3Current() || telemetry.powerMetrics.hasCh3Voltage()) { - PowerChannelColumn( - Res.string.channel_3, - telemetry.powerMetrics.ch3Voltage, - telemetry.powerMetrics.ch3Current, - ) + val pm = telemetry.power_metrics + if (pm != null) { + if (pm.ch1_current != null || pm.ch1_voltage != null) { + PowerChannelColumn(Res.string.channel_1, pm.ch1_voltage ?: 0f, pm.ch1_current ?: 0f) + } + if (pm.ch2_current != null || pm.ch2_voltage != null) { + PowerChannelColumn(Res.string.channel_2, pm.ch2_voltage ?: 0f, pm.ch2_current ?: 0f) + } + if (pm.ch3_current != null || pm.ch3_voltage != null) { + PowerChannelColumn(Res.string.channel_3, pm.ch3_voltage ?: 0f, pm.ch3_current ?: 0f) + } } } } @@ -408,14 +399,14 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current /** Retrieves the appropriate voltage depending on `channelSelected`. */ private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { - PowerChannel.ONE -> telemetry.powerMetrics.ch1Voltage - PowerChannel.TWO -> telemetry.powerMetrics.ch2Voltage - PowerChannel.THREE -> telemetry.powerMetrics.ch3Voltage + PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN + PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN + PowerChannel.THREE -> telemetry.power_metrics?.ch3_voltage ?: Float.NaN } /** Retrieves the appropriate current depending on `channelSelected`. */ private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { - PowerChannel.ONE -> telemetry.powerMetrics.ch1Current - PowerChannel.TWO -> telemetry.powerMetrics.ch2Current - PowerChannel.THREE -> telemetry.powerMetrics.ch3Current + PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN + PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN + PowerChannel.THREE -> telemetry.power_metrics?.ch3_current ?: Float.NaN } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index eb46e6b57..4597ff7cd 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -68,7 +68,7 @@ import org.meshtastic.core.ui.theme.GraphColors.Blue import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.proto.MeshProtos.MeshPacket +import org.meshtastic.proto.MeshPacket private enum class SignalMetric(val color: Color) { SNR(Green), @@ -93,7 +93,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat telemetryType = TelemetryType.LOCAL_STATS, titleRes = Res.string.signal_quality, data = data, - timeProvider = { it.rxTime.toDouble() }, + timeProvider = { (it.rx_time ?: 0).toDouble() }, infoData = listOf( InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color), @@ -113,8 +113,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat itemsIndexed(data) { _, meshPacket -> SignalMetricsCard( meshPacket = meshPacket, - isSelected = meshPacket.rxTime.toDouble() == selectedX, - onClick = { onCardClick(meshPacket.rxTime.toDouble()) }, + isSelected = (meshPacket.rx_time ?: 0).toDouble() == selectedX, + onClick = { onCardClick((meshPacket.rx_time ?: 0).toDouble()) }, ) } } @@ -140,12 +140,12 @@ private fun SignalMetricsChart( modelProducer.runTransaction { /* Use separate lineSeries calls to associate them with different vertical axes */ lineSeries { - val rssiData = meshPackets.filter { it.rxRssi != 0 && !it.rxRssi.toFloat().isNaN() } - series(x = rssiData.map { it.rxTime }, y = rssiData.map { it.rxRssi }) + val rssiData = meshPackets.filter { (it.rx_rssi ?: 0) != 0 } + series(x = rssiData.map { it.rx_time ?: 0 }, y = rssiData.map { it.rx_rssi ?: 0 }) } lineSeries { - val snrData = meshPackets.filter { !it.rxSnr.isNaN() } - series(x = snrData.map { it.rxTime }, y = snrData.map { it.rxSnr }) + val snrData = meshPackets.filter { !((it.rx_snr ?: Float.NaN).isNaN()) } + series(x = snrData.map { it.rx_time ?: 0 }, y = snrData.map { it.rx_snr ?: 0f }) } } } @@ -215,7 +215,7 @@ private fun SignalMetricsChart( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) { - val time = meshPacket.rxTime * MS_PER_SEC + val time = (meshPacket.rx_time ?: 0).toLong() * MS_PER_SEC Card( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, @@ -250,14 +250,14 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli MetricIndicator(SignalMetric.RSSI.color) Spacer(Modifier.width(4.dp)) Text( - text = "%.0f dBm".format(meshPacket.rxRssi.toFloat()), + text = "%.0f dBm".format((meshPacket.rx_rssi ?: 0).toFloat()), style = MaterialTheme.typography.labelLarge, ) Spacer(Modifier.width(12.dp)) MetricIndicator(SignalMetric.SNR.color) Spacer(Modifier.width(4.dp)) Text( - text = "%.1f dB".format(meshPacket.rxSnr), + text = "%.1f dB".format(meshPacket.rx_snr ?: 0f), style = MaterialTheme.typography.labelLarge, ) } @@ -266,7 +266,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli /* Signal Indicator */ Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) { - LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi) + LoraSignalIndicator(meshPacket.rx_snr ?: 0f, meshPacket.rx_rssi ?: 0) } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 3847654d5..515983b03 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -90,7 +90,8 @@ import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.component.CooldownIconButton import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Position +import org.meshtastic.proto.RouteDiscovery private data class TracerouteDialog( val message: AnnotatedString, @@ -122,7 +123,8 @@ fun TracerouteLogScreen( } } - fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } + fun getUsername(nodeNum: Int): String = + with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" } var showDialog by remember { mutableStateOf(null) } var errorMessageRes by remember { mutableStateOf(null) } @@ -142,7 +144,7 @@ fun TracerouteLogScreen( topBar = { val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState() MainAppBar( - title = state.node?.user?.longName ?: "", + title = state.node?.user?.long_name ?: "", subtitle = stringResource(Res.string.traceroute_log), ourNode = null, showNodeChip = false, @@ -169,9 +171,9 @@ fun TracerouteLogScreen( ) { items(state.tracerouteRequests, key = { it.uuid }) { log -> val result = - remember(state.tracerouteRequests, log.fromRadio.packet.id) { + remember(state.tracerouteRequests, log.fromRadio.packet?.id) { state.tracerouteResults.find { - it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id + it.fromRadio.packet?.decoded?.request_id == log.fromRadio.packet?.id } } val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery } @@ -187,12 +189,12 @@ fun TracerouteLogScreen( val tracerouteDetailsAnnotated: AnnotatedString? = result?.let { res -> - if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) { + if (route != null && route.route.isNotEmpty() && route.route_back.isNotEmpty()) { val seconds = (res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC val annotatedBase = annotateTraceroute( - res.fromRadio.packet.getTracerouteResponse( + res.fromRadio.packet?.getTracerouteResponse( ::getUsername, headerTowards = stringResource(Res.string.traceroute_route_towards_dest), headerBack = stringResource(Res.string.traceroute_route_back_to_us), @@ -206,7 +208,7 @@ fun TracerouteLogScreen( } else { // For cases where there's a result but no full route, display plain text res.fromRadio.packet - .getTracerouteResponse( + ?.getTracerouteResponse( ::getUsername, headerTowards = stringResource(Res.string.traceroute_route_towards_dest), headerBack = stringResource(Res.string.traceroute_route_back_to_us), @@ -217,9 +219,9 @@ fun TracerouteLogScreen( val overlay = route?.let { TracerouteOverlay( - requestId = log.fromRadio.packet.id, - forwardRoute = it.routeList, - returnRoute = it.routeBackList, + requestId = log.fromRadio.packet?.id ?: 0, + forwardRoute = it.route, + returnRoute = it.route_back, ) } @@ -246,7 +248,7 @@ fun TracerouteLogScreen( showDialog = TracerouteDialog( message = it, - requestId = log.fromRadio.packet.id, + requestId = log.fromRadio.packet?.id ?: 0, responseLogUuid = responseLogUuid, overlay = overlay, ) @@ -278,7 +280,7 @@ private fun TracerouteLogDialogs( dialog?.let { dialogState -> val snapshotPositionsFlow = remember(dialogState.responseLogUuid) { viewModel.tracerouteSnapshotPositions(dialogState.responseLogUuid) } - val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap()) + val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap()) SimpleAlertDialog( title = Res.string.traceroute, text = { SelectionContainer { Text(text = dialogState.message) } }, @@ -316,24 +318,24 @@ private fun TracerouteLogDialogs( /** Generates a display string and icon based on the route discovery information. */ @Composable -private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair = when { +private fun RouteDiscovery?.getTextAndIcon(): Pair = when { this == null -> { stringResource(Res.string.routing_error_no_response) to MeshtasticIcons.PersonOff } // A direct route means the sender and receiver are the only two nodes in the route. - routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust + route.size <= 2 && route_back.size <= 2 -> { // also check route_back size for direct to be more robust stringResource(Res.string.traceroute_direct) to MeshtasticIcons.Group } - routeCount == routeBackCount -> { - val hops = routeCount - 2 + route.size == route_back.size -> { + val hops = route.size - 2 pluralStringResource(Res.plurals.traceroute_hops, hops, hops) to MeshtasticIcons.Route } else -> { // Asymmetric route - val towards = maxOf(0, routeCount - 2) - val back = maxOf(0, routeBackCount - 2) + val towards = maxOf(0, route.size - 2) + val back = maxOf(0, route_back.size - 2) stringResource(Res.string.traceroute_diff, towards, back) to MeshtasticIcons.Route } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index eb35e1a20..792b478ac 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -54,7 +54,7 @@ import org.meshtastic.core.ui.icon.Route import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.feature.map.MapView import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.Position @Composable fun TracerouteMapScreen( @@ -66,27 +66,26 @@ fun TracerouteMapScreen( val state by metricsViewModel.state.collectAsStateWithLifecycle() val snapshotPositions by remember(logUuid) { - logUuid?.let(metricsViewModel::tracerouteSnapshotPositions) - ?: flowOf(emptyMap()) + logUuid?.let(metricsViewModel::tracerouteSnapshotPositions) ?: flowOf(emptyMap()) } - .collectAsStateWithLifecycle(emptyMap()) + .collectAsStateWithLifecycle(emptyMap()) val tracerouteResult = if (logUuid != null) { state.tracerouteResults.find { it.uuid == logUuid } } else { - state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == requestId } + state.tracerouteResults.find { it.fromRadio.packet?.decoded?.request_id == requestId } } val routeDiscovery = tracerouteResult?.fromRadio?.packet?.fullRouteDiscovery val overlayFromLogs = remember(routeDiscovery, requestId) { - routeDiscovery?.let { TracerouteOverlay(requestId, it.routeList, it.routeBackList) } + routeDiscovery?.let { TracerouteOverlay(requestId, it.route, it.route_back) } } val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) } val overlay = overlayFromLogs ?: overlayFromService LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() } TracerouteMapScaffold( - title = state.node?.user?.longName ?: stringResource(Res.string.traceroute), + title = state.node?.user?.long_name ?: stringResource(Res.string.traceroute), overlay = overlay, snapshotPositions = snapshotPositions, onNavigateUp = onNavigateUp, @@ -97,7 +96,7 @@ fun TracerouteMapScreen( private fun TracerouteMapScaffold( title: String, overlay: TracerouteOverlay?, - snapshotPositions: Map, + snapshotPositions: Map, onNavigateUp: () -> Unit, modifier: Modifier = Modifier, ) { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt index 589983537..14484e530 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.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,16 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.node.model import org.meshtastic.core.database.model.Node import org.meshtastic.core.database.model.isUnmessageableRole val Node.isEffectivelyUnmessageable: Boolean - get() = - if (user.hasIsUnmessagable()) { - user.isUnmessagable - } else { - user.role?.isUnmessageableRole() == true - } + get() = user.is_unmessagable ?: (user.role?.isUnmessageableRole() == true) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt index 460a43e6d..f0808e911 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -20,28 +20,29 @@ import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.Config +import org.meshtastic.proto.FirmwareEdition +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.Position +import org.meshtastic.proto.Telemetry data class MetricsState( val isLocal: Boolean = false, val isManaged: Boolean = true, val isFahrenheit: Boolean = false, - val displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits = - ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC, + val displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC, val node: Node? = null, - val deviceMetrics: List = emptyList(), - val signalMetrics: List = emptyList(), - val powerMetrics: List = emptyList(), - val hostMetrics: List = emptyList(), + val deviceMetrics: List = emptyList(), + val signalMetrics: List = emptyList(), + val powerMetrics: List = emptyList(), + val hostMetrics: List = emptyList(), val tracerouteRequests: List = emptyList(), val tracerouteResults: List = emptyList(), val neighborInfoRequests: List = emptyList(), val neighborInfoResults: List = emptyList(), - val positionLogs: List = emptyList(), + val positionLogs: List = emptyList(), val deviceHardware: DeviceHardware? = null, - val firmwareEdition: MeshProtos.FirmwareEdition? = null, + val firmwareEdition: FirmwareEdition? = null, val latestStableFirmware: FirmwareRelease = FirmwareRelease(), val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), val paxMetrics: List = emptyList(), diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt index 540e48190..e74440f91 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.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,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.node.model import org.meshtastic.core.database.model.Node import org.meshtastic.core.navigation.Route import org.meshtastic.core.service.ServiceAction import org.meshtastic.feature.node.component.NodeMenuAction -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits +import org.meshtastic.proto.Config sealed interface NodeDetailAction { data class Navigate(val route: Route) : NodeDetailAction @@ -33,5 +32,5 @@ sealed interface NodeDetailAction { data object ShareContact : NodeDetailAction // Opens the compass sheet scoped to a target node and the user’s preferred units. - data class OpenCompass(val node: Node, val displayUnits: DisplayUnits) : NodeDetailAction + data class OpenCompass(val node: Node, val displayUnits: Config.DisplayConfig.DisplayUnits) : NodeDetailAction } diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt index 6ae9f873f..a8134a255 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt +++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt @@ -19,8 +19,8 @@ package org.meshtastic.feature.node.metrics import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics -import org.meshtastic.proto.TelemetryProtos.Telemetry +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.Telemetry class EnvironmentMetricsStateTest { @@ -29,18 +29,9 @@ class EnvironmentMetricsStateTest { val now = (System.currentTimeMillis() / 1000).toInt() val metrics = listOf( - Telemetry.newBuilder() - .setTime(now - 100) - .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f)) - .build(), - Telemetry.newBuilder() - .setTime(now - 50) - .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(22f)) - .build(), - Telemetry.newBuilder() - .setTime(now) - .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f)) - .build(), + Telemetry(time = now - 100, environment_metrics = EnvironmentMetrics(temperature = 20f)), + Telemetry(time = now - 50, environment_metrics = EnvironmentMetrics(temperature = 22f)), + Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 21f)), ) val state = EnvironmentMetricsState(metrics) val result = state.environmentMetricsForGraphing() @@ -52,13 +43,7 @@ class EnvironmentMetricsStateTest { @Test fun `environmentMetricsForGraphing handles valid zero temperatures`() { val now = (System.currentTimeMillis() / 1000).toInt() - val metrics = - listOf( - Telemetry.newBuilder() - .setTime(now) - .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(0.0f)) - .build(), - ) + val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f))) val state = EnvironmentMetricsState(metrics) val result = state.environmentMetricsForGraphing() diff --git a/feature/settings/detekt-baseline.xml b/feature/settings/detekt-baseline.xml index 59df3d54a..07bfecca3 100644 --- a/feature/settings/detekt-baseline.xml +++ b/feature/settings/detekt-baseline.xml @@ -1,33 +1,48 @@ - + - CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - CyclomaticComplexMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) - LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) - LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) - LongMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) - LongMethod:UserConfigItemList.kt$@Composable fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + CyclomaticComplexMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + CyclomaticComplexMethod:EditDeviceProfileDialog.kt$@Suppress("LongMethod") @OptIn(ExperimentalLayoutApi::class) @Composable fun EditDeviceProfileDialog( title: String, deviceProfile: DeviceProfile, onConfirm: (DeviceProfile) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) + CyclomaticComplexMethod:ExternalNotificationConfigItemList.kt$@Suppress("LongMethod", "TooGenericExceptionCaught") @Composable fun ExternalNotificationConfigScreen( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: RadioConfigViewModel = hiltViewModel(), ) + CyclomaticComplexMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + CyclomaticComplexMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$fun installProfile(protobuf: DeviceProfile) + CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket) + CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) + CyclomaticComplexMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LargeClass:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModel + LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) + LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket) + LongMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) + LongMethod:UserConfigItemList.kt$@Composable fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) MagicNumber:Debug.kt$3 MagicNumber:EditChannelDialog.kt$16 MagicNumber:EditChannelDialog.kt$32 + MagicNumber:EditDeviceProfileDialog.kt$ProfileField.CHANNEL_URL$3 + MagicNumber:EditDeviceProfileDialog.kt$ProfileField.CONFIG$4 + MagicNumber:EditDeviceProfileDialog.kt$ProfileField.FIXED_POSITION$6 + MagicNumber:EditDeviceProfileDialog.kt$ProfileField.MODULE_CONFIG$5 MagicNumber:PacketResponseStateDialog.kt$100 - NestedBlockDepth:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) - ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket) + NestedBlockDepth:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket) + ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket) + TooGenericExceptionCaught:DebugViewModel.kt$DebugViewModel$e: Exception TooGenericExceptionCaught:LanguageUtils.kt$LanguageUtils$e: Exception TooGenericExceptionCaught:RadioConfigViewModel.kt$RadioConfigViewModel$ex: Exception TooManyFunctions:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModel + UnusedPrivateMember:RadioConfigViewModel.kt$RadioConfigViewModel$private fun setChannels(channelUrl: String) + UnusedPrivateProperty:SettingsViewModel.kt$SettingsViewModel$val capabilities = Capabilities(node.metadata?.firmware_version) diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt index 518e15c3b..92910ed04 100644 --- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt +++ b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry import com.meshtastic.core.strings.getString import org.junit.Assert import org.junit.Rule @@ -30,28 +29,22 @@ import org.junit.runner.RunWith import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.cancel import org.meshtastic.core.strings.save -import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile -import org.meshtastic.proto.deviceProfile -import org.meshtastic.proto.position +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.Position @RunWith(AndroidJUnit4::class) class EditDeviceProfileDialogTest { @get:Rule val composeTestRule = createComposeRule() - private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id) - private val title = "Export configuration" - private val deviceProfile = deviceProfile { - longName = "Long name" - shortName = "Short name" - channelUrl = "https://meshtastic.org/e/#CgMSAQESBggBQANIAQ" - fixedPosition = position { - latitudeI = 327766650 - longitudeI = -967969890 - altitude = 138 - } - } + private val deviceProfile = + DeviceProfile( + long_name = "Long name", + short_name = "Short name", + channel_url = "https://meshtastic.org/e/#CgMSAQESBggBQANIAQ", + fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138), + ) private fun testEditDeviceProfileDialog(onDismiss: () -> Unit = {}, onConfirm: (DeviceProfile) -> Unit = {}) = composeTestRule.setContent { diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 256e37fbd..35e93f25b 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -38,8 +38,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.rounded.AppSettingsAlt -import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.FormatPaint import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Language @@ -56,7 +54,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -119,7 +116,7 @@ import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog import org.meshtastic.feature.settings.util.LanguageUtils import org.meshtastic.feature.settings.util.LanguageUtils.languageMap -import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile +import org.meshtastic.proto.DeviceProfile import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -139,7 +136,7 @@ fun SettingsScreen( val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false) val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle() - val destNode by viewModel.destNode.collectAsState() + val destNode by viewModel.destNode.collectAsStateWithLifecycle() val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var isWaiting by remember { mutableStateOf(false) } if (isWaiting) { @@ -192,7 +189,7 @@ fun SettingsScreen( viewModel.installProfile(it) } else { deviceProfile = it - val nodeName = it.shortName.ifBlank { "node" } + val nodeName = (it.short_name ?: "").ifBlank { "node" } val dateFormat = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.getDefault()) val dateStr = dateFormat.format(java.util.Date()) val fileName = "Meshtastic_${nodeName}_${dateStr}_nodeConfig.cfg" @@ -231,9 +228,9 @@ fun SettingsScreen( title = stringResource(Res.string.bottom_nav_settings), subtitle = if (state.isLocal) { - ourNode?.user?.longName + ourNode?.user?.long_name } else { - val remoteName = destNode?.user?.longName ?: "" + val remoteName = destNode?.user?.long_name ?: "" stringResource(Res.string.remotely_administrating, remoteName) }, ourNode = ourNode, @@ -248,7 +245,7 @@ fun SettingsScreen( Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) { RadioConfigItemList( state = state, - isManaged = localConfig.security.isManaged, + isManaged = localConfig.security?.is_managed ?: false, node = destNode, excludedModulesUnlocked = excludedModulesUnlocked, isOtaCapable = isOtaCapable, @@ -277,180 +274,169 @@ fun SettingsScreen( val context = LocalContext.current - if (state.isLocal) { - TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) { - if (state.analyticsAvailable) { - val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false) - SwitchListItem( - text = stringResource(Res.string.analytics_okay), - checked = allowed, - leadingIcon = Icons.Rounded.BugReport, - onClick = { viewModel.toggleAnalyticsAllowed() }, - ) - } - - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - val isGpsDisabled = context.gpsDisabled() - val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle() - - LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) { - if (provideLocation) { - if (locationPermissionsState.allPermissionsGranted) { - if (!isGpsDisabled) { - settingsViewModel.meshService?.startProvideLocation() - } else { - context.showToast(Res.string.location_disabled) - } - } else { - // Request permissions if not granted and user wants to provide location - locationPermissionsState.launchMultiplePermissionRequest() - } - } else { - settingsViewModel.meshService?.stopProvideLocation() - } - } - + TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) { + if (state.analyticsAvailable) { + val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false) SwitchListItem( - text = stringResource(Res.string.provide_location_to_mesh), - leadingIcon = Icons.Rounded.LocationOn, - enabled = !isGpsDisabled, - checked = provideLocation, - onClick = { settingsViewModel.setProvideLocation(!provideLocation) }, + text = stringResource(Res.string.analytics_okay), + checked = allowed, + leadingIcon = Icons.Default.BugReport, + onClick = { viewModel.toggleAnalyticsAllowed() }, ) + } - val settingsLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) {} + val locationPermissionsState = + rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) + val isGpsDisabled = context.gpsDisabled() + val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle() - // On Android 12 and below, system app settings for language are not available. Use the in-app - // language - // picker for these devices. - val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU - ListItem( - text = stringResource(Res.string.preferences_language), - leadingIcon = Icons.Rounded.Language, - trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight, - ) { - if (useInAppLangPicker) { - showLanguagePickerDialog = true - } else { - val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri()) - if (intent.resolveActivity(context.packageManager) != null) { - settingsLauncher.launch(intent) + LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) { + if (provideLocation) { + if (locationPermissionsState.allPermissionsGranted) { + if (!isGpsDisabled) { + settingsViewModel.meshService?.startProvideLocation() } else { - // Fall back to the in-app picker - showLanguagePickerDialog = true + context.showToast(Res.string.location_disabled) } + } else { + // Request permissions if not granted and user wants to provide location + locationPermissionsState.launchMultiplePermissionRequest() + } + } else { + settingsViewModel.meshService?.stopProvideLocation() + } + } + + SwitchListItem( + text = stringResource(Res.string.provide_location_to_mesh), + leadingIcon = Icons.Rounded.LocationOn, + enabled = !isGpsDisabled, + checked = provideLocation, + onClick = { settingsViewModel.setProvideLocation(!provideLocation) }, + ) + + val settingsLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} + + // On Android 12 and below, system app settings for language are not available. Use the in-app language + // picker for these devices. + val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + ListItem( + text = stringResource(Res.string.preferences_language), + leadingIcon = Icons.Rounded.Language, + trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight, + ) { + if (useInAppLangPicker) { + showLanguagePickerDialog = true + } else { + val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri()) + if (intent.resolveActivity(context.packageManager) != null) { + settingsLauncher.launch(intent) + } else { + // Fall back to the in-app picker + showLanguagePickerDialog = true } } + } - ListItem( - text = stringResource(Res.string.theme), - leadingIcon = Icons.Rounded.FormatPaint, - trailingIcon = null, - ) { - showThemePickerDialog = true + ListItem( + text = stringResource(Res.string.theme), + leadingIcon = Icons.Rounded.FormatPaint, + trailingIcon = null, + ) { + showThemePickerDialog = true + } + + // Node DB cache limit (App setting) + val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value + val cacheItems = remember { + (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { + it.toLong() to it.toString() } + } + DropDownPreference( + title = stringResource(Res.string.device_db_cache_limit), + enabled = true, + items = cacheItems, + selectedItem = cacheLimit.toLong(), + onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) }, + summary = stringResource(Res.string.device_db_cache_limit_summary), + ) - ListItem( - text = "Message Filter", - leadingIcon = Icons.Rounded.FilterList, - trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - ) { - onNavigate(SettingsRoutes.FilterSettings) - } // Node DB cache limit (App setting) - val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value - val cacheItems = remember { - (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { - it.toLong() to it.toString() + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val nodeName = ourNode?.user?.short_name ?: "" + + val exportRangeTestLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) } } } - DropDownPreference( - title = stringResource(Res.string.device_db_cache_limit), - enabled = true, - items = cacheItems, - selectedItem = cacheLimit.toLong(), - onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) }, - summary = stringResource(Res.string.device_db_cache_limit_summary), - ) - - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val nodeName = ourNode?.user?.shortName ?: "" - - val exportRangeTestLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) } - } + ListItem( + text = stringResource(Res.string.save_rangetest), + leadingIcon = Icons.Rounded.Output, + trailingIcon = null, + ) { + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/csv" + putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv") } - ListItem( - text = stringResource(Res.string.save_rangetest), - leadingIcon = Icons.Rounded.Output, - trailingIcon = null, - ) { - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/csv" - putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv") - } - exportRangeTestLauncher.launch(intent) - } + exportRangeTestLauncher.launch(intent) + } - val exportDataLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) } - } + val exportDataLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) } } - ListItem( - text = stringResource(Res.string.export_data_csv), - leadingIcon = Icons.Rounded.Output, - trailingIcon = null, - ) { - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/csv" - putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv") - } - exportDataLauncher.launch(intent) } + ListItem( + text = stringResource(Res.string.export_data_csv), + leadingIcon = Icons.Rounded.Output, + trailingIcon = null, + ) { + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/csv" + putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv") + } + exportDataLauncher.launch(intent) + } - ListItem( - text = stringResource(Res.string.intro_show), - leadingIcon = Icons.Rounded.WavingHand, - trailingIcon = null, - ) { - settingsViewModel.showAppIntro() - } + ListItem( + text = stringResource(Res.string.intro_show), + leadingIcon = Icons.Rounded.WavingHand, + trailingIcon = null, + ) { + settingsViewModel.showAppIntro() + } - ListItem( - text = stringResource(Res.string.system_settings), - leadingIcon = Icons.Rounded.AppSettingsAlt, - trailingIcon = null, - ) { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.fromParts("package", context.packageName, null) - settingsLauncher.launch(intent) - } + ListItem( + text = stringResource(Res.string.system_settings), + leadingIcon = Icons.Rounded.AppSettingsAlt, + trailingIcon = null, + ) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.fromParts("package", context.packageName, null) + settingsLauncher.launch(intent) + } - ListItem( - text = stringResource(Res.string.acknowledgements), - leadingIcon = Icons.Rounded.Info, - trailingIcon = null, - ) { - onNavigate(SettingsRoutes.About) - } + ListItem( + text = stringResource(Res.string.acknowledgements), + leadingIcon = Icons.Rounded.Info, + trailingIcon = null, + ) { + onNavigate(SettingsRoutes.About) + } - AppVersionButton( - excludedModulesUnlocked = excludedModulesUnlocked, - appVersionName = settingsViewModel.appVersionName, - ) { - settingsViewModel.unlockExcludedModules() - } + AppVersionButton( + excludedModulesUnlocked = excludedModulesUnlocked, + appVersionName = settingsViewModel.appVersionName, + ) { + settingsViewModel.unlockExcludedModules() } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 225c8d1c3..9ed773068 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -58,15 +58,15 @@ import org.meshtastic.core.prefs.ui.UiPrefs import org.meshtastic.core.service.IMeshService import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.LocalOnlyProtos.LocalConfig -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.Portnums +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.PortNum import java.io.BufferedWriter import java.io.FileNotFoundException import java.io.FileWriter import java.util.Locale import javax.inject.Inject import kotlin.math.roundToInt +import org.meshtastic.proto.Position as ProtoPosition @Suppress("LongParameterList") @HiltViewModel @@ -97,7 +97,7 @@ constructor( serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false) val localConfig: StateFlow = - radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance()) + radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) val meshService: IMeshService? get() = serviceRepository.meshService @@ -126,20 +126,17 @@ constructor( if (node == null || !connectionState.isConnected()) { flowOf(false) } else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) { - val hwModel = node.user.hwModel.number + val hwModel = node.user.hw_model.value val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull() - val capabilities = Capabilities(node.metadata?.firmwareVersion) - val isSerial = radioPrefs.isSerial() + // Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta) + val capabilities = Capabilities(node.metadata?.firmware_version) // ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial. - val isEsp32OtaSupported = hw?.isEsp32Arc == true && capabilities.supportsEsp32Ota && !isSerial + // TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware + val isEsp32OtaSupported = false + // hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial() - // Nordic DFU/USB update is supported for NRF52/RP2040. - // For ESP32, we do NOT support Serial updates from the app yet, even if requiresDfu is true - // (which might be set for S3 native USB, but is currently unused by our handlers). - val isDfuSupported = hw?.requiresDfu == true && hw.isEsp32Arc != true - - flow { emit(isDfuSupported || isEsp32OtaSupported) } + flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) } } else { flowOf(false) } @@ -214,14 +211,14 @@ constructor( // Capture the current node value while we're still on main thread val nodes = nodeRepository.nodeDBbyNum.value - // Converts a MeshProtos.Position (nullable) to a Position, but only if it's valid, otherwise returns null. + // Converts a ProtoPosition (nullable) to a Position, but only if it's valid, otherwise returns null. // The returned Position is guaranteed to be non-null and valid, or null if the input was null or invalid. - val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition -> + val positionToPos: (ProtoPosition?) -> Position? = { meshPosition -> meshPosition?.let { Position(it) }?.takeIf { it.isValid() } } writeToUri(uri) { writer -> - val nodePositions = mutableMapOf() + val nodePositions = mutableMapOf() @Suppress("MaxLineLength") writer.appendLine( @@ -249,12 +246,12 @@ constructor( // packets must have rxSNR, and optionally match the filter given as a param. if ( - (filterPortnum == null || proto.decoded.portnumValue == filterPortnum) && - proto.rxSnr != 0.0f + (filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) && + (proto.rx_snr ?: 0f) != 0.0f ) { val rxDateTime = dateFormat.format(packet.received_date) val rxFrom = proto.from.toUInt() - val senderName = nodes[proto.from]?.user?.longName ?: "" + val senderName = nodes[proto.from]?.user?.long_name ?: "" // sender lat & long val senderPosition = nodePositions[proto.from] @@ -268,7 +265,7 @@ constructor( val rxLat = rxPos?.latitude ?: "" val rxLong = rxPos?.longitude ?: "" val rxAlt = rxPos?.altitude ?: "" - val rxSnr = proto.rxSnr + val rxSnr = proto.rx_snr // Calculate the distance if both positions are valid @@ -286,19 +283,19 @@ constructor( .toString() } - val hopLimit = proto.hopLimit + val hopLimit = proto.hop_limit ?: 0 + val decoded = proto.decoded + val encrypted = proto.encrypted val payload = when { - proto.decoded.portnumValue !in - setOf( - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - Portnums.PortNum.RANGE_TEST_APP_VALUE, - ) -> "<${proto.decoded.portnum}>" + (decoded?.portnum?.value ?: 0) !in + setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) -> + "<${decoded?.portnum}>" - proto.hasDecoded() -> proto.decoded.payload.toStringUtf8().replace("\"", "\"\"") + decoded != null -> decoded.payload.utf8().replace("\"", "\"\"") - proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes" + encrypted != null -> "${encrypted.size} encrypted bytes" else -> "" } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index f563dd42a..08ed36065 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger -import com.google.protobuf.InvalidProtocolBufferException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -39,14 +38,23 @@ import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.getTracerouteResponse +import org.meshtastic.core.model.util.decodeOrNull +import org.meshtastic.core.model.util.toReadableString import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.PaxcountProtos -import org.meshtastic.proto.Portnums.PortNum -import org.meshtastic.proto.StoreAndForwardProtos -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.RouteDiscovery +import org.meshtastic.proto.Routing +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import org.meshtastic.proto.Waypoint import java.text.DateFormat import java.util.Date import java.util.Locale @@ -277,7 +285,7 @@ constructor( combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> searchManager.findSearchMatches(searchText, logs) } - .collect { matches -> + .collect { searchManager.updateMatches(searchManager.searchText.value, filterManager.filteredLogs.value) } } @@ -302,32 +310,30 @@ constructor( /** Transform the input [MeshLog] by enhancing the raw message with annotations. */ private fun annotateMeshLogMessage(meshLog: MeshLog): String = when (meshLog.message_type) { - "LogRecord" -> meshLog.fromRadio.logRecord.toString().replace("\\n\"", "\"") + "LogRecord" -> meshLog.fromRadio.log_record.toString().replace("\\n\"", "\"") "Packet" -> meshLog.meshPacket?.let { packet -> annotatePacketLog(packet) } ?: meshLog.raw_message "NodeInfo" -> meshLog.nodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.num) } ?: meshLog.raw_message "MyNodeInfo" -> - meshLog.myNodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) } + meshLog.myNodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.my_node_num) } ?: meshLog.raw_message else -> meshLog.raw_message } - private fun annotatePacketLog(packet: MeshProtos.MeshPacket): String { - val builder = packet.toBuilder() - val hasDecoded = builder.hasDecoded() - val decoded = if (hasDecoded) builder.decoded else null - if (hasDecoded) builder.clearDecoded() - val baseText = builder.build().toString().trimEnd() + private fun annotatePacketLog(packet: MeshPacket): String { + val decoded = packet.decoded + val basePacket = packet.copy(decoded = null) + val baseText = basePacket.toString().trimEnd() var result = - if (hasDecoded && decoded != null) { + if (decoded != null) { val decodedText = decoded.toString().trimEnd().prependIndent(" ") "$baseText\ndecoded {\n$decodedText\n}" } else { baseText } - val relayNode = packet.relayNode + val relayNode = packet.relay_node ?: 0 var relayNodeAnnotation: String? = null val placeholder = "___RELAY_NODE___" @@ -337,7 +343,7 @@ constructor( Packet.getRelayNode(relayNode, nodeList, myNodeNum)?.let { node -> val relayId = node.user.id - val relayName = node.user.longName + val relayName = node.user.long_name val regex = Regex("""\brelay_node: ${relayNode.toUInt()}\b""") if (regex.containsMatchIn(result)) { relayNodeAnnotation = "relay_node: $relayName ($relayId)" @@ -364,7 +370,7 @@ constructor( var mutated = false nodeIds.toSet().forEach { nodeId -> mutated = mutated or msg.annotateNodeId(nodeId) } return if (mutated) { - return msg.toString() + msg.toString() } else { rawMessage } @@ -375,12 +381,10 @@ constructor( val nodeIdStr = nodeId.toUInt().toString() // Only match if whitespace before and after val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""") - regex.find(this)?.let { matchResult -> - matchResult.groupValues.let { _ -> - regex.findAll(this).toList().asReversed().forEach { match -> - val idx = match.range.last + 1 - insert(idx, " (${nodeId.asNodeId()})") - } + regex.find(this)?.let { _ -> + regex.findAll(this).toList().asReversed().forEach { match -> + val idx = match.range.last + 1 + insert(idx, " (${nodeId.asNodeId()})") } return true } @@ -434,70 +438,82 @@ constructor( * @return A human-readable string representation of the decoded payload, or an error message if decoding fails, or * null if the log does not contain a decodable packet. */ - @Suppress("detekt:CyclomaticComplexMethod") // large switch that detekt doesn't parse well. + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") private fun decodePayloadFromMeshLog(log: MeshLog): String? { - var result: String? = null val packet = log.meshPacket - if (packet == null || !packet.hasDecoded()) { - result = null - } else { - val portnum = packet.decoded.portnumValue - val payload = packet.decoded.payload.toByteArray() - result = - try { - when (portnum) { - PortNum.TEXT_MESSAGE_APP_VALUE, - PortNum.ALERT_APP_VALUE, - -> payload.toString(Charsets.UTF_8) - PortNum.POSITION_APP_VALUE -> MeshProtos.Position.parseFrom(payload).toString() - PortNum.WAYPOINT_APP_VALUE -> MeshProtos.Waypoint.parseFrom(payload).toString() - PortNum.NODEINFO_APP_VALUE -> MeshProtos.User.parseFrom(payload).toString() - PortNum.TELEMETRY_APP_VALUE -> TelemetryProtos.Telemetry.parseFrom(payload).toString() - PortNum.ROUTING_APP_VALUE -> MeshProtos.Routing.parseFrom(payload).toString() - PortNum.ADMIN_APP_VALUE -> AdminProtos.AdminMessage.parseFrom(payload).toString() - PortNum.PAXCOUNTER_APP_VALUE -> PaxcountProtos.Paxcount.parseFrom(payload).toString() - PortNum.STORE_FORWARD_APP_VALUE -> - StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString() - PortNum.STORE_FORWARD_PLUSPLUS_APP_VALUE -> - MeshProtos.StoreForwardPlusPlus.parseFrom(payload).toString() - PortNum.NEIGHBORINFO_APP_VALUE -> decodeNeighborInfo(payload) - PortNum.TRACEROUTE_APP_VALUE -> decodeTraceroute(packet, payload) - else -> payload.joinToString(" ") { HEX_FORMAT.format(it) } - } - } catch (e: InvalidProtocolBufferException) { - "Failed to decode payload: ${e.message}" - } + val decoded = packet?.decoded ?: return null + + val portnumValue = decoded.portnum.value + val payload = decoded.payload.toByteArray() + return try { + when (portnumValue) { + PortNum.TEXT_MESSAGE_APP.value, + PortNum.ALERT_APP.value, + -> payload.toString(Charsets.UTF_8) + PortNum.POSITION_APP.value -> + Position.ADAPTER.decodeOrNull(payload)?.let { Position.ADAPTER.toReadableString(it) } + ?: "Failed to decode Position" + PortNum.WAYPOINT_APP.value -> + Waypoint.ADAPTER.decodeOrNull(payload)?.let { Waypoint.ADAPTER.toReadableString(it) } + ?: "Failed to decode Waypoint" + PortNum.NODEINFO_APP.value -> + User.ADAPTER.decodeOrNull(payload)?.let { User.ADAPTER.toReadableString(it) } + ?: "Failed to decode User" + PortNum.TELEMETRY_APP.value -> + Telemetry.ADAPTER.decodeOrNull(payload)?.let { Telemetry.ADAPTER.toReadableString(it) } + ?: "Failed to decode Telemetry" + PortNum.ROUTING_APP.value -> + Routing.ADAPTER.decodeOrNull(payload)?.let { Routing.ADAPTER.toReadableString(it) } + ?: "Failed to decode Routing" + PortNum.ADMIN_APP.value -> + AdminMessage.ADAPTER.decodeOrNull(payload)?.let { AdminMessage.ADAPTER.toReadableString(it) } + ?: "Failed to decode AdminMessage" + PortNum.PAXCOUNTER_APP.value -> + Paxcount.ADAPTER.decodeOrNull(payload)?.let { Paxcount.ADAPTER.toReadableString(it) } + ?: "Failed to decode Paxcount" + PortNum.STORE_FORWARD_APP.value -> + StoreAndForward.ADAPTER.decodeOrNull(payload)?.let { StoreAndForward.ADAPTER.toReadableString(it) } + ?: "Failed to decode StoreAndForward" + PortNum.STORE_FORWARD_PLUSPLUS_APP.value -> + StoreForwardPlusPlus.ADAPTER.decodeOrNull(payload)?.let { + StoreForwardPlusPlus.ADAPTER.toReadableString(it) + } ?: "Failed to decode StoreForwardPlusPlus" + PortNum.NEIGHBORINFO_APP.value -> decodeNeighborInfo(payload) + PortNum.TRACEROUTE_APP.value -> decodeTraceroute(packet, payload) + else -> payload.joinToString(" ") { HEX_FORMAT.format(it) } + } + } catch (e: Exception) { + "Failed to decode payload: ${e.message}" } - return result } private fun formatNodeWithShortName(nodeNum: Int): String { val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user - val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: "" + val shortName = user?.short_name?.takeIf { it.isNotEmpty() } ?: "" val nodeId = "!%08x".format(nodeNum) return if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId } private fun decodeNeighborInfo(payload: ByteArray): String { - val info = MeshProtos.NeighborInfo.parseFrom(payload) + val info = NeighborInfo.ADAPTER.decode(payload) return buildString { appendLine("NeighborInfo:") - appendLine(" node_id: ${formatNodeWithShortName(info.nodeId)}") - appendLine(" last_sent_by_id: ${formatNodeWithShortName(info.lastSentById)}") - appendLine(" node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}") - if (info.neighborsCount > 0) { + appendLine(" node_id: ${formatNodeWithShortName(info.node_id ?: 0)}") + appendLine(" last_sent_by_id: ${formatNodeWithShortName(info.last_sent_by_id ?: 0)}") + appendLine(" node_broadcast_interval_secs: ${info.node_broadcast_interval_secs}") + if (info.neighbors.isNotEmpty()) { appendLine(" neighbors:") - info.neighborsList.forEach { n -> - appendLine(" - node_id: ${formatNodeWithShortName(n.nodeId)} snr: ${n.snr}") + info.neighbors.forEach { n -> + appendLine(" - node_id: ${formatNodeWithShortName(n.node_id ?: 0)} snr: ${n.snr}") } } } } - private fun decodeTraceroute(packet: MeshProtos.MeshPacket, payload: ByteArray): String { + private fun decodeTraceroute(packet: MeshPacket, payload: ByteArray): String { val getUsername: (Int) -> String = { nodeNum -> formatNodeWithShortName(nodeNum) } return packet.getTracerouteResponse(getUsername) - ?: runCatching { MeshProtos.RouteDiscovery.parseFrom(payload).toString() }.getOrNull() + ?: runCatching { RouteDiscovery.ADAPTER.decode(payload).toString() }.getOrNull() ?: payload.joinToString(" ") { HEX_FORMAT.format(it) } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt index dcaa0055e..1ad0a5bfe 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt @@ -18,15 +18,15 @@ package org.meshtastic.feature.settings.navigation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.CellTower -import androidx.compose.material.icons.rounded.DisplaySettings -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.Power -import androidx.compose.material.icons.rounded.Router -import androidx.compose.material.icons.rounded.Security -import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.CellTower +import androidx.compose.material.icons.filled.DisplaySettings +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Power +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Wifi import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.navigation.Route @@ -42,59 +42,44 @@ import org.meshtastic.core.strings.position import org.meshtastic.core.strings.power import org.meshtastic.core.strings.security import org.meshtastic.core.strings.user -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.MeshProtos.DeviceMetadata +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.DeviceMetadata enum class ConfigRoute(val title: StringResource, val route: Route, val icon: ImageVector?, val type: Int = 0) { - USER(Res.string.user, SettingsRoutes.User, Icons.Rounded.Person, 0), + USER(Res.string.user, SettingsRoutes.User, Icons.Default.Person, 0), CHANNELS(Res.string.channels, SettingsRoutes.ChannelConfig, Icons.AutoMirrored.Default.List, 0), - DEVICE( - Res.string.device, - SettingsRoutes.Device, - Icons.Rounded.Router, - AdminProtos.AdminMessage.ConfigType.DEVICE_CONFIG_VALUE, - ), + DEVICE(Res.string.device, SettingsRoutes.Device, Icons.Default.Router, AdminMessage.ConfigType.DEVICE_CONFIG.value), POSITION( Res.string.position, SettingsRoutes.Position, - Icons.Rounded.LocationOn, - AdminProtos.AdminMessage.ConfigType.POSITION_CONFIG_VALUE, - ), - POWER( - Res.string.power, - SettingsRoutes.Power, - Icons.Rounded.Power, - AdminProtos.AdminMessage.ConfigType.POWER_CONFIG_VALUE, + Icons.Default.LocationOn, + AdminMessage.ConfigType.POSITION_CONFIG.value, ), + POWER(Res.string.power, SettingsRoutes.Power, Icons.Default.Power, AdminMessage.ConfigType.POWER_CONFIG.value), NETWORK( Res.string.network, SettingsRoutes.Network, - Icons.Rounded.Wifi, - AdminProtos.AdminMessage.ConfigType.NETWORK_CONFIG_VALUE, + Icons.Default.Wifi, + AdminMessage.ConfigType.NETWORK_CONFIG.value, ), DISPLAY( Res.string.display, SettingsRoutes.Display, - Icons.Rounded.DisplaySettings, - AdminProtos.AdminMessage.ConfigType.DISPLAY_CONFIG_VALUE, - ), - LORA( - Res.string.lora, - SettingsRoutes.LoRa, - Icons.Rounded.CellTower, - AdminProtos.AdminMessage.ConfigType.LORA_CONFIG_VALUE, + Icons.Default.DisplaySettings, + AdminMessage.ConfigType.DISPLAY_CONFIG.value, ), + LORA(Res.string.lora, SettingsRoutes.LoRa, Icons.Default.CellTower, AdminMessage.ConfigType.LORA_CONFIG.value), BLUETOOTH( Res.string.bluetooth, SettingsRoutes.Bluetooth, - Icons.Rounded.Bluetooth, - AdminProtos.AdminMessage.ConfigType.BLUETOOTH_CONFIG_VALUE, + Icons.Default.Bluetooth, + AdminMessage.ConfigType.BLUETOOTH_CONFIG.value, ), SECURITY( Res.string.security, SettingsRoutes.Security, - Icons.Rounded.Security, - AdminProtos.AdminMessage.ConfigType.SECURITY_CONFIG_VALUE, + Icons.Default.Security, + AdminMessage.ConfigType.SECURITY_CONFIG.value, ), ; @@ -102,8 +87,8 @@ enum class ConfigRoute(val title: StringResource, val route: Route, val icon: Im private fun filterExcludedFrom(metadata: DeviceMetadata?): List = entries.filter { when { metadata == null -> true // Include all routes if metadata is null - it == BLUETOOTH -> metadata.hasBluetooth - it == NETWORK -> metadata.hasWifi || metadata.hasEthernet + it == BLUETOOTH -> metadata.hasBluetooth == true + it == NETWORK -> metadata.hasWifi == true || metadata.hasEthernet == true else -> true // Include all other routes by default } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt index 981fc75de..6c7b2bedb 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt @@ -50,8 +50,8 @@ import org.meshtastic.core.strings.serial import org.meshtastic.core.strings.status_message import org.meshtastic.core.strings.store_forward import org.meshtastic.core.strings.telemetry -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.MeshProtos.DeviceMetadata +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.DeviceMetadata enum class ModuleRoute( val title: StringResource, @@ -60,89 +60,84 @@ enum class ModuleRoute( val type: Int = 0, val isSupported: (Capabilities) -> Boolean = { true }, ) { - MQTT( - Res.string.mqtt, - SettingsRoutes.MQTT, - Icons.Rounded.Cloud, - AdminProtos.AdminMessage.ModuleConfigType.MQTT_CONFIG_VALUE, - ), + MQTT(Res.string.mqtt, SettingsRoutes.MQTT, Icons.Rounded.Cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value), SERIAL( Res.string.serial, SettingsRoutes.Serial, Icons.Rounded.Usb, - AdminProtos.AdminMessage.ModuleConfigType.SERIAL_CONFIG_VALUE, + AdminMessage.ModuleConfigType.SERIAL_CONFIG.value, ), EXT_NOTIFICATION( Res.string.external_notification, SettingsRoutes.ExtNotification, Icons.Rounded.Notifications, - AdminProtos.AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG_VALUE, + AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG.value, ), STORE_FORWARD( Res.string.store_forward, SettingsRoutes.StoreForward, Icons.AutoMirrored.Default.Forward, - AdminProtos.AdminMessage.ModuleConfigType.STOREFORWARD_CONFIG_VALUE, + AdminMessage.ModuleConfigType.STOREFORWARD_CONFIG.value, ), RANGE_TEST( Res.string.range_test, SettingsRoutes.RangeTest, Icons.Rounded.Speed, - AdminProtos.AdminMessage.ModuleConfigType.RANGETEST_CONFIG_VALUE, + AdminMessage.ModuleConfigType.RANGETEST_CONFIG.value, ), TELEMETRY( Res.string.telemetry, SettingsRoutes.Telemetry, Icons.Rounded.DataUsage, - AdminProtos.AdminMessage.ModuleConfigType.TELEMETRY_CONFIG_VALUE, + AdminMessage.ModuleConfigType.TELEMETRY_CONFIG.value, ), CANNED_MESSAGE( Res.string.canned_message, SettingsRoutes.CannedMessage, Icons.AutoMirrored.Default.Message, - AdminProtos.AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG_VALUE, + AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG.value, ), AUDIO( Res.string.audio, SettingsRoutes.Audio, Icons.AutoMirrored.Default.VolumeUp, - AdminProtos.AdminMessage.ModuleConfigType.AUDIO_CONFIG_VALUE, + AdminMessage.ModuleConfigType.AUDIO_CONFIG.value, ), REMOTE_HARDWARE( Res.string.remote_hardware, SettingsRoutes.RemoteHardware, Icons.Rounded.SettingsRemote, - AdminProtos.AdminMessage.ModuleConfigType.REMOTEHARDWARE_CONFIG_VALUE, + AdminMessage.ModuleConfigType.REMOTEHARDWARE_CONFIG.value, ), NEIGHBOR_INFO( Res.string.neighbor_info, SettingsRoutes.NeighborInfo, Icons.Rounded.People, - AdminProtos.AdminMessage.ModuleConfigType.NEIGHBORINFO_CONFIG_VALUE, + AdminMessage.ModuleConfigType.NEIGHBORINFO_CONFIG.value, ), AMBIENT_LIGHTING( Res.string.ambient_lighting, SettingsRoutes.AmbientLighting, Icons.Rounded.LightMode, - AdminProtos.AdminMessage.ModuleConfigType.AMBIENTLIGHTING_CONFIG_VALUE, + AdminMessage.ModuleConfigType.AMBIENTLIGHTING_CONFIG.value, ), DETECTION_SENSOR( Res.string.detection_sensor, SettingsRoutes.DetectionSensor, Icons.Rounded.Sensors, - AdminProtos.AdminMessage.ModuleConfigType.DETECTIONSENSOR_CONFIG_VALUE, + AdminMessage.ModuleConfigType.DETECTIONSENSOR_CONFIG.value, ), PAXCOUNTER( Res.string.paxcounter, SettingsRoutes.Paxcounter, Icons.Rounded.PermScanWifi, - AdminProtos.AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG_VALUE, + AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG.value, ), STATUS_MESSAGE( Res.string.status_message, SettingsRoutes.StatusMessage, Icons.AutoMirrored.Default.Message, - AdminProtos.AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG_VALUE, + AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG.value, isSupported = { it.supportsStatusMessage }, ), ; @@ -152,9 +147,10 @@ enum class ModuleRoute( companion object { fun filterExcludedFrom(metadata: DeviceMetadata?): List { - val capabilities = Capabilities(metadata?.firmwareVersion) + val capabilities = Capabilities(metadata?.firmware_version) return entries.filter { - val isExcluded = metadata != null && (metadata.excludedModules and it.bitfield != 0) + val excludedModules = metadata?.excluded_modules ?: 0 + val isExcluded = (excludedModules and it.bitfield) != 0 !isExcluded && it.isSupported(capabilities) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index c676fd489..cd5ac5a51 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -31,8 +31,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import co.touchlab.kermit.Logger -import com.google.protobuf.MessageLite -import com.meshtastic.core.strings.getString import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -65,24 +63,24 @@ import org.meshtastic.core.service.IMeshService import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.cant_shutdown -import org.meshtastic.core.strings.fetching_channel_indexed -import org.meshtastic.core.strings.fetching_config import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.feature.settings.util.UiText -import org.meshtastic.proto.AdminProtos -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.ConfigProtos.Config.SecurityConfig -import org.meshtastic.proto.ConnStatusProtos -import org.meshtastic.proto.MeshProtos -import org.meshtastic.proto.ModuleConfigProtos -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.config -import org.meshtastic.proto.deviceProfile -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.User import java.io.FileOutputStream import javax.inject.Inject @@ -91,14 +89,14 @@ data class RadioConfigState( val isLocal: Boolean = false, val connected: Boolean = false, val route: String = "", - val metadata: MeshProtos.DeviceMetadata? = null, - val userConfig: MeshProtos.User = MeshProtos.User.getDefaultInstance(), - val channelList: List = emptyList(), - val radioConfig: ConfigProtos.Config = config {}, - val moduleConfig: ModuleConfigProtos.ModuleConfig = moduleConfig {}, + val metadata: DeviceMetadata? = null, + val userConfig: User = User(), + val channelList: List = emptyList(), + val radioConfig: Config = Config(), + val moduleConfig: LocalModuleConfig = LocalModuleConfig(), val ringtone: String = "", val cannedMessageMessages: String = "", - val deviceConnectionStatus: ConnStatusProtos.DeviceConnectionStatus? = null, + val deviceConnectionStatus: DeviceConnectionStatus? = null, val responseState: ResponseState = ResponseState.Empty, val analyticsAvailable: Boolean = true, val analyticsEnabled: Boolean = false, @@ -129,12 +127,15 @@ constructor( analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed } - private val destNum = savedStateHandle.toRoute().destNum + private val destNum = + savedStateHandle.get("destNum") + ?: runCatching { savedStateHandle.toRoute().destNum }.getOrNull() + private val _destNode = MutableStateFlow(null) val destNode: StateFlow get() = _destNode - private val requestIds = MutableStateFlow(emptySet()) + private val requestIds = MutableStateFlow(hashSetOf()) private val _radioConfigState = MutableStateFlow(RadioConfigState()) val radioConfigState: StateFlow = _radioConfigState @@ -142,7 +143,7 @@ constructor( viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } } - private val _currentDeviceProfile = MutableStateFlow(deviceProfile {}) + private val _currentDeviceProfile = MutableStateFlow(DeviceProfile()) val currentDeviceProfile get() = _currentDeviceProfile.value @@ -195,14 +196,13 @@ constructor( val hasPaFan: Boolean get() = - destNode.value?.user?.hwModel in + destNode.value?.user?.hw_model in setOf( null, - MeshProtos.HardwareModel.UNRECOGNIZED, - MeshProtos.HardwareModel.UNSET, - MeshProtos.HardwareModel.BETAFPV_2400_TX, - MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT_NANO, - MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT, + HardwareModel.UNSET, + HardwareModel.BETAFPV_2400_TX, + HardwareModel.RADIOMASTER_900_BANDIT_NANO, + HardwareModel.RADIOMASTER_900_BANDIT, ) override fun onCleared() { @@ -216,12 +216,11 @@ constructor( val packetId = service.packetId try { requestAction(service, packetId, destNum) - requestIds.update { it + packetId } + requestIds.update { it.apply { add(packetId) } } _radioConfigState.update { state -> - val currentState = state.responseState - if (currentState is ResponseState.Loading) { - val total = maxOf(currentState.total, requestIds.value.size) - state.copy(responseState = currentState.copy(total = total)) + if (state.responseState is ResponseState.Loading) { + val total = maxOf(requestIds.value.size, state.responseState.total) + state.copy(responseState = state.responseState.copy(total = total)) } else { state.copy( route = "", // setter (response is PortNum.ROUTING_APP) @@ -235,25 +234,15 @@ constructor( } } - fun setOwner(user: MeshProtos.User) { - val targetNode = destNode.value ?: return - // Ensure we are setting the owner for the intended target node - // This prevents accidentally updating the local node if the user object has the wrong ID - val fixedUser = - if (targetNode.user.id.isNotEmpty() && targetNode.user.id != user.id) { - Logger.w { "Fixing user ID mismatch in setOwner: form=${user.id} target=${targetNode.user.id}" } - user.toBuilder().setId(targetNode.user.id).build() - } else { - user - } - setRemoteOwner(targetNode.num, fixedUser) + fun setOwner(user: User) { + setRemoteOwner(destNode.value?.num ?: return, user) } - private fun setRemoteOwner(destNum: Int, user: MeshProtos.User) = request( + private fun setRemoteOwner(destNum: Int, user: User) = request( destNum, { service, packetId, _ -> _radioConfigState.update { it.copy(userConfig = user) } - service.setRemoteOwner(packetId, destNum, user.toByteArray()) + service.setRemoteOwner(packetId, destNum, user.encode()) }, "Request setOwner error", ) @@ -264,7 +253,7 @@ constructor( "Request getOwner error", ) - fun updateChannels(new: List, old: List) { + fun updateChannels(new: List, old: List) { val destNum = destNode.value?.num ?: return getChannelList(new, old).forEach { setRemoteChannel(destNum, it) } @@ -280,12 +269,12 @@ constructor( private fun setChannels(channelUrl: String) = viewModelScope.launch { val new = channelUrl.toUri().toChannelSet() val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch - updateChannels(new.settingsList, old.settingsList) + updateChannels(new.settings, old.settings) } - private fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) = request( + private fun setRemoteChannel(destNum: Int, channel: Channel) = request( destNum, - { service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.toByteArray()) }, + { service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) }, "Request setRemoteChannel error", ) @@ -295,15 +284,29 @@ constructor( "Request getChannel error", ) - fun setConfig(config: ConfigProtos.Config) { + fun setConfig(config: Config) { setRemoteConfig(destNode.value?.num ?: return, config) } - private fun setRemoteConfig(destNum: Int, config: ConfigProtos.Config) = request( + private fun setRemoteConfig(destNum: Int, config: Config) = request( destNum, { service, packetId, dest -> - _radioConfigState.update { it.copy(radioConfig = config) } - service.setRemoteConfig(packetId, dest, config.toByteArray()) + _radioConfigState.update { state -> + state.copy( + radioConfig = + state.radioConfig.copy( + device = config.device ?: state.radioConfig.device, + position = config.position ?: state.radioConfig.position, + power = config.power ?: state.radioConfig.power, + network = config.network ?: state.radioConfig.network, + display = config.display ?: state.radioConfig.display, + lora = config.lora ?: state.radioConfig.lora, + bluetooth = config.bluetooth ?: state.radioConfig.bluetooth, + security = config.security ?: state.radioConfig.security, + ), + ) + } + service.setRemoteConfig(packetId, dest, config.encode()) }, "Request setConfig error", ) @@ -314,17 +317,38 @@ constructor( "Request getConfig error", ) - fun setModuleConfig(config: ModuleConfigProtos.ModuleConfig) { - setModuleConfig(destNode.value?.num ?: return, config) + fun setModuleConfig(config: ModuleConfig) { + setRemoteModuleConfig(destNode.value?.num ?: return, config) } - private fun setModuleConfig(destNum: Int, config: ModuleConfigProtos.ModuleConfig) = request( + private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) = request( destNum, { service, packetId, dest -> - _radioConfigState.update { it.copy(moduleConfig = config) } - service.setModuleConfig(packetId, dest, config.toByteArray()) + _radioConfigState.update { state -> + state.copy( + moduleConfig = + state.moduleConfig.copy( + mqtt = config.mqtt ?: state.moduleConfig.mqtt, + serial = config.serial ?: state.moduleConfig.serial, + external_notification = + config.external_notification ?: state.moduleConfig.external_notification, + store_forward = config.store_forward ?: state.moduleConfig.store_forward, + range_test = config.range_test ?: state.moduleConfig.range_test, + telemetry = config.telemetry ?: state.moduleConfig.telemetry, + canned_message = config.canned_message ?: state.moduleConfig.canned_message, + audio = config.audio ?: state.moduleConfig.audio, + remote_hardware = config.remote_hardware ?: state.moduleConfig.remote_hardware, + neighbor_info = config.neighbor_info ?: state.moduleConfig.neighbor_info, + ambient_lighting = config.ambient_lighting ?: state.moduleConfig.ambient_lighting, + detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor, + paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter, + statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage, + ), + ) + } + service.setModuleConfig(packetId, dest, config.encode()) }, - "Request setConfig error", + "Request setModuleConfig error", ) private fun getModuleConfig(destNum: Int, configType: Int) = request( @@ -418,7 +442,7 @@ constructor( AdminRoute.REBOOT.name -> requestReboot(destNum) AdminRoute.SHUTDOWN.name -> with(radioConfigState.value) { - if (metadata != null && !metadata.canShutdown) { + if (metadata != null && metadata.canShutdown != true) { sendError(Res.string.cant_shutdown) } else { requestShutdown(destNum) @@ -444,8 +468,8 @@ constructor( fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) { try { app.contentResolver.openInputStream(uri).use { inputStream -> - val bytes = inputStream?.readBytes() - val protobuf = DeviceProfile.parseFrom(bytes) + val bytes = inputStream?.readBytes() ?: ByteArray(0) + val protobuf = DeviceProfile.ADAPTER.decode(bytes) onResult(protobuf) } } catch (ex: Exception) { @@ -456,11 +480,11 @@ constructor( fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) } - private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) { + private suspend fun writeToUri(uri: Uri, message: com.squareup.wire.Message<*, *>) = withContext(Dispatchers.IO) { try { app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor -> FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream -> - message.writeTo(outputStream) + outputStream.write(message.encode()) } } setResponseStateSuccess() @@ -470,16 +494,16 @@ constructor( } } - fun exportSecurityConfig(uri: Uri, securityConfig: SecurityConfig) = + fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) = viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) } private val indentSpaces = 4 - private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: SecurityConfig) = + private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) = withContext(Dispatchers.IO) { try { - val publicKeyBytes = securityConfig.publicKey.toByteArray() - val privateKeyBytes = securityConfig.privateKey.toByteArray() + val publicKeyBytes = securityConfig.public_key?.toByteArray() ?: ByteArray(0) + val privateKeyBytes = securityConfig.private_key?.toByteArray() ?: ByteArray(0) // Convert byte arrays to Base64 strings for human readability in JSON val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP) @@ -509,74 +533,57 @@ constructor( } } - @Suppress("CyclomaticComplexMethod") fun installProfile(protobuf: DeviceProfile) { val destNum = destNode.value?.num ?: return with(protobuf) { meshService?.beginEditSettings(destNum) - if (hasLongName() || hasShortName()) { + if (long_name != null || short_name != null) { destNode.value?.user?.let { - val user = - MeshProtos.User.newBuilder() - .setId(it.id) - .setLongName(if (hasLongName()) longName else it.longName) - .setShortName(if (hasShortName()) shortName else it.shortName) - .setIsLicensed(it.isLicensed) - .build() + val user = it.copy(long_name = long_name ?: it.long_name, short_name = short_name ?: it.short_name) setOwner(user) } } - if (hasChannelUrl()) { - try { - setChannels(channelUrl) - } catch (ex: Exception) { - Logger.e(ex) { "DeviceProfile channel import error" } - sendError(ex.customMessage) - } + config?.let { lc -> + lc.device?.let { setConfig(Config(device = it)) } + lc.position?.let { setConfig(Config(position = it)) } + lc.power?.let { setConfig(Config(power = it)) } + lc.network?.let { setConfig(Config(network = it)) } + lc.display?.let { setConfig(Config(display = it)) } + lc.lora?.let { setConfig(Config(lora = it)) } + lc.bluetooth?.let { setConfig(Config(bluetooth = it)) } + lc.security?.let { setConfig(Config(security = it)) } } - if (hasConfig()) { - val descriptor = ConfigProtos.Config.getDescriptor() - config.allFields.forEach { (field, value) -> - val newConfig = - ConfigProtos.Config.newBuilder().setField(descriptor.findFieldByName(field.name), value).build() - setConfig(newConfig) - } + if (fixed_position != null) { + setFixedPosition(Position(fixed_position!!)) } - if (hasFixedPosition()) { - setFixedPosition(Position(fixedPosition)) - } - if (hasModuleConfig()) { - val descriptor = ModuleConfigProtos.ModuleConfig.getDescriptor() - moduleConfig.allFields.forEach { (field, value) -> - val newConfig = - ModuleConfigProtos.ModuleConfig.newBuilder() - .setField(descriptor.findFieldByName(field.name), value) - .build() - setModuleConfig(newConfig) - } + module_config?.let { lmc -> + lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) } + lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) } + lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) } + lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) } + lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) } + lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) } + lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) } + lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) } + lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) } + lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) } + lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) } + lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) } + lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) } + lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) } } meshService?.commitEditSettings(destNum) } } fun clearPacketResponse() { - requestIds.value = emptySet() + requestIds.value = hashSetOf() _radioConfigState.update { it.copy(responseState = ResponseState.Empty) } } - private fun getTitleForRoute(route: Enum<*>) = when (route) { - is ConfigRoute -> route.title - is ModuleRoute -> route.title - is AdminRoute -> route.title - else -> null - } - - @Suppress("CyclomaticComplexMethod") fun setResponseStateLoading(route: Enum<*>) { val destNum = destNode.value?.num ?: return - val title = getTitleForRoute(route) - _radioConfigState.update { RadioConfigState( isLocal = it.isLocal, @@ -584,10 +591,7 @@ constructor( route = route.name, metadata = it.metadata, nodeDbResetPreserveFavorites = it.nodeDbResetPreserveFavorites, - responseState = - ResponseState.Loading( - status = title?.let { t -> getString(Res.string.fetching_config, getString(t)) }, - ), + responseState = ResponseState.Loading(), ) } @@ -602,7 +606,7 @@ constructor( } is AdminRoute -> { - getConfig(destNum, AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) + getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) setResponseStateTotal(2) } @@ -634,11 +638,10 @@ constructor( mapConsentPrefs.setShouldReportLocation(nodeNum, shouldReportLocation) } - private fun setResponseStateTotal(newTotal: Int) { + private fun setResponseStateTotal(total: Int) { _radioConfigState.update { state -> - val currentState = state.responseState - if (currentState is ResponseState.Loading) { - state.copy(responseState = currentState.copy(total = newTotal)) + if (state.responseState is ResponseState.Loading) { + state.copy(responseState = state.responseState.copy(total = total)) } else { state // Return the unchanged state for other response states } @@ -666,35 +669,32 @@ constructor( _radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) } } - private fun incrementCompleted(status: String? = null) { + private fun incrementCompleted() { _radioConfigState.update { state -> if (state.responseState is ResponseState.Loading) { val increment = state.responseState.completed + 1 - state.copy( - responseState = - state.responseState.copy(completed = increment, status = status ?: state.responseState.status), - ) + state.copy(responseState = state.responseState.copy(completed = increment)) } else { state // Return the unchanged state for other response states } } } - private fun processPacketResponse(packet: MeshProtos.MeshPacket) { - val data = packet.decoded - if (data.requestId !in requestIds.value) return + private fun processPacketResponse(packet: MeshPacket) { + val data = packet.decoded ?: return + if (data.request_id !in requestIds.value) return val route = radioConfigState.value.route val destNum = destNode.value?.num ?: return - val debugMsg = "requestId: ${data.requestId.toUInt()} to: ${destNum.toUInt()} received %s" + val debugMsg = "requestId: ${data.request_id.toUInt()} to: ${destNum.toUInt()} received %s" - if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) { - val parsed = MeshProtos.Routing.parseFrom(data.payload) - Logger.d { debugMsg.format(parsed.errorReason.name) } - if (parsed.errorReason != MeshProtos.Routing.Error.NONE) { - sendError(getStringResFrom(parsed.errorReasonValue)) + if (data.portnum == PortNum.ROUTING_APP) { + val parsed = Routing.ADAPTER.decode(data.payload) + Logger.d { debugMsg.format(parsed.error_reason?.name) } + if (parsed.error_reason != Routing.Error.NONE) { + sendError(getStringResFrom(parsed.error_reason?.value ?: 0)) } else if (packet.from == destNum && route.isEmpty()) { - requestIds.update { it - data.requestId } + requestIds.update { it.apply { remove(data.request_id) } } if (requestIds.value.isEmpty()) { setResponseStateSuccess() } else { @@ -702,91 +702,125 @@ constructor( } } } - if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) { - val parsed = AdminProtos.AdminMessage.parseFrom(data.payload) - Logger.d { debugMsg.format(parsed.payloadVariantCase.name) } + if (data.portnum == PortNum.ADMIN_APP) { + val parsed = AdminMessage.ADAPTER.decode(data.payload) + // Explicitly log the non-null field name for clarity + val variant = + when { + parsed.get_device_metadata_response != null -> "get_device_metadata_response" + parsed.get_channel_response != null -> "get_channel_response" + parsed.get_owner_response != null -> "get_owner_response" + parsed.get_config_response != null -> "get_config_response" + parsed.get_module_config_response != null -> "get_module_config_response" + parsed.get_canned_message_module_messages_response != null -> + "get_canned_message_module_messages_response" + parsed.get_ringtone_response != null -> "get_ringtone_response" + parsed.get_device_connection_status_response != null -> "get_device_connection_status_response" + else -> "unknown" + } + Logger.d { debugMsg.format(variant) } if (destNum != packet.from) { sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.") return } - when (parsed.payloadVariantCase) { - AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> { - _radioConfigState.update { it.copy(metadata = parsed.getDeviceMetadataResponse) } + when { + parsed.get_device_metadata_response != null -> { + _radioConfigState.update { it.copy(metadata = parsed.get_device_metadata_response) } incrementCompleted() } - AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { - val response = parsed.getChannelResponse + parsed.get_channel_response != null -> { + val response = parsed.get_channel_response!! // Stop once we get to the first disabled entry - if (response.role != ChannelProtos.Channel.Role.DISABLED) { + if (response.role != Channel.Role.DISABLED) { _radioConfigState.update { state -> state.copy( channelList = - state.channelList.toMutableList().apply { add(response.index, response.settings) }, + state.channelList.toMutableList().apply { + val index = response.index ?: 0 + val settings = response.settings ?: ChannelSettings() + // Make sure list is large enough + while (size <= index) add(ChannelSettings()) + set(index, settings) + }, ) } - incrementCompleted( - getString(Res.string.fetching_channel_indexed, response.index + 1, maxChannels), - ) - if (response.index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { + incrementCompleted() + val index = response.index ?: 0 + if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { // Not done yet, request next channel - getChannel(destNum, response.index + 1) + getChannel(destNum, index + 1) } } else { // Received last channel, update total and start channel editor - setResponseStateTotal(response.index + 1) + setResponseStateTotal((response.index ?: 0) + 1) } } - AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> { - _radioConfigState.update { it.copy(userConfig = parsed.getOwnerResponse) } + parsed.get_owner_response != null -> { + _radioConfigState.update { it.copy(userConfig = parsed.get_owner_response!!) } incrementCompleted() } - AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { - val response = parsed.getConfigResponse - if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET - sendError(response.payloadVariantCase.name) - } + parsed.get_config_response != null -> { + val response = parsed.get_config_response!! _radioConfigState.update { it.copy(radioConfig = response) } incrementCompleted() } - AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> { - val response = parsed.getModuleConfigResponse - if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET - sendError(response.payloadVariantCase.name) + parsed.get_module_config_response != null -> { + val response = parsed.get_module_config_response!! + _radioConfigState.update { state -> + state.copy( + moduleConfig = + state.moduleConfig.copy( + mqtt = response.mqtt ?: state.moduleConfig.mqtt, + serial = response.serial ?: state.moduleConfig.serial, + external_notification = + response.external_notification ?: state.moduleConfig.external_notification, + store_forward = response.store_forward ?: state.moduleConfig.store_forward, + range_test = response.range_test ?: state.moduleConfig.range_test, + telemetry = response.telemetry ?: state.moduleConfig.telemetry, + canned_message = response.canned_message ?: state.moduleConfig.canned_message, + audio = response.audio ?: state.moduleConfig.audio, + remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware, + neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info, + ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting, + detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor, + paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter, + statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage, + ), + ) } - _radioConfigState.update { it.copy(moduleConfig = response) } incrementCompleted() } - AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> { + parsed.get_canned_message_module_messages_response != null -> { _radioConfigState.update { - it.copy(cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse) + it.copy(cannedMessageMessages = parsed.get_canned_message_module_messages_response!!) } incrementCompleted() } - AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> { - _radioConfigState.update { it.copy(ringtone = parsed.getRingtoneResponse) } + parsed.get_ringtone_response != null -> { + _radioConfigState.update { it.copy(ringtone = parsed.get_ringtone_response!!) } incrementCompleted() } - AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_CONNECTION_STATUS_RESPONSE -> { + parsed.get_device_connection_status_response != null -> { _radioConfigState.update { - it.copy(deviceConnectionStatus = parsed.getDeviceConnectionStatusResponse) + it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response) } incrementCompleted() } - else -> Logger.d { "No custom processing needed for ${parsed.payloadVariantCase}" } + else -> Logger.d { "No custom processing needed for $parsed" } } if (AdminRoute.entries.any { it.name == route }) { sendAdminRequest(destNum) } - requestIds.update { it - data.requestId } + requestIds.update { it.apply { remove(data.request_id) } } } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 63d42ea5d..71931f30c 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -73,9 +73,8 @@ import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog -import org.meshtastic.proto.ChannelProtos.ChannelSettings -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig -import org.meshtastic.proto.channelSettings +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config @Composable fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { @@ -89,9 +88,9 @@ fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { title = stringResource(Res.string.channels), onBack = onBack, settingsList = state.channelList, - loraConfig = state.radioConfig.lora, + loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(), maxChannels = viewModel.maxChannels, - firmwareVersion = state.metadata?.firmwareVersion ?: "0.0.0", + firmwareVersion = state.metadata?.firmware_version ?: "0.0.0", enabled = state.connected, onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) }, ) @@ -103,7 +102,7 @@ private fun ChannelConfigScreen( title: String, onBack: () -> Unit, settingsList: List, - loraConfig: LoRaConfig, + loraConfig: Config.LoRaConfig, maxChannels: Int = 8, firmwareVersion: String, enabled: Boolean, @@ -138,7 +137,7 @@ private fun ChannelConfigScreen( if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return EditChannelDialog( - channelSettings = with(settingsListInput) { if (size > index) get(index) else channelSettings {} }, + channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() }, modemPresetName = modemPresetName, onAddClick = { if (settingsListInput.size > index) { @@ -173,7 +172,7 @@ private fun ChannelConfigScreen( FloatingActionButton( onClick = { if (maxChannels > settingsListInput.size) { - settingsListInput.add(channelSettings { psk = Channel.default.settings.psk }) + settingsListInput.add(ChannelSettings(psk = Channel.default.settings.psk)) showEditChannelDialog = settingsListInput.lastIndex } }, @@ -188,14 +187,14 @@ private fun ChannelConfigScreen( Column { ChannelConfigHeader( frequency = - if (loraConfig.overrideFrequency != 0f) { - loraConfig.overrideFrequency + if (loraConfig.override_frequency != 0f) { + loraConfig.override_frequency } else { primaryChannel.radioFreq }, slot = - if (loraConfig.channelNum != 0) { - loraConfig.channelNum + if (loraConfig.channel_num != 0) { + loraConfig.channel_num } else { primaryChannel.channelNum }, @@ -282,7 +281,7 @@ private fun determineLocationSharingChannel(capabilities: Capabilities, settings if (capabilities.supportsSecondaryChannelLocation) { /* Essentially the first index with the setting enabled */ for ((i, settings) in settingsList.withIndex()) { - if (settings.moduleSettings.positionPrecision > 0) { + if ((settings.module_settings?.position_precision ?: 0) > 0) { output = i break } @@ -290,7 +289,7 @@ private fun determineLocationSharingChannel(capabilities: Capabilities, settings } else { /* Only the primary channel at index 0 can share locations automatically */ val primary = settingsList[0] - if (primary.moduleSettings.positionPrecision > 0) { + if ((primary.module_settings?.position_precision ?: 0) > 0) { output = 0 } } @@ -305,11 +304,8 @@ private fun ChannelConfigScreenPreview() { onBack = {}, settingsList = listOf( - channelSettings { - psk = Channel.default.settings.psk - name = Channel.default.name - }, - channelSettings { name = stringResource(Res.string.channel_name) }, + ChannelSettings(psk = Channel.default.settings.psk, name = Channel.default.name), + ChannelSettings(name = stringResource(Res.string.channel_name)), ), loraConfig = Channel.default.loraConfig, firmwareVersion = "1.3.2", diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.kt index c81e73625..e89c37df9 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelCard.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 org.meshtastic.feature.settings.radio.channel.component import androidx.compose.foundation.layout.Spacer @@ -35,10 +34,8 @@ import org.meshtastic.core.strings.delete import org.meshtastic.core.ui.component.ChannelItem import org.meshtastic.core.ui.component.SecurityIcon import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.ChannelProtos.ChannelSettings -import org.meshtastic.proto.ConfigKt.loRaConfig -import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig -import org.meshtastic.proto.channelSettings +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config @Composable internal fun ChannelCard( @@ -46,7 +43,7 @@ internal fun ChannelCard( title: String, enabled: Boolean, channelSettings: ChannelSettings, - loraConfig: LoRaConfig, + loraConfig: Config.LoRaConfig, onEditClick: () -> Unit, onDeleteClick: () -> Unit, sharesLocation: Boolean, @@ -58,14 +55,14 @@ internal fun ChannelCard( modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), ) } - if (channelSettings.uplinkEnabled) { + if (channelSettings.uplink_enabled) { Icon( imageVector = ChannelIcons.UPLINK.icon, contentDescription = stringResource(ChannelIcons.UPLINK.descriptionResId), modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), ) } - if (channelSettings.downlinkEnabled) { + if (channelSettings.downlink_enabled) { Icon( imageVector = ChannelIcons.DOWNLINK.icon, contentDescription = stringResource(ChannelIcons.DOWNLINK.descriptionResId), @@ -91,12 +88,8 @@ private fun ChannelCardPreview() { index = 0, title = "Medium Fast", enabled = true, - channelSettings = - channelSettings { - uplinkEnabled = true - downlinkEnabled = true - }, - loraConfig = loRaConfig {}, + channelSettings = ChannelSettings(uplink_enabled = true, downlink_enabled = true), + loraConfig = Config.LoRaConfig(), onEditClick = {}, onDeleteClick = {}, sharesLocation = true, diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt index bf9a5b4f2..dad8ecccb 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.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 org.meshtastic.feature.settings.radio.channel.component import androidx.compose.foundation.layout.Arrangement @@ -54,15 +53,14 @@ import org.meshtastic.core.ui.component.EditBase64Preference import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.PositionPrecisionPreference import org.meshtastic.core.ui.component.SwitchPreference -import org.meshtastic.proto.ChannelProtos -import org.meshtastic.proto.channelSettings -import org.meshtastic.proto.copy +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.ModuleSettings @Suppress("LongMethod") @Composable fun EditChannelDialog( - channelSettings: ChannelProtos.ChannelSettings, - onAddClick: (ChannelProtos.ChannelSettings) -> Unit, + channelSettings: ChannelSettings, + onAddClick: (ChannelSettings) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, modemPresetName: String = stringResource(Res.string.default_), @@ -76,10 +74,15 @@ fun EditChannelDialog( onDismissRequest = onDismissRequest, shape = RoundedCornerShape(16.dp), text = { - Column(modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { EditTextPreference( title = stringResource(Res.string.channel_name), - value = if (isFocused) channelInput.name else channelInput.name.ifEmpty { modemPresetName }, + value = + if (isFocused) { + (channelInput.name ?: "") + } else { + (channelInput.name ?: "").ifEmpty { modemPresetName } + }, maxSize = 11, // name max_size:12 enabled = true, isError = false, @@ -87,51 +90,55 @@ fun EditChannelDialog( KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { - channelInput = - channelInput.copy { - name = it.trim() - if (psk == Channel.default.settings.psk) psk = Channel.getRandomKey() + val defaultPsk = Channel.default.settings.psk + val newPsk = + if (channelInput.psk == defaultPsk) { + Channel.getRandomKey() + } else { + (channelInput.psk ?: okio.ByteString.EMPTY) } + channelInput = channelInput.copy(name = it.trim(), psk = newPsk) }, onFocusChanged = { isFocused = it.isFocused }, ) EditBase64Preference( title = "PSK", - value = channelInput.psk, + value = channelInput.psk ?: okio.ByteString.EMPTY, enabled = true, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChange = { - val fullPsk = Channel(channelSettings { psk = it }).psk - if (fullPsk.size() in setOf(0, 16, 32)) { - channelInput = channelInput.copy { psk = it } + val fullPsk = Channel(ChannelSettings(psk = it)).psk + if (fullPsk.size in setOf(0, 16, 32)) { + channelInput = channelInput.copy(psk = it) } }, - onGenerateKey = { channelInput = channelInput.copy { psk = Channel.getRandomKey() } }, + onGenerateKey = { channelInput = channelInput.copy(psk = Channel.getRandomKey()) }, ) SwitchPreference( title = stringResource(Res.string.uplink_enabled), - checked = channelInput.uplinkEnabled, + checked = channelInput.uplink_enabled ?: false, enabled = true, - onCheckedChange = { channelInput = channelInput.copy { uplinkEnabled = it } }, + onCheckedChange = { channelInput = channelInput.copy(uplink_enabled = it) }, padding = PaddingValues(0.dp), ) SwitchPreference( title = stringResource(Res.string.downlink_enabled), - checked = channelInput.downlinkEnabled, + checked = channelInput.downlink_enabled ?: false, enabled = true, - onCheckedChange = { channelInput = channelInput.copy { downlinkEnabled = it } }, + onCheckedChange = { channelInput = channelInput.copy(downlink_enabled = it) }, padding = PaddingValues(0.dp), ) + val moduleSettings = channelInput.module_settings ?: ModuleSettings() PositionPrecisionPreference( enabled = true, - value = channelInput.moduleSettings.positionPrecision, + value = moduleSettings.position_precision ?: 0, onValueChanged = { - val module = channelInput.moduleSettings.copy { positionPrecision = it } - channelInput = channelInput.copy { moduleSettings = module } + val updatedModule = moduleSettings.copy(position_precision = it) + channelInput = channelInput.copy(module_settings = updatedModule) }, ) } @@ -156,11 +163,7 @@ fun EditChannelDialog( @Composable private fun EditChannelDialogPreview() { EditChannelDialog( - channelSettings = - channelSettings { - psk = Channel.default.settings.psk - name = Channel.default.name - }, + channelSettings = ChannelSettings(psk = Channel.default.settings.psk, name = Channel.default.name), onAddClick = {}, onDismissRequest = {}, ) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.kt index 46af00b33..e29c41fa4 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -38,13 +37,12 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val ambientLightingConfig = state.moduleConfig.ambientLighting + val ambientLightingConfig = state.moduleConfig.ambient_lighting ?: ModuleConfig.AmbientLightingConfig() val formState = rememberConfigState(initialValue = ambientLightingConfig) val focusManager = LocalFocusManager.current @@ -56,7 +54,7 @@ fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel( responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { ambientLighting = it } + val config = ModuleConfig(ambient_lighting = it) viewModel.setModuleConfig(config) }, ) { @@ -64,40 +62,40 @@ fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel( TitledCard(title = stringResource(Res.string.ambient_lighting_config)) { SwitchPreference( title = stringResource(Res.string.led_state), - checked = formState.value.ledState, + checked = formState.value.led_state ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { ledState = it } }, + onCheckedChange = { formState.value = formState.value.copy(led_state = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.current), - value = formState.value.current, + value = formState.value.current ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { current = it } }, + onValueChanged = { formState.value = formState.value.copy(current = it) }, ) EditTextPreference( title = stringResource(Res.string.red), - value = formState.value.red, + value = formState.value.red ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { red = it } }, + onValueChanged = { formState.value = formState.value.copy(red = it) }, ) EditTextPreference( title = stringResource(Res.string.green), - value = formState.value.green, + value = formState.value.green ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { green = it } }, + onValueChanged = { formState.value = formState.value.copy(green = it) }, ) EditTextPreference( title = stringResource(Res.string.blue), - value = formState.value.blue, + value = formState.value.blue ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { blue = it } }, + onValueChanged = { formState.value = formState.value.copy(blue = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.kt index f13aecdaf..6e868478e 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -41,14 +40,12 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig.AudioConfig -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val audioConfig = state.moduleConfig.audio + val audioConfig = state.moduleConfig.audio ?: ModuleConfig.AudioConfig() val formState = rememberConfigState(initialValue = audioConfig) val focusManager = LocalFocusManager.current @@ -60,7 +57,7 @@ fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { audio = it } + val config = ModuleConfig(audio = it) viewModel.setModuleConfig(config) }, ) { @@ -68,57 +65,54 @@ fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: TitledCard(title = stringResource(Res.string.audio_config)) { SwitchPreference( title = stringResource(Res.string.codec_2_enabled), - checked = formState.value.codec2Enabled, + checked = formState.value.codec2_enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { codec2Enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(codec2_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.ptt_pin), - value = formState.value.pttPin, + value = formState.value.ptt_pin ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { pttPin = it } }, + onValueChanged = { formState.value = formState.value.copy(ptt_pin = it) }, ) DropDownPreference( title = stringResource(Res.string.codec2_sample_rate), enabled = state.connected, - items = - AudioConfig.Audio_Baud.entries - .filter { it != AudioConfig.Audio_Baud.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.bitrate, - onItemSelected = { formState.value = formState.value.copy { bitrate = it } }, + items = ModuleConfig.AudioConfig.Audio_Baud.entries.map { it to it.name }, + selectedItem = formState.value.bitrate ?: ModuleConfig.AudioConfig.Audio_Baud.CODEC2_DEFAULT, + onItemSelected = { formState.value = formState.value.copy(bitrate = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.i2s_word_select), - value = formState.value.i2SWs, + value = formState.value.i2s_ws ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { i2SWs = it } }, + onValueChanged = { formState.value = formState.value.copy(i2s_ws = it) }, ) EditTextPreference( title = stringResource(Res.string.i2s_data_in), - value = formState.value.i2SSd, + value = formState.value.i2s_sd ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { i2SSd = it } }, + onValueChanged = { formState.value = formState.value.copy(i2s_sd = it) }, ) EditTextPreference( title = stringResource(Res.string.i2s_data_out), - value = formState.value.i2SDin, + value = formState.value.i2s_din ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { i2SDin = it } }, + onValueChanged = { formState.value = formState.value.copy(i2s_din = it) }, ) EditTextPreference( title = stringResource(Res.string.i2s_clock), - value = formState.value.i2SSck, + value = formState.value.i2s_sck ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { i2SSck = it } }, + onValueChanged = { formState.value = formState.value.copy(i2s_sck = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.kt index e200379da..d9d5ecc5a 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -37,14 +36,12 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.ConfigProtos.Config.BluetoothConfig -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config @Composable fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val bluetoothConfig = state.radioConfig.bluetooth + val bluetoothConfig = state.radioConfig.bluetooth ?: Config.BluetoothConfig() val formState = rememberConfigState(initialValue = bluetoothConfig) val focusManager = LocalFocusManager.current @@ -56,7 +53,7 @@ fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = config { bluetooth = it } + val config = Config(bluetooth = it) viewModel.setConfig(config) }, ) { @@ -66,7 +63,7 @@ fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB title = stringResource(Res.string.bluetooth_enabled), checked = formState.value.enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -74,21 +71,23 @@ fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB title = stringResource(Res.string.pairing_mode), enabled = state.connected, items = - BluetoothConfig.PairingMode.entries - .filter { it != BluetoothConfig.PairingMode.UNRECOGNIZED } + Config.BluetoothConfig.PairingMode.entries + .filter { it.name != "UNRECOGNIZED" } .map { it to it.name }, - selectedItem = formState.value.mode, - onItemSelected = { formState.value = formState.value.copy { mode = it } }, + selectedItem = + formState.value.mode?.takeUnless { it.name == "UNRECOGNIZED" } + ?: Config.BluetoothConfig.PairingMode.RANDOM_PIN, + onItemSelected = { formState.value = formState.value.copy(mode = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.fixed_pin), - value = formState.value.fixedPin, + value = formState.value.fixed_pin ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { if (it.toString().length == 6) { // ensure 6 digits - formState.value = formState.value.copy { fixedPin = it } + formState.value = formState.value.copy(fixed_pin = it) } }, ) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt index 6c38888f1..bacc82b76 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -52,14 +51,12 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig.CannedMessageConfig -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val cannedMessageConfig = state.moduleConfig.cannedMessage + val cannedMessageConfig = state.moduleConfig.canned_message ?: ModuleConfig.CannedMessageConfig() val messages = state.cannedMessageMessages val formState = rememberConfigState(initialValue = cannedMessageConfig) var messagesInput by rememberSaveable(messages) { mutableStateOf(messages) } @@ -79,7 +76,7 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), viewModel.setCannedMessages(messagesInput) } if (formState.value != cannedMessageConfig) { - val config = moduleConfig { cannedMessage = formState.value } + val config = ModuleConfig(canned_message = formState.value) viewModel.setModuleConfig(config) } }, @@ -90,96 +87,90 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), title = stringResource(Res.string.canned_message_enabled), checked = formState.value.enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.rotary_encoder_1_enabled), - checked = formState.value.rotary1Enabled, + checked = formState.value.rotary1_enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { rotary1Enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(rotary1_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.gpio_pin_for_rotary_encoder_a_port), - value = formState.value.inputbrokerPinA, + value = formState.value.inputbroker_pin_a ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { inputbrokerPinA = it } }, + onValueChanged = { formState.value = formState.value.copy(inputbroker_pin_a = it) }, ) EditTextPreference( title = stringResource(Res.string.gpio_pin_for_rotary_encoder_b_port), - value = formState.value.inputbrokerPinB, + value = formState.value.inputbroker_pin_b ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { inputbrokerPinB = it } }, + onValueChanged = { formState.value = formState.value.copy(inputbroker_pin_b = it) }, ) EditTextPreference( title = stringResource(Res.string.gpio_pin_for_rotary_encoder_press_port), - value = formState.value.inputbrokerPinPress, + value = formState.value.inputbroker_pin_press ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { inputbrokerPinPress = it } }, + onValueChanged = { formState.value = formState.value.copy(inputbroker_pin_press = it) }, ) DropDownPreference( title = stringResource(Res.string.generate_input_event_on_press), enabled = state.connected, - items = - CannedMessageConfig.InputEventChar.entries - .filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.inputbrokerEventPress, - onItemSelected = { formState.value = formState.value.copy { inputbrokerEventPress = it } }, + items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name }, + selectedItem = + formState.value.inputbroker_event_press ?: ModuleConfig.CannedMessageConfig.InputEventChar.NONE, + onItemSelected = { formState.value = formState.value.copy(inputbroker_event_press = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.generate_input_event_on_cw), enabled = state.connected, - items = - CannedMessageConfig.InputEventChar.entries - .filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.inputbrokerEventCw, - onItemSelected = { formState.value = formState.value.copy { inputbrokerEventCw = it } }, + items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name }, + selectedItem = + formState.value.inputbroker_event_cw ?: ModuleConfig.CannedMessageConfig.InputEventChar.NONE, + onItemSelected = { formState.value = formState.value.copy(inputbroker_event_cw = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.generate_input_event_on_ccw), enabled = state.connected, - items = - CannedMessageConfig.InputEventChar.entries - .filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.inputbrokerEventCcw, - onItemSelected = { formState.value = formState.value.copy { inputbrokerEventCcw = it } }, + items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name }, + selectedItem = + formState.value.inputbroker_event_ccw ?: ModuleConfig.CannedMessageConfig.InputEventChar.NONE, + onItemSelected = { formState.value = formState.value.copy(inputbroker_event_ccw = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.up_down_select_input_enabled), - checked = formState.value.updown1Enabled, + checked = formState.value.updown1_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { updown1Enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(updown1_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.allow_input_source), - value = formState.value.allowInputSource, + value = formState.value.allow_input_source ?: "", maxSize = 63, // allow_input_source max_size:16 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { allowInputSource = it } }, + onValueChanged = { formState.value = formState.value.copy(allow_input_source = it) }, ) SwitchPreference( title = stringResource(Res.string.send_bell), - checked = formState.value.sendBell, + checked = formState.value.send_bell ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { sendBell = it } }, + onCheckedChange = { formState.value = formState.value.copy(send_bell = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ConfigState.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ConfigState.kt index 94da1a977..b9e3e85ab 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ConfigState.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ConfigState.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 org.meshtastic.feature.settings.radio.component import androidx.compose.runtime.Composable @@ -23,7 +22,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import com.google.protobuf.MessageLite +import com.squareup.wire.Message /** * A state holder for managing config data within a Composable. @@ -32,10 +31,10 @@ import com.google.protobuf.MessageLite * whether the current value has been modified ("dirty"), and provides simple methods to save the changes or reset to * the initial state. * - * @param T The type of the data being managed, typically a Protobuf message. + * @param T The type of the data being managed, typically a Wire message. * @property initialValue The original, unmodified value of the config data. */ -class ConfigState(private val initialValue: T) { +class ConfigState>(private val initialValue: T) { var value by mutableStateOf(initialValue) val isDirty: Boolean @@ -46,14 +45,9 @@ class ConfigState(private val initialValue: T) { } companion object { - fun saver(initialValue: T): Saver, ByteArray> = Saver( - save = { it.value.toByteArray() }, - restore = { - ConfigState(initialValue).apply { - @Suppress("UNCHECKED_CAST") - value = initialValue.parserForType.parseFrom(it) as T - } - }, + fun > saver(initialValue: T): Saver, ByteArray> = Saver( + save = { it.value.adapter.encode(it.value) }, + restore = { ConfigState(initialValue).apply { value = initialValue.adapter.decode(it) } }, ) } } @@ -66,5 +60,5 @@ class ConfigState(private val initialValue: T) { * across recompositions. */ @Composable -fun rememberConfigState(initialValue: T): ConfigState = +fun > rememberConfigState(initialValue: T): ConfigState = rememberSaveable(initialValue, saver = ConfigState.saver(initialValue)) { ConfigState(initialValue) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.kt index 4456db3cd..89ec48a20 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -49,14 +48,12 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.gpioPins import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val detectionSensorConfig = state.moduleConfig.detectionSensor + val detectionSensorConfig = state.moduleConfig.detection_sensor ?: ModuleConfig.DetectionSensorConfig() val formState = rememberConfigState(initialValue = detectionSensorConfig) val focusManager = LocalFocusManager.current @@ -68,7 +65,7 @@ fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel( responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { detectionSensor = it } + val config = ModuleConfig(detection_sensor = it) viewModel.setModuleConfig(config) }, ) { @@ -78,7 +75,7 @@ fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel( title = stringResource(Res.string.detection_sensor_enabled), checked = formState.value.enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -87,66 +84,65 @@ fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel( } DropDownPreference( title = stringResource(Res.string.minimum_broadcast_seconds), - selectedItem = formState.value.minimumBroadcastSecs.toLong(), + selectedItem = (formState.value.minimum_broadcast_secs ?: 0).toLong(), enabled = state.connected, items = minimumBroadcastIntervals.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { minimumBroadcastSecs = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(minimum_broadcast_secs = it.toInt()) }, ) val stateBroadcastIntervals = remember { IntervalConfiguration.DETECTION_SENSOR_STATE.allowedIntervals } DropDownPreference( title = stringResource(Res.string.state_broadcast_seconds), - selectedItem = formState.value.stateBroadcastSecs.toLong(), + selectedItem = (formState.value.state_broadcast_secs ?: 0).toLong(), enabled = state.connected, items = stateBroadcastIntervals.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { stateBroadcastSecs = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(state_broadcast_secs = it.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.send_bell_with_alert_message), - checked = formState.value.sendBell, + checked = formState.value.send_bell, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { sendBell = it } }, + onCheckedChange = { formState.value = formState.value.copy(send_bell = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.friendly_name), - value = formState.value.name, + value = formState.value.name ?: "", maxSize = 19, // name max_size:20 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { name = it } }, + onValueChanged = { formState.value = formState.value.copy(name = it) }, ) HorizontalDivider() val pins = remember { gpioPins } DropDownPreference( title = stringResource(Res.string.gpio_pin_to_monitor), items = pins, - selectedItem = formState.value.monitorPin, + selectedItem = formState.value.monitor_pin ?: 0, enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy { monitorPin = it } }, + onItemSelected = { formState.value = formState.value.copy(monitor_pin = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.detection_trigger_type), enabled = state.connected, - items = - ModuleConfig.DetectionSensorConfig.TriggerType.entries - .filter { it != ModuleConfig.DetectionSensorConfig.TriggerType.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.detectionTriggerType, - onItemSelected = { formState.value = formState.value.copy { detectionTriggerType = it } }, + items = ModuleConfig.DetectionSensorConfig.TriggerType.entries.map { it to it.name }, + selectedItem = + formState.value.detection_trigger_type + ?: ModuleConfig.DetectionSensorConfig.TriggerType.LOGIC_LOW, + onItemSelected = { formState.value = formState.value.copy(detection_trigger_type = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.use_input_pullup_mode), - checked = formState.value.usePullup, + checked = formState.value.use_pullup ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { usePullup = it } }, + onCheckedChange = { formState.value = formState.value.copy(use_pullup = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt index bb8976a99..0f27c711a 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt @@ -119,39 +119,38 @@ import org.meshtastic.core.ui.timezone.toPosixString import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.ConfigProtos.Config.DeviceConfig -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config import java.time.ZoneId -private val DeviceConfig.Role.description: StringResource +private val Config.DeviceConfig.Role.description: StringResource get() = when (this) { - DeviceConfig.Role.CLIENT -> Res.string.role_client_desc - DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc - DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc - DeviceConfig.Role.ROUTER -> Res.string.role_router_desc - DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc - DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc - DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc - DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc - DeviceConfig.Role.TAK -> Res.string.role_tak_desc - DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc - DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc - DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc - DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc + Config.DeviceConfig.Role.CLIENT -> Res.string.role_client_desc + Config.DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc + Config.DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc + Config.DeviceConfig.Role.ROUTER -> Res.string.role_router_desc + Config.DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc + Config.DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc + Config.DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc + Config.DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc + Config.DeviceConfig.Role.TAK -> Res.string.role_tak_desc + Config.DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc + Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc + Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc + Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc else -> Res.string.unrecognized } -private val DeviceConfig.RebroadcastMode.description: StringResource +private val Config.DeviceConfig.RebroadcastMode.description: StringResource get() = when (this) { - DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc - DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc - DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc - DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc - DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc - DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY -> Res.string.rebroadcast_mode_core_portnums_only_desc + Config.DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc + Config.DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc + Config.DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc + Config.DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc + Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc + Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY -> + Res.string.rebroadcast_mode_core_portnums_only_desc else -> Res.string.unrecognized } @@ -159,19 +158,19 @@ private val DeviceConfig.RebroadcastMode.description: StringResource @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val deviceConfig = state.radioConfig.device + val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig() val formState = rememberConfigState(initialValue = deviceConfig) - var selectedRole by rememberSaveable { mutableStateOf(formState.value.role) } + var selectedRole by rememberSaveable { mutableStateOf(formState.value.role ?: Config.DeviceConfig.Role.CLIENT) } val infrastructureRoles = - listOf(DeviceConfig.Role.ROUTER, DeviceConfig.Role.ROUTER_LATE, DeviceConfig.Role.REPEATER) + listOf(Config.DeviceConfig.Role.ROUTER, Config.DeviceConfig.Role.ROUTER_LATE, Config.DeviceConfig.Role.REPEATER) if (selectedRole != formState.value.role) { if (selectedRole in infrastructureRoles) { RouterRoleConfirmationDialog( - onDismiss = { selectedRole = formState.value.role }, - onConfirm = { formState.value = formState.value.copy { role = selectedRole } }, + onDismiss = { selectedRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT }, + onConfirm = { formState.value = formState.value.copy(role = selectedRole) }, ) } else { - formState.value = formState.value.copy { role = selectedRole } + formState.value = formState.value.copy(role = selectedRole) } } val focusManager = LocalFocusManager.current @@ -183,28 +182,30 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = config { device = it } + val config = Config(device = it) viewModel.setConfig(config) }, ) { item { TitledCard(title = stringResource(Res.string.options)) { + val currentRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT DropDownPreference( title = stringResource(Res.string.role), enabled = state.connected, - selectedItem = formState.value.role, + selectedItem = currentRole, onItemSelected = { selectedRole = it }, - summary = stringResource(formState.value.role.description), + summary = stringResource(currentRole.description), ) HorizontalDivider() + val currentRebroadcastMode = formState.value.rebroadcast_mode ?: Config.DeviceConfig.RebroadcastMode.ALL DropDownPreference( title = stringResource(Res.string.rebroadcast_mode), enabled = state.connected, - selectedItem = formState.value.rebroadcastMode, - onItemSelected = { formState.value = formState.value.copy { rebroadcastMode = it } }, - summary = stringResource(formState.value.rebroadcastMode.description), + selectedItem = currentRebroadcastMode, + onItemSelected = { formState.value = formState.value.copy(rebroadcast_mode = it) }, + summary = stringResource(currentRebroadcastMode.description), ) HorizontalDivider() @@ -212,10 +213,10 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack val nodeInfoBroadcastIntervals = remember { IntervalConfiguration.NODE_INFO_BROADCAST.allowedIntervals } DropDownPreference( title = stringResource(Res.string.nodeinfo_broadcast_interval), - selectedItem = formState.value.nodeInfoBroadcastSecs.toLong(), + selectedItem = (formState.value.node_info_broadcast_secs ?: 0).toLong(), enabled = state.connected, items = nodeInfoBroadcastIntervals.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { nodeInfoBroadcastSecs = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(node_info_broadcast_secs = it.toInt()) }, ) } } @@ -225,9 +226,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack SwitchPreference( title = stringResource(Res.string.double_tap_as_button_press), summary = stringResource(Res.string.config_device_doubleTapAsButtonPress_summary), - checked = formState.value.doubleTapAsButtonPress, + checked = formState.value.double_tap_as_button_press, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { doubleTapAsButtonPress = it } }, + onCheckedChange = { formState.value = formState.value.copy(double_tap_as_button_press = it) }, containerColor = CardDefaults.cardColors().containerColor, ) @@ -236,9 +237,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack SwitchPreference( title = stringResource(Res.string.triple_click_adhoc_ping), summary = stringResource(Res.string.config_device_tripleClickAsAdHocPing_summary), - checked = !formState.value.disableTripleClick, + checked = !formState.value.disable_triple_click, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { disableTripleClick = !it } }, + onCheckedChange = { formState.value = formState.value.copy(disable_triple_click = !it) }, containerColor = CardDefaults.cardColors().containerColor, ) @@ -247,9 +248,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack SwitchPreference( title = stringResource(Res.string.led_heartbeat), summary = stringResource(Res.string.config_device_ledHeartbeatEnabled_summary), - checked = !formState.value.ledHeartbeatDisabled, + checked = !formState.value.led_heartbeat_disabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { ledHeartbeatDisabled = !it } }, + onCheckedChange = { formState.value = formState.value.copy(led_heartbeat_disabled = !it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -273,7 +274,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack EditTextPreference( title = "", - value = formState.value.tzdef, + value = formState.value.tzdef ?: "", summary = stringResource(Res.string.config_device_tzdef_summary), maxSize = 64, // tzdef max_size:65 enabled = state.connected, @@ -281,9 +282,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { tzdef = it } }, + onValueChanged = { formState.value = formState.value.copy(tzdef = it) }, trailingIcon = { - IconButton(onClick = { formState.value = formState.value.copy { tzdef = "" } }) { + IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) { Icon(imageVector = Icons.Rounded.Clear, contentDescription = null) } }, @@ -295,7 +296,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack modifier = Modifier.height(MediumContainerHeight).fillMaxWidth(), enabled = state.connected, shape = RectangleShape, - onClick = { formState.value = formState.value.copy { tzdef = appTzPosixString } }, + onClick = { formState.value = formState.value.copy(tzdef = appTzPosixString) }, ) { Icon(imageVector = Icons.Rounded.PhoneAndroid, contentDescription = null) @@ -310,20 +311,20 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack TitledCard(title = stringResource(Res.string.gpio)) { EditTextPreference( title = stringResource(Res.string.button_gpio), - value = formState.value.buttonGpio, + value = formState.value.button_gpio ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { buttonGpio = it } }, + onValueChanged = { formState.value = formState.value.copy(button_gpio = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.buzzer_gpio), - value = formState.value.buzzerGpio, + value = formState.value.buzzer_gpio ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { buzzerGpio = it } }, + onValueChanged = { formState.value = formState.value.copy(buzzer_gpio = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.kt index a4ccf84be..ea5b8e8b7 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.material3.CardDefaults @@ -56,14 +55,12 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config @Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val displayConfig = state.radioConfig.display + val displayConfig = state.radioConfig.display ?: Config.DisplayConfig() val formState = rememberConfigState(initialValue = displayConfig) RadioConfigScreenList( @@ -74,7 +71,7 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = config { display = it } + val config = Config(display = it) viewModel.setConfig(config) }, ) { @@ -83,9 +80,9 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac SwitchPreference( title = stringResource(Res.string.always_point_north), summary = stringResource(Res.string.config_display_compass_north_top_summary), - checked = formState.value.compassNorthTop, + checked = formState.value.compass_north_top ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { compassNorthTop = it } }, + onCheckedChange = { formState.value = formState.value.copy(compass_north_top = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -93,17 +90,17 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac title = stringResource(Res.string.use_12h_format), summary = stringResource(Res.string.display_time_in_12h_format), enabled = state.connected, - checked = formState.value.use12HClock, - onCheckedChange = { formState.value = formState.value.copy { use12HClock = it } }, + checked = formState.value.use_12h_clock ?: false, + onCheckedChange = { formState.value = formState.value.copy(use_12h_clock = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.bold_heading), summary = stringResource(Res.string.config_display_heading_bold_summary), - checked = formState.value.headingBold, + checked = formState.value.heading_bold ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { headingBold = it } }, + onCheckedChange = { formState.value = formState.value.copy(heading_bold = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -111,12 +108,9 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac title = stringResource(Res.string.display_units), summary = stringResource(Res.string.config_display_units_summary), enabled = state.connected, - items = - DisplayConfig.DisplayUnits.entries - .filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.units, - onItemSelected = { formState.value = formState.value.copy { units = it } }, + items = Config.DisplayConfig.DisplayUnits.entries.map { it to it.name }, + selectedItem = formState.value.units ?: Config.DisplayConfig.DisplayUnits.METRIC, + onItemSelected = { formState.value = formState.value.copy(units = it) }, ) } } @@ -130,9 +124,9 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac enabled = state.connected, items = screenOnIntervals.map { it to it.toDisplayString() }, selectedItem = - screenOnIntervals.find { it.value == formState.value.screenOnSecs.toLong() } + screenOnIntervals.find { it.value == (formState.value.screen_on_secs ?: 0).toLong() } ?: screenOnIntervals.first(), - onItemSelected = { formState.value = formState.value.copy { screenOnSecs = it.value.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(screen_on_secs = it.value.toInt()) }, ) HorizontalDivider() DropDownPreference( @@ -141,28 +135,28 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac enabled = state.connected, items = carouselIntervals.map { it to it.toDisplayString() }, selectedItem = - carouselIntervals.find { it.value == formState.value.autoScreenCarouselSecs.toLong() } + carouselIntervals.find { it.value == (formState.value.auto_screen_carousel_secs ?: 0).toLong() } ?: carouselIntervals.first(), onItemSelected = { - formState.value = formState.value.copy { autoScreenCarouselSecs = it.value.toInt() } + formState.value = formState.value.copy(auto_screen_carousel_secs = it.value.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.wake_on_tap_or_motion), summary = stringResource(Res.string.config_display_wake_on_tap_or_motion_summary), - checked = formState.value.wakeOnTapOrMotion, + checked = formState.value.wake_on_tap_or_motion ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { wakeOnTapOrMotion = it } }, + onCheckedChange = { formState.value = formState.value.copy(wake_on_tap_or_motion = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.flip_screen), summary = stringResource(Res.string.config_display_flip_screen_summary), - checked = formState.value.flipScreen, + checked = formState.value.flip_screen ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { flipScreen = it } }, + onCheckedChange = { formState.value = formState.value.copy(flip_screen = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -170,35 +164,27 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac title = stringResource(Res.string.display_mode), summary = stringResource(Res.string.config_display_displaymode_summary), enabled = state.connected, - items = - DisplayConfig.DisplayMode.entries - .filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.displaymode, - onItemSelected = { formState.value = formState.value.copy { displaymode = it } }, + items = Config.DisplayConfig.DisplayMode.entries.map { it to it.name }, + selectedItem = formState.value.displaymode ?: Config.DisplayConfig.DisplayMode.DEFAULT, + onItemSelected = { formState.value = formState.value.copy(displaymode = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.oled_type), summary = stringResource(Res.string.config_display_oled_summary), enabled = state.connected, - items = - DisplayConfig.OledType.entries - .filter { it != DisplayConfig.OledType.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.oled, - onItemSelected = { formState.value = formState.value.copy { oled = it } }, + items = Config.DisplayConfig.OledType.entries.map { it to it.name }, + selectedItem = formState.value.oled ?: Config.DisplayConfig.OledType.OLED_AUTO, + onItemSelected = { formState.value = formState.value.copy(oled = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.compass_orientation), enabled = state.connected, - items = - DisplayConfig.CompassOrientation.entries - .filter { it != DisplayConfig.CompassOrientation.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.compassOrientation, - onItemSelected = { formState.value = formState.value.copy { compassOrientation = it } }, + items = Config.DisplayConfig.CompassOrientation.entries.map { it to it.name }, + selectedItem = + formState.value.compass_orientation ?: Config.DisplayConfig.CompassOrientation.DEGREES_0, + onItemSelected = { formState.value = formState.value.copy(compass_orientation = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialog.kt index 561e0f042..af6c599c9 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialog.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialog.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.layout.Arrangement @@ -39,15 +38,28 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.protobuf.Descriptors +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.cancel +import org.meshtastic.core.strings.channel_url +import org.meshtastic.core.strings.fixed_position +import org.meshtastic.core.strings.long_name +import org.meshtastic.core.strings.module_settings +import org.meshtastic.core.strings.radio_configuration import org.meshtastic.core.strings.save +import org.meshtastic.core.strings.short_name import org.meshtastic.core.ui.component.SwitchPreference -import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile +import org.meshtastic.proto.DeviceProfile -private const val SUPPORTED_FIELDS = 7 +private enum class ProfileField(val tag: Int, val labelRes: StringResource) { + LONG_NAME(1, Res.string.long_name), + SHORT_NAME(2, Res.string.short_name), + CHANNEL_URL(3, Res.string.channel_url), + CONFIG(4, Res.string.radio_configuration), + MODULE_CONFIG(5, Res.string.module_settings), + FIXED_POSITION(6, Res.string.fixed_position), +} @Suppress("LongMethod") @OptIn(ExperimentalLayoutApi::class) @@ -60,12 +72,19 @@ fun EditDeviceProfileDialog( modifier: Modifier = Modifier, ) { val state = remember { - val fields = - deviceProfile.descriptorForType.fields.filter { - it.number < SUPPORTED_FIELDS - } // TODO add ringtone & canned messages - mutableStateMapOf().apply { - putAll(fields.associateWith(deviceProfile::hasField)) + mutableStateMapOf().apply { + putAll( + ProfileField.entries.associateWith { field -> + when (field) { + ProfileField.LONG_NAME -> deviceProfile.long_name != null + ProfileField.SHORT_NAME -> deviceProfile.short_name != null + ProfileField.CHANNEL_URL -> deviceProfile.channel_url != null + ProfileField.CONFIG -> deviceProfile.config != null + ProfileField.MODULE_CONFIG -> deviceProfile.module_config != null + ProfileField.FIXED_POSITION -> deviceProfile.fixed_position != null + } + }, + ) } } @@ -84,17 +103,24 @@ fun EditDeviceProfileDialog( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), ) HorizontalDivider() - state.keys - .sortedBy { it.number } - .forEach { field -> - SwitchPreference( - title = field.name, - checked = state[field] == true, - enabled = deviceProfile.hasField(field), - onCheckedChange = { state[field] = it }, - padding = PaddingValues(0.dp), - ) - } + ProfileField.entries.forEach { field -> + val isAvailable = + when (field) { + ProfileField.LONG_NAME -> deviceProfile.long_name != null + ProfileField.SHORT_NAME -> deviceProfile.short_name != null + ProfileField.CHANNEL_URL -> deviceProfile.channel_url != null + ProfileField.CONFIG -> deviceProfile.config != null + ProfileField.MODULE_CONFIG -> deviceProfile.module_config != null + ProfileField.FIXED_POSITION -> deviceProfile.fixed_position != null + } + SwitchPreference( + title = stringResource(field.labelRes), + checked = state[field] == true, + enabled = isAvailable, + onCheckedChange = { state[field] = it }, + padding = PaddingValues(0.dp), + ) + } HorizontalDivider() } }, @@ -109,13 +135,29 @@ fun EditDeviceProfileDialog( Button( modifier = modifier.weight(1f), onClick = { - val builder = DeviceProfile.newBuilder() - deviceProfile.allFields.forEach { (field, value) -> - if (state[field] == true) { - builder.setField(field, value) - } - } - onConfirm(builder.build()) + val result = + DeviceProfile( + long_name = + if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null, + short_name = + if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null, + channel_url = + if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null, + config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null, + module_config = + if (state[ProfileField.MODULE_CONFIG] == true) { + deviceProfile.module_config + } else { + null + }, + fixed_position = + if (state[ProfileField.FIXED_POSITION] == true) { + deviceProfile.fixed_position + } else { + null + }, + ) + onConfirm(result) }, enabled = state.values.any { it }, ) { @@ -131,7 +173,7 @@ fun EditDeviceProfileDialog( private fun EditDeviceProfileDialogPreview() { EditDeviceProfileDialog( title = "Export configuration", - deviceProfile = DeviceProfile.getDefaultInstance(), + deviceProfile = DeviceProfile(), onConfirm = {}, onDismiss = {}, ) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt index a1ac41314..da63704d5 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.FolderOpen -import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -77,8 +77,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.gpioPins import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig import java.io.File private const val MAX_RINGTONE_SIZE = 230 @@ -91,7 +90,7 @@ fun ExternalNotificationConfigScreen( viewModel: RadioConfigViewModel = hiltViewModel(), ) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val extNotificationConfig = state.moduleConfig.externalNotification + val extNotificationConfig = state.moduleConfig.external_notification ?: ModuleConfig.ExternalNotificationConfig() val ringtone = state.ringtone val formState = rememberConfigState(initialValue = extNotificationConfig) var ringtoneInput by rememberSaveable(ringtone) { mutableStateOf(ringtone) } @@ -136,7 +135,7 @@ fun ExternalNotificationConfigScreen( viewModel.setRingtone(ringtoneInput) } if (formState.value != extNotificationConfig) { - val config = moduleConfig { externalNotification = formState.value } + val config = ModuleConfig(external_notification = formState.value) viewModel.setModuleConfig(config) } }, @@ -145,9 +144,9 @@ fun ExternalNotificationConfigScreen( TitledCard(title = stringResource(Res.string.external_notification_config)) { SwitchPreference( title = stringResource(Res.string.external_notification_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -157,25 +156,25 @@ fun ExternalNotificationConfigScreen( TitledCard(title = stringResource(Res.string.notifications_on_message_receipt)) { SwitchPreference( title = stringResource(Res.string.alert_message_led), - checked = formState.value.alertMessage, + checked = formState.value.alert_message ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { alertMessage = it } }, + onCheckedChange = { formState.value = formState.value.copy(alert_message = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_message_buzzer), - checked = formState.value.alertMessageBuzzer, + checked = formState.value.alert_message_buzzer ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { alertMessageBuzzer = it } }, + onCheckedChange = { formState.value = formState.value.copy(alert_message_buzzer = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_message_vibra), - checked = formState.value.alertMessageVibra, + checked = formState.value.alert_message_vibra ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { alertMessageVibra = it } }, + onCheckedChange = { formState.value = formState.value.copy(alert_message_vibra = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -185,25 +184,25 @@ fun ExternalNotificationConfigScreen( TitledCard(title = stringResource(Res.string.notifications_on_alert_bell_receipt)) { SwitchPreference( title = stringResource(Res.string.alert_bell_led), - checked = formState.value.alertBell, + checked = formState.value.alert_bell ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { alertBell = it } }, + onCheckedChange = { formState.value = formState.value.copy(alert_bell = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_bell_buzzer), - checked = formState.value.alertBellBuzzer, + checked = formState.value.alert_bell_buzzer ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { alertBellBuzzer = it } }, + onCheckedChange = { formState.value = formState.value.copy(alert_bell_buzzer = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.alert_bell_vibra), - checked = formState.value.alertBellVibra, + checked = formState.value.alert_bell_vibra ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { alertBellVibra = it } }, + onCheckedChange = { formState.value = formState.value.copy(alert_bell_vibra = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -215,17 +214,17 @@ fun ExternalNotificationConfigScreen( DropDownPreference( title = stringResource(Res.string.output_led_gpio), items = gpio, - selectedItem = formState.value.output, + selectedItem = (formState.value.output ?: 0).toLong(), enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy { output = it } }, + onItemSelected = { formState.value = formState.value.copy(output = it.toInt()) }, ) - if (formState.value.output != 0) { + if (formState.value.output ?: 0 != 0) { HorizontalDivider() SwitchPreference( title = stringResource(Res.string.output_led_active_high), - checked = formState.value.active, + checked = formState.value.active ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { active = it } }, + onCheckedChange = { formState.value = formState.value.copy(active = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -233,17 +232,17 @@ fun ExternalNotificationConfigScreen( DropDownPreference( title = stringResource(Res.string.output_buzzer_gpio), items = gpio, - selectedItem = formState.value.outputBuzzer, + selectedItem = (formState.value.output_buzzer ?: 0).toLong(), enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy { outputBuzzer = it } }, + onItemSelected = { formState.value = formState.value.copy(output_buzzer = it.toInt()) }, ) - if (formState.value.outputBuzzer != 0) { + if (formState.value.output_buzzer ?: 0 != 0) { HorizontalDivider() SwitchPreference( title = stringResource(Res.string.use_pwm_buzzer), - checked = formState.value.usePwm, + checked = formState.value.use_pwm ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { usePwm = it } }, + onCheckedChange = { formState.value = formState.value.copy(use_pwm = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -251,27 +250,27 @@ fun ExternalNotificationConfigScreen( DropDownPreference( title = stringResource(Res.string.output_vibra_gpio), items = gpio, - selectedItem = formState.value.outputVibra, + selectedItem = (formState.value.output_vibra ?: 0).toLong(), enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy { outputVibra = it } }, + onItemSelected = { formState.value = formState.value.copy(output_vibra = it.toInt()) }, ) HorizontalDivider() val outputItems = remember { IntervalConfiguration.OUTPUT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.output_duration_milliseconds), items = outputItems.map { it.value to it.toDisplayString() }, - selectedItem = formState.value.outputMs.toLong(), + selectedItem = (formState.value.output_ms ?: 0).toLong(), enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy { outputMs = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(output_ms = it.toInt()) }, ) HorizontalDivider() val nagItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.nag_timeout_seconds), items = nagItems.map { it.value to it.toDisplayString() }, - selectedItem = formState.value.nagTimeout.toLong(), + selectedItem = (formState.value.nag_timeout ?: 0).toLong(), enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy { nagTimeout = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(nag_timeout = it.toInt()) }, ) HorizontalDivider() EditTextPreference( @@ -288,7 +287,7 @@ fun ExternalNotificationConfigScreen( Row { IconButton(onClick = { launcher.launch("*/*") }, enabled = state.connected) { Icon( - Icons.Rounded.FolderOpen, + Icons.Default.FolderOpen, contentDescription = stringResource(Res.string.import_label), ) } @@ -312,7 +311,7 @@ fun ExternalNotificationConfigScreen( }, enabled = state.connected, ) { - Icon(Icons.Rounded.PlayArrow, contentDescription = stringResource(Res.string.play)) + Icon(Icons.Default.PlayArrow, contentDescription = stringResource(Res.string.play)) } } }, @@ -320,9 +319,9 @@ fun ExternalNotificationConfigScreen( HorizontalDivider() SwitchPreference( title = stringResource(Res.string.use_i2s_as_buzzer), - checked = formState.value.useI2SAsBuzzer, + checked = formState.value.use_i2s_as_buzzer ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { useI2SAsBuzzer = it } }, + onCheckedChange = { formState.value = formState.value.copy(use_i2s_as_buzzer = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index 8e2a46754..dd4cbbc86 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -60,16 +60,14 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard -import org.meshtastic.core.ui.util.labelRes import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.hopLimits -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config @Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val loraConfig = state.radioConfig.lora + val loraConfig = state.radioConfig.lora ?: Config.LoRaConfig() val primarySettings = state.channelList.getOrNull(0) ?: return val formState = rememberConfigState(initialValue = loraConfig) @@ -84,7 +82,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = config { lora = it } + val config = Config(lora = it) viewModel.setConfig(config) }, ) { @@ -96,49 +94,49 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { enabled = state.connected, items = RegionInfo.entries.map { it.regionCode to it.description }, selectedItem = formState.value.region, - onItemSelected = { formState.value = formState.value.copy { region = it } }, + onItemSelected = { formState.value = formState.value.copy(region = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.use_modem_preset), - checked = formState.value.usePreset, + checked = formState.value.use_preset, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { usePreset = it } }, + onCheckedChange = { formState.value = formState.value.copy(use_preset = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - if (formState.value.usePreset) { + if (formState.value.use_preset) { DropDownPreference( title = stringResource(Res.string.modem_preset), summary = stringResource(Res.string.config_lora_modem_preset_summary), - enabled = state.connected && formState.value.usePreset, - items = ChannelOption.entries.map { it.modemPreset to stringResource(it.labelRes) }, - selectedItem = formState.value.modemPreset, - onItemSelected = { formState.value = formState.value.copy { modemPreset = it } }, + enabled = state.connected && formState.value.use_preset, + items = ChannelOption.entries.map { it.modemPreset to it.modemPreset.name }, + selectedItem = formState.value.modem_preset, + onItemSelected = { formState.value = formState.value.copy(modem_preset = it) }, ) } else { EditTextPreference( title = stringResource(Res.string.bandwidth), value = formState.value.bandwidth, - enabled = state.connected && !formState.value.usePreset, + enabled = state.connected && !formState.value.use_preset, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { bandwidth = it } }, + onValueChanged = { formState.value = formState.value.copy(bandwidth = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.spread_factor), - value = formState.value.spreadFactor, - enabled = state.connected && !formState.value.usePreset, + value = formState.value.spread_factor, + enabled = state.connected && !formState.value.use_preset, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { spreadFactor = it } }, + onValueChanged = { formState.value = formState.value.copy(spread_factor = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.coding_rate), - value = formState.value.codingRate, - enabled = state.connected && !formState.value.usePreset, + value = formState.value.coding_rate, + enabled = state.connected && !formState.value.use_preset, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { codingRate = it } }, + onValueChanged = { formState.value = formState.value.copy(coding_rate = it) }, ) } } @@ -148,33 +146,33 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { TitledCard(title = stringResource(Res.string.advanced)) { SwitchPreference( title = stringResource(Res.string.ignore_mqtt), - checked = formState.value.ignoreMqtt, + checked = formState.value.ignore_mqtt, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { ignoreMqtt = it } }, + onCheckedChange = { formState.value = formState.value.copy(ignore_mqtt = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.ok_to_mqtt), - checked = formState.value.configOkToMqtt, + checked = formState.value.config_ok_to_mqtt, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { configOkToMqtt = it } }, + onCheckedChange = { formState.value = formState.value.copy(config_ok_to_mqtt = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.tx_enabled), - checked = formState.value.txEnabled, + checked = formState.value.tx_enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { txEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(tx_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.override_duty_cycle), - checked = formState.value.overrideDutyCycle, + checked = formState.value.override_duty_cycle, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { overrideDutyCycle = it } }, + onCheckedChange = { formState.value = formState.value.copy(override_duty_cycle = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -183,8 +181,8 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { title = stringResource(Res.string.hop_limit), summary = stringResource(Res.string.config_lora_hop_limit_summary), items = hopLimitItems, - selectedItem = formState.value.hopLimit, - onItemSelected = { formState.value = formState.value.copy { hopLimit = it } }, + selectedItem = formState.value.hop_limit.toLong(), + onItemSelected = { formState.value = formState.value.copy(hop_limit = it.toInt()) }, enabled = state.connected, ) HorizontalDivider() @@ -193,8 +191,8 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { title = stringResource(Res.string.frequency_slot), summary = stringResource(Res.string.config_lora_frequency_slot_summary), value = - if (isFocusedSlot || formState.value.channelNum != 0) { - formState.value.channelNum + if (isFocusedSlot || formState.value.channel_num != 0) { + formState.value.channel_num } else { primaryChannel.channelNum }, @@ -203,16 +201,16 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onFocusChanged = { isFocusedSlot = it.isFocused }, onValueChanged = { if (it <= formState.value.numChannels) { // total num of LoRa channels - formState.value = formState.value.copy { channelNum = it } + formState.value = formState.value.copy(channel_num = it) } }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.sx126x_rx_boosted_gain), - checked = formState.value.sx126XRxBoostedGain, + checked = formState.value.sx126x_rx_boosted_gain, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { sx126XRxBoostedGain = it } }, + onCheckedChange = { formState.value = formState.value.copy(sx126x_rx_boosted_gain = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -220,31 +218,31 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { EditTextPreference( title = stringResource(Res.string.override_frequency_mhz), value = - if (isFocusedOverride || formState.value.overrideFrequency != 0f) { - formState.value.overrideFrequency + if (isFocusedOverride || formState.value.override_frequency != 0f) { + formState.value.override_frequency } else { primaryChannel.radioFreq }, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onFocusChanged = { isFocusedOverride = it.isFocused }, - onValueChanged = { formState.value = formState.value.copy { overrideFrequency = it } }, + onValueChanged = { formState.value = formState.value.copy(override_frequency = it) }, ) HorizontalDivider() SignedIntegerEditTextPreference( title = stringResource(Res.string.tx_power_dbm), - value = formState.value.txPower, + value = formState.value.tx_power, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { txPower = it } }, + onValueChanged = { formState.value = formState.value.copy(tx_power = it) }, ) if (viewModel.hasPaFan) { HorizontalDivider() SwitchPreference( title = stringResource(Res.string.pa_fan_disabled), - checked = formState.value.paFanDisabled, + checked = formState.value.pa_fan_disabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { paFanDisabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(pa_fan_disabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt index 2e204658b..e043cdaa9 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.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 . */ - @file:Suppress("LongMethod") package org.meshtastic.feature.settings.radio.component @@ -50,29 +49,26 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsStateWithLifecycle() val destNum = destNode?.num - val mqttConfig = state.moduleConfig.mqtt + val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig() val formState = rememberConfigState(initialValue = mqttConfig) - if (!formState.value.mapReportSettings.shouldReportLocation) { - val settings = - formState.value.mapReportSettings.copy { - this.shouldReportLocation = viewModel.shouldReportLocation(destNum) - } - formState.value = formState.value.copy { mapReportSettings = settings } + val currentMapReportSettings = formState.value.map_report_settings ?: ModuleConfig.MapReportSettings() + if (!(currentMapReportSettings.should_report_location ?: false)) { + val settings = currentMapReportSettings.copy(should_report_location = viewModel.shouldReportLocation(destNum)) + formState.value = formState.value.copy(map_report_settings = settings) } val consentValid = - if (formState.value.mapReportingEnabled) { - formState.value.mapReportSettings.shouldReportLocation && - formState.value.mapReportSettings.publishIntervalSecs >= MIN_INTERVAL_SECS + if (formState.value.map_reporting_enabled ?: false) { + (formState.value.map_report_settings?.should_report_location ?: false) && + (formState.value.map_report_settings?.publish_interval_secs ?: 0) >= MIN_INTERVAL_SECS } else { true } @@ -86,7 +82,7 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { mqtt = it } + val config = ModuleConfig(mqtt = it) viewModel.setModuleConfig(config) }, ) { @@ -94,89 +90,91 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: TitledCard(title = stringResource(Res.string.mqtt_config)) { SwitchPreference( title = stringResource(Res.string.mqtt_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.address), - value = formState.value.address, + value = formState.value.address ?: "", maxSize = 63, // address max_size:64 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { address = it } }, + onValueChanged = { formState.value = formState.value.copy(address = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.username), - value = formState.value.username, + value = formState.value.username ?: "", maxSize = 63, // username max_size:64 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { username = it } }, + onValueChanged = { formState.value = formState.value.copy(username = it) }, ) HorizontalDivider() EditPasswordPreference( title = stringResource(Res.string.password), - value = formState.value.password, + value = formState.value.password ?: "", maxSize = 63, // password max_size:64 enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { password = it } }, + onValueChanged = { formState.value = formState.value.copy(password = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.encryption_enabled), - checked = formState.value.encryptionEnabled, + checked = formState.value.encryption_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { encryptionEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(encryption_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.json_output_enabled), - checked = formState.value.jsonEnabled, + checked = formState.value.json_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { jsonEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(json_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() val defaultAddress = stringResource(Res.string.default_mqtt_address) - val isDefault = formState.value.address.isEmpty() || formState.value.address.contains(defaultAddress) - val enforceTls = isDefault && formState.value.proxyToClientEnabled + val isDefault = + (formState.value.address ?: "").isEmpty() || + (formState.value.address ?: "").contains(defaultAddress) + val enforceTls = isDefault && (formState.value.proxy_to_client_enabled ?: false) SwitchPreference( title = stringResource(Res.string.tls_enabled), - checked = formState.value.tlsEnabled || enforceTls, + checked = (formState.value.tls_enabled ?: false) || enforceTls, enabled = state.connected && !enforceTls, - onCheckedChange = { formState.value = formState.value.copy { tlsEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(tls_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.root_topic), - value = formState.value.root, + value = formState.value.root ?: "", maxSize = 31, // root max_size:32 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { root = it } }, + onValueChanged = { formState.value = formState.value.copy(root = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.proxy_to_client_enabled), - checked = formState.value.proxyToClientEnabled, + checked = formState.value.proxy_to_client_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { proxyToClientEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(proxy_to_client_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -184,26 +182,27 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: item { TitledCard(title = stringResource(Res.string.map_reporting)) { + val mapReportSettings = formState.value.map_report_settings ?: ModuleConfig.MapReportSettings() MapReportingPreference( - mapReportingEnabled = formState.value.mapReportingEnabled, + mapReportingEnabled = formState.value.map_reporting_enabled ?: false, onMapReportingEnabledChanged = { - formState.value = formState.value.copy { mapReportingEnabled = it } + formState.value = formState.value.copy(map_reporting_enabled = it) }, - shouldReportLocation = formState.value.mapReportSettings.shouldReportLocation, + shouldReportLocation = mapReportSettings.should_report_location ?: false, onShouldReportLocationChanged = { viewModel.setShouldReportLocation(destNum, it) - val settings = formState.value.mapReportSettings.copy { this.shouldReportLocation = it } - formState.value = formState.value.copy { mapReportSettings = settings } + val settings = mapReportSettings.copy(should_report_location = it) + formState.value = formState.value.copy(map_report_settings = settings) }, - positionPrecision = formState.value.mapReportSettings.positionPrecision, + positionPrecision = mapReportSettings.position_precision ?: 0, onPositionPrecisionChanged = { - val settings = formState.value.mapReportSettings.copy { positionPrecision = it } - formState.value = formState.value.copy { mapReportSettings = settings } + val settings = mapReportSettings.copy(position_precision = it) + formState.value = formState.value.copy(map_report_settings = settings) }, - publishIntervalSecs = formState.value.mapReportSettings.publishIntervalSecs, + publishIntervalSecs = mapReportSettings.publish_interval_secs ?: 0, onPublishIntervalSecsChanged = { - val settings = formState.value.mapReportSettings.copy { publishIntervalSecs = it } - formState.value = formState.value.copy { mapReportSettings = settings } + val settings = mapReportSettings.copy(publish_interval_secs = it) + formState.value = formState.value.copy(map_report_settings = settings) }, enabled = state.connected, ) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NeighborInfoConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NeighborInfoConfigItemList.kt index fed1865b3..8a18f8eea 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NeighborInfoConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NeighborInfoConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -37,13 +36,12 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val neighborInfoConfig = state.moduleConfig.neighborInfo + val neighborInfoConfig = state.moduleConfig.neighbor_info ?: ModuleConfig.NeighborInfoConfig() val formState = rememberConfigState(initialValue = neighborInfoConfig) val focusManager = LocalFocusManager.current @@ -55,7 +53,7 @@ fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { neighborInfo = it } + val config = ModuleConfig(neighbor_info = it) viewModel.setModuleConfig(config) }, ) { @@ -63,26 +61,26 @@ fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), TitledCard(title = stringResource(Res.string.neighbor_info_config)) { SwitchPreference( title = stringResource(Res.string.neighbor_info_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.update_interval_seconds), - value = formState.value.updateInterval, + value = formState.value.update_interval ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { updateInterval = it } }, + onValueChanged = { formState.value = formState.value.copy(update_interval = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.transmit_over_lora), summary = stringResource(Res.string.config_device_transmitOverLora_summary), - checked = formState.value.transmitOverLora, + checked = formState.value.transmit_over_lora ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { transmitOverLora = it } }, + onCheckedChange = { formState.value = formState.value.copy(transmit_over_lora = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt index 70d4e9ac4..73dda2c55 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.activity.compose.rememberLauncherForActivityResult @@ -61,7 +60,6 @@ import org.meshtastic.core.strings.password import org.meshtastic.core.strings.rsyslog_server import org.meshtastic.core.strings.ssid import org.meshtastic.core.strings.subnet -import org.meshtastic.core.strings.udp_config import org.meshtastic.core.strings.udp_enabled import org.meshtastic.core.strings.wifi_config import org.meshtastic.core.strings.wifi_enabled @@ -77,9 +75,7 @@ import org.meshtastic.core.ui.component.SimpleAlertDialog import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.ConfigProtos.Config.NetworkConfig -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config @Composable private fun ScanErrorDialog(onDismiss: () -> Unit = {}) = @@ -88,7 +84,7 @@ private fun ScanErrorDialog(onDismiss: () -> Unit = {}) = @Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val networkConfig = state.radioConfig.network + val networkConfig = state.radioConfig.network ?: Config.NetworkConfig() val formState = rememberConfigState(initialValue = networkConfig) var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) } @@ -101,11 +97,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac if (result.contents != null) { val (ssid, psk) = extractWifiCredentials(result.contents) if (ssid != null && psk != null) { - formState.value = - formState.value.copy { - wifiSsid = ssid - wifiPsk = psk - } + formState.value = formState.value.copy(wifi_ssid = ssid, wifi_psk = psk) } else { showScanErrorDialog = true } @@ -132,32 +124,31 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = config { network = it } + val config = Config(network = it) viewModel.setConfig(config) }, ) { // Display device connection status state.deviceConnectionStatus?.let { connectionStatus -> - if ( - connectionStatus.wifi?.status?.isConnected == true || - connectionStatus.ethernet?.status?.isConnected == true - ) { + val ws = connectionStatus.wifi?.status + val es = connectionStatus.ethernet?.status + if (ws?.is_connected == true || es?.is_connected == true) { item { TitledCard(title = stringResource(Res.string.connection_status)) { - connectionStatus.wifi?.let { wifiStatus -> - if (wifiStatus.status.isConnected) { + ws?.let { wifiStatus -> + if (wifiStatus.is_connected) { ListItem( text = stringResource(Res.string.wifi_ip), - supportingText = formatIpAddress(wifiStatus.status.ipAddress), + supportingText = formatIpAddress(wifiStatus.ip_address ?: 0), trailingIcon = null, ) } } - connectionStatus.ethernet?.let { ethernetStatus -> - if (ethernetStatus.status.isConnected) { + es?.let { ethernetStatus -> + if (ethernetStatus.is_connected) { ListItem( text = stringResource(Res.string.ethernet_ip), - supportingText = formatIpAddress(ethernetStatus.status.ipAddress), + supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0), trailingIcon = null, ) } @@ -172,31 +163,31 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac SwitchPreference( title = stringResource(Res.string.wifi_enabled), summary = stringResource(Res.string.config_network_wifi_enabled_summary), - checked = formState.value.wifiEnabled, + checked = formState.value.wifi_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { wifiEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.ssid), - value = formState.value.wifiSsid, + value = formState.value.wifi_ssid ?: "", maxSize = 32, // wifi_ssid max_size:33 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { wifiSsid = it } }, + onValueChanged = { formState.value = formState.value.copy(wifi_ssid = it) }, ) HorizontalDivider() EditPasswordPreference( title = stringResource(Res.string.password), - value = formState.value.wifiPsk, + value = formState.value.wifi_psk ?: "", maxSize = 64, // wifi_psk max_size:65 enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { wifiPsk = it } }, + onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) }, ) HorizontalDivider() Button( @@ -215,9 +206,9 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac SwitchPreference( title = stringResource(Res.string.ethernet_enabled), summary = stringResource(Res.string.config_network_eth_enabled_summary), - checked = formState.value.ethEnabled, + checked = formState.value.eth_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { ethEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -226,15 +217,14 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) { item { - TitledCard(title = stringResource(Res.string.udp_config)) { + TitledCard(title = stringResource(Res.string.network)) { SwitchPreference( title = stringResource(Res.string.udp_enabled), summary = stringResource(Res.string.config_network_udp_enabled_summary), - checked = formState.value.enabledProtocols == 1, + checked = (formState.value.enabled_protocols ?: 0) == 1, enabled = state.connected, onCheckedChange = { - formState.value = - formState.value.copy { if (it) enabledProtocols = 1 else enabledProtocols = 0 } + formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0) }, containerColor = CardDefaults.cardColors().containerColor, ) @@ -246,81 +236,71 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac TitledCard(title = stringResource(Res.string.advanced)) { EditTextPreference( title = stringResource(Res.string.ntp_server), - value = formState.value.ntpServer, + value = formState.value.ntp_server ?: "", maxSize = 32, // ntp_server max_size:33 enabled = state.connected, - isError = formState.value.ntpServer.isEmpty(), + isError = formState.value.ntp_server?.isEmpty() ?: true, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { ntpServer = it } }, + onValueChanged = { formState.value = formState.value.copy(ntp_server = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.rsyslog_server), - value = formState.value.rsyslogServer, + value = formState.value.rsyslog_server ?: "", maxSize = 32, // rsyslog_server max_size:33 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { rsyslogServer = it } }, + onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.ipv4_mode), enabled = state.connected, - items = - NetworkConfig.AddressMode.entries - .filter { it != NetworkConfig.AddressMode.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.addressMode, - onItemSelected = { formState.value = formState.value.copy { addressMode = it } }, + items = Config.NetworkConfig.AddressMode.entries.map { it to it.name }, + selectedItem = formState.value.address_mode ?: Config.NetworkConfig.AddressMode.DHCP, + onItemSelected = { formState.value = formState.value.copy(address_mode = it) }, ) HorizontalDivider() + val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config() EditIPv4Preference( title = stringResource(Res.string.ip), - value = formState.value.ipv4Config.ip, - enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC, + value = ipv4.ip ?: 0, + enabled = + state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - val ipv4 = formState.value.ipv4Config.copy { ip = it } - formState.value = formState.value.copy { ipv4Config = ipv4 } - }, + onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) }, ) HorizontalDivider() EditIPv4Preference( title = stringResource(Res.string.gateway), - value = formState.value.ipv4Config.gateway, - enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC, + value = ipv4.gateway ?: 0, + enabled = + state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - val ipv4 = formState.value.ipv4Config.copy { gateway = it } - formState.value = formState.value.copy { ipv4Config = ipv4 } - }, + onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) }, ) HorizontalDivider() EditIPv4Preference( title = stringResource(Res.string.subnet), - value = formState.value.ipv4Config.subnet, - enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC, + value = ipv4.subnet ?: 0, + enabled = + state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - val ipv4 = formState.value.ipv4Config.copy { subnet = it } - formState.value = formState.value.copy { ipv4Config = ipv4 } - }, + onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) }, ) HorizontalDivider() EditIPv4Preference( title = "DNS", - value = formState.value.ipv4Config.dns, - enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC, + value = ipv4.dns ?: 0, + enabled = + state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - val ipv4 = formState.value.ipv4Config.copy { dns = it } - formState.value = formState.value.copy { ipv4Config = ipv4 } - }, + onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PaxcounterConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PaxcounterConfigItemList.kt index 293ed349d..c32a54eb3 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PaxcounterConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PaxcounterConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -41,13 +40,12 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val paxcounterConfig = state.moduleConfig.paxcounter + val paxcounterConfig = state.moduleConfig.paxcounter ?: ModuleConfig.PaxcounterConfig() val formState = rememberConfigState(initialValue = paxcounterConfig) val focusManager = LocalFocusManager.current @@ -59,7 +57,7 @@ fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), on responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { paxcounter = it } + val config = ModuleConfig(paxcounter = it) viewModel.setModuleConfig(config) }, ) { @@ -67,37 +65,37 @@ fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), on TitledCard(title = stringResource(Res.string.paxcounter_config)) { SwitchPreference( title = stringResource(Res.string.paxcounter_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() val items = remember { IntervalConfiguration.PAX_COUNTER.allowedIntervals } DropDownPreference( title = stringResource(Res.string.update_interval_seconds), - selectedItem = formState.value.paxcounterUpdateInterval.toLong(), + selectedItem = (formState.value.paxcounter_update_interval ?: 0).toLong(), enabled = state.connected, items = items.map { it.value to it.toDisplayString() }, onItemSelected = { - formState.value = formState.value.copy { paxcounterUpdateInterval = it.toInt() } + formState.value = formState.value.copy(paxcounter_update_interval = it.toInt()) }, ) HorizontalDivider() SignedIntegerEditTextPreference( title = stringResource(Res.string.wifi_rssi_threshold_defaults_to_80), - value = formState.value.wifiThreshold, + value = formState.value.wifi_threshold ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { wifiThreshold = it } }, + onValueChanged = { formState.value = formState.value.copy(wifi_threshold = it) }, ) HorizontalDivider() SignedIntegerEditTextPreference( title = stringResource(Res.string.ble_rssi_threshold_defaults_to_80), - value = formState.value.bleThreshold, + value = formState.value.ble_threshold ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { bleThreshold = it } }, + onValueChanged = { formState.value = formState.value.copy(ble_threshold = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt index 1c43ba1a0..92770409b 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.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 org.meshtastic.feature.settings.radio.component import android.Manifest @@ -78,9 +77,7 @@ import org.meshtastic.feature.settings.util.FixedUpdateIntervals import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.gpioPins import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.ConfigProtos.Config.PositionConfig -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -96,22 +93,23 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa altitude = node?.position?.altitude ?: 0, time = 1, // ignore time for fixed_position ) - val positionConfig = state.radioConfig.position + val positionConfig = state.radioConfig.position ?: Config.PositionConfig() val sanitizedPositionConfig = remember(positionConfig) { val positionItems = IntervalConfiguration.POSITION.allowedIntervals val smartBroadcastItems = IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals - positionConfig.copy { - if (FixedUpdateIntervals.fromValue(positionBroadcastSecs.toLong()) == null) { - positionBroadcastSecs = positionItems.first().value.toInt() - } - if (FixedUpdateIntervals.fromValue(broadcastSmartMinimumIntervalSecs.toLong()) == null) { - broadcastSmartMinimumIntervalSecs = smartBroadcastItems.first().value.toInt() - } - if (FixedUpdateIntervals.fromValue(gpsUpdateInterval.toLong()) == null) { - gpsUpdateInterval = positionItems.first().value.toInt() - } + var updated = positionConfig + if (FixedUpdateIntervals.fromValue((updated.position_broadcast_secs ?: 0).toLong()) == null) { + updated = updated.copy(position_broadcast_secs = positionItems.first().value.toInt()) } + if (FixedUpdateIntervals.fromValue((updated.broadcast_smart_minimum_interval_secs ?: 0).toLong()) == null) { + updated = + updated.copy(broadcast_smart_minimum_interval_secs = smartBroadcastItems.first().value.toInt()) + } + if (FixedUpdateIntervals.fromValue((updated.gps_update_interval ?: 0).toLong()) == null) { + updated = updated.copy(gps_update_interval = positionItems.first().value.toInt()) + } + updated } val formState = rememberConfigState(initialValue = sanitizedPositionConfig) var locationInput by rememberSaveable { mutableStateOf(currentPosition) } @@ -152,17 +150,17 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa additionalDirtyCheck = { locationInput != currentPosition }, onDiscard = { locationInput = currentPosition }, onSave = { - if (formState.value.fixedPosition) { + if (formState.value.fixed_position) { if (locationInput != currentPosition) { viewModel.setFixedPosition(locationInput) } } else { - if (positionConfig.fixedPosition) { + if (positionConfig.fixed_position) { // fixed position changed from enabled to disabled viewModel.removeFixedPosition() } } - val config = config { position = it } + val config = Config(position = it) viewModel.setConfig(config) }, ) { @@ -175,20 +173,21 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa enabled = state.connected, items = items.map { it to it.toDisplayString() }, selectedItem = - FixedUpdateIntervals.fromValue(formState.value.positionBroadcastSecs.toLong()) ?: items.first(), + FixedUpdateIntervals.fromValue((formState.value.position_broadcast_secs ?: 0).toLong()) + ?: items.first(), onItemSelected = { - formState.value = formState.value.copy { positionBroadcastSecs = it.value.toInt() } + formState.value = formState.value.copy(position_broadcast_secs = it.value.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.smart_position), - checked = formState.value.positionBroadcastSmartEnabled, + checked = formState.value.position_broadcast_smart_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { positionBroadcastSmartEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(position_broadcast_smart_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) - if (formState.value.positionBroadcastSmartEnabled) { + if (formState.value.position_broadcast_smart_enabled ?: false) { HorizontalDivider() val smartItems = remember { IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals } DropDownPreference( @@ -198,22 +197,23 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa enabled = state.connected, items = smartItems.map { it to it.toDisplayString() }, selectedItem = - FixedUpdateIntervals.fromValue(formState.value.broadcastSmartMinimumIntervalSecs.toLong()) - ?: smartItems.first(), + FixedUpdateIntervals.fromValue( + (formState.value.broadcast_smart_minimum_interval_secs ?: 0).toLong(), + ) ?: smartItems.first(), onItemSelected = { formState.value = - formState.value.copy { broadcastSmartMinimumIntervalSecs = it.value.toInt() } + formState.value.copy(broadcast_smart_minimum_interval_secs = it.value.toInt()) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.minimum_distance), summary = stringResource(Res.string.config_position_broadcast_smart_minimum_distance_summary), - value = formState.value.broadcastSmartMinimumDistance, + value = formState.value.broadcast_smart_minimum_distance ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { - formState.value = formState.value.copy { broadcastSmartMinimumDistance = it } + formState.value = formState.value.copy(broadcast_smart_minimum_distance = it) }, ) } @@ -223,12 +223,12 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa TitledCard(title = stringResource(Res.string.device_gps)) { SwitchPreference( title = stringResource(Res.string.fixed_position), - checked = formState.value.fixedPosition, + checked = formState.value.fixed_position ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { fixedPosition = it } }, + onCheckedChange = { formState.value = formState.value.copy(fixed_position = it) }, containerColor = CardDefaults.cardColors().containerColor, ) - if (formState.value.fixedPosition) { + if (formState.value.fixed_position ?: false) { HorizontalDivider() EditTextPreference( title = stringResource(Res.string.latitude), @@ -273,12 +273,9 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa DropDownPreference( title = stringResource(Res.string.gps_mode), enabled = state.connected, - items = - PositionConfig.GpsMode.entries - .filter { it != PositionConfig.GpsMode.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.gpsMode, - onItemSelected = { formState.value = formState.value.copy { gpsMode = it } }, + items = Config.PositionConfig.GpsMode.entries.map { it to it.name }, + selectedItem = formState.value.gps_mode ?: Config.PositionConfig.GpsMode.DISABLED, + onItemSelected = { formState.value = formState.value.copy(gps_mode = it) }, ) HorizontalDivider() val items = remember { IntervalConfiguration.GPS_UPDATE.allowedIntervals } @@ -288,9 +285,10 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa enabled = state.connected, items = items.map { it to it.toDisplayString() }, selectedItem = - FixedUpdateIntervals.fromValue(formState.value.gpsUpdateInterval.toLong()) ?: items.first(), + FixedUpdateIntervals.fromValue((formState.value.gps_update_interval ?: 0).toLong()) + ?: items.first(), onItemSelected = { - formState.value = formState.value.copy { gpsUpdateInterval = it.value.toInt() } + formState.value = formState.value.copy(gps_update_interval = it.value.toInt()) }, ) } @@ -301,16 +299,13 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa BitwisePreference( title = stringResource(Res.string.position_flags), summary = stringResource(Res.string.config_position_flags_summary), - value = formState.value.positionFlags, + value = formState.value.position_flags ?: 0, enabled = state.connected, items = - PositionConfig.PositionFlags.entries - .filter { - it != PositionConfig.PositionFlags.UNSET && - it != PositionConfig.PositionFlags.UNRECOGNIZED - } - .map { it.number to it.name }, - onItemSelected = { formState.value = formState.value.copy { positionFlags = it } }, + Config.PositionConfig.PositionFlags.entries + .filter { it != Config.PositionConfig.PositionFlags.UNSET } + .map { it.value to it.name }, + onItemSelected = { formState.value = formState.value.copy(position_flags = it) }, ) } } @@ -321,24 +316,24 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa title = stringResource(Res.string.gps_receive_gpio), enabled = state.connected, items = pins, - selectedItem = formState.value.rxGpio, - onItemSelected = { formState.value = formState.value.copy { rxGpio = it } }, + selectedItem = formState.value.rx_gpio ?: 0, + onItemSelected = { formState.value = formState.value.copy(rx_gpio = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.gps_transmit_gpio), enabled = state.connected, items = pins, - selectedItem = formState.value.txGpio, - onItemSelected = { formState.value = formState.value.copy { txGpio = it } }, + selectedItem = formState.value.tx_gpio ?: 0, + onItemSelected = { formState.value = formState.value.copy(tx_gpio = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.gps_en_gpio), enabled = state.connected, items = pins, - selectedItem = formState.value.gpsEnGpio, - onItemSelected = { formState.value = formState.value.copy { gpsEnGpio = it } }, + selectedItem = formState.value.gps_en_gpio ?: 0, + onItemSelected = { formState.value = formState.value.copy(gps_en_gpio = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PowerConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PowerConfigItemList.kt index c8fee4e2a..1321dc885 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PowerConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PowerConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -46,13 +45,12 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config @Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val powerConfig = state.radioConfig.power + val powerConfig = state.radioConfig.power ?: Config.PowerConfig() val formState = rememberConfigState(initialValue = powerConfig) val focusManager = LocalFocusManager.current @@ -64,7 +62,7 @@ fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = config { power = it } + val config = Config(power = it) viewModel.setConfig(config) }, ) { @@ -73,57 +71,57 @@ fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: SwitchPreference( title = stringResource(Res.string.enable_power_saving_mode), summary = stringResource(Res.string.config_power_is_power_saving_summary), - checked = formState.value.isPowerSaving, + checked = formState.value.is_power_saving ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { isPowerSaving = it } }, + onCheckedChange = { formState.value = formState.value.copy(is_power_saving = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() val items = remember { IntervalConfiguration.ALL.allowedIntervals } DropDownPreference( title = stringResource(Res.string.shutdown_on_power_loss), - selectedItem = formState.value.onBatteryShutdownAfterSecs.toLong(), + selectedItem = (formState.value.on_battery_shutdown_after_secs ?: 0).toLong(), enabled = state.connected, items = items.map { it.value to it.toDisplayString() }, onItemSelected = { - formState.value = formState.value.copy { onBatteryShutdownAfterSecs = it.toInt() } + formState.value = formState.value.copy(on_battery_shutdown_after_secs = it.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.adc_multiplier_override), - checked = formState.value.adcMultiplierOverride > 0f, + checked = (formState.value.adc_multiplier_override ?: 0f) > 0f, enabled = state.connected, onCheckedChange = { - formState.value = formState.value.copy { adcMultiplierOverride = if (it) 1.0f else 0.0f } + formState.value = formState.value.copy(adc_multiplier_override = if (it) 1.0f else 0.0f) }, containerColor = CardDefaults.cardColors().containerColor, ) - if (formState.value.adcMultiplierOverride > 0f) { + if ((formState.value.adc_multiplier_override ?: 0f) > 0f) { HorizontalDivider() EditTextPreference( title = stringResource(Res.string.adc_multiplier_override_ratio), - value = formState.value.adcMultiplierOverride, + value = formState.value.adc_multiplier_override ?: 0f, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { adcMultiplierOverride = it } }, + onValueChanged = { formState.value = formState.value.copy(adc_multiplier_override = it) }, ) } HorizontalDivider() val waitBluetoothItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.wait_for_bluetooth_duration_seconds), - selectedItem = formState.value.waitBluetoothSecs.toLong(), + selectedItem = (formState.value.wait_bluetooth_secs ?: 0).toLong(), enabled = state.connected, items = waitBluetoothItems.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { waitBluetoothSecs = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(wait_bluetooth_secs = it.toInt()) }, ) HorizontalDivider() val sdsSecsItems = remember { IntervalConfiguration.ALL.allowedIntervals } DropDownPreference( title = stringResource(Res.string.super_deep_sleep_duration_seconds), - selectedItem = formState.value.sdsSecs.toLong(), - onItemSelected = { formState.value = formState.value.copy { sdsSecs = it.toInt() } }, + selectedItem = (formState.value.sds_secs ?: 0).toLong(), + onItemSelected = { formState.value = formState.value.copy(sds_secs = it.toInt()) }, enabled = state.connected, items = sdsSecsItems.map { it.value to it.toDisplayString() }, ) @@ -131,18 +129,18 @@ fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: val minWakeItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.minimum_wake_time_seconds), - selectedItem = formState.value.minWakeSecs.toLong(), + selectedItem = (formState.value.min_wake_secs ?: 0).toLong(), enabled = state.connected, items = minWakeItems.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { minWakeSecs = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(min_wake_secs = it.toInt()) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.battery_ina_2xx_i2c_address), - value = formState.value.deviceBatteryInaAddress, + value = formState.value.device_battery_ina_address ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { deviceBatteryInaAddress = it } }, + onValueChanged = { formState.value = formState.value.copy(device_battery_ina_address = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt index 1ac9ce516..0a855a241 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.animation.AnimatedVisibility @@ -33,7 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp -import com.google.protobuf.MessageLite +import com.squareup.wire.Message import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.discard_changes @@ -44,7 +43,7 @@ import org.meshtastic.feature.settings.radio.ResponseState @Suppress("LongMethod") @Composable -fun RadioConfigScreenList( +fun > RadioConfigScreenList( title: String, onBack: () -> Unit, responseState: ResponseState, diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RangeTestConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RangeTestConfigItemList.kt index 59c49092e..66061c404 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RangeTestConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RangeTestConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.material3.CardDefaults @@ -37,13 +36,12 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun RangeTestConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val rangeTestConfig = state.moduleConfig.rangeTest + val rangeTestConfig = state.moduleConfig.range_test ?: ModuleConfig.RangeTestConfig() val formState = rememberConfigState(initialValue = rangeTestConfig) RadioConfigScreenList( @@ -54,7 +52,7 @@ fun RangeTestConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { rangeTest = it } + val config = ModuleConfig(range_test = it) viewModel.setModuleConfig(config) }, ) { @@ -62,26 +60,26 @@ fun RangeTestConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB TitledCard(title = stringResource(Res.string.range_test_config)) { SwitchPreference( title = stringResource(Res.string.range_test_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() val rangeItems = remember { IntervalConfiguration.RANGE_TEST_SENDER.allowedIntervals } DropDownPreference( title = stringResource(Res.string.sender_message_interval_seconds), - selectedItem = formState.value.sender.toLong(), + selectedItem = (formState.value.sender ?: 0).toLong(), enabled = state.connected, items = rangeItems.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { sender = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(sender = it.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.save_csv_in_storage_esp32_only), - checked = formState.value.save, + checked = formState.value.save ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { save = it } }, + onCheckedChange = { formState.value = formState.value.copy(save = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RemoteHardwareConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RemoteHardwareConfigItemList.kt index 9b8fe0a73..d13f4e2ee 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RemoteHardwareConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RemoteHardwareConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -36,13 +35,12 @@ import org.meshtastic.core.ui.component.EditListPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val remoteHardwareConfig = state.moduleConfig.remoteHardware + val remoteHardwareConfig = state.moduleConfig.remote_hardware ?: ModuleConfig.RemoteHardwareConfig() val formState = rememberConfigState(initialValue = remoteHardwareConfig) val focusManager = LocalFocusManager.current @@ -54,7 +52,7 @@ fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel() responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { remoteHardware = it } + val config = ModuleConfig(remote_hardware = it) viewModel.setModuleConfig(config) }, ) { @@ -62,33 +60,27 @@ fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel() TitledCard(title = stringResource(Res.string.remote_hardware_config)) { SwitchPreference( title = stringResource(Res.string.remote_hardware_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.allow_undefined_pin_access), - checked = formState.value.allowUndefinedPinAccess, + checked = formState.value.allow_undefined_pin_access ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { allowUndefinedPinAccess = it } }, + onCheckedChange = { formState.value = formState.value.copy(allow_undefined_pin_access = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditListPreference( title = stringResource(Res.string.available_pins), - list = formState.value.availablePinsList, + list = formState.value.available_pins, maxCount = 4, // available_pins max_count:4 enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValuesChanged = { list -> - formState.value = - formState.value.copy { - availablePins.clear() - availablePins.addAll(list) - } - }, + onValuesChanged = { list -> formState.value = formState.value.copy(available_pins = list) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt index 07dfd7eb5..df141d109 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.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 org.meshtastic.feature.settings.radio.component import android.app.Activity @@ -42,10 +41,10 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.protobuf.ByteString +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.util.encodeToString -import org.meshtastic.core.model.util.toByteString import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.admin_key import org.meshtastic.core.strings.admin_keys @@ -77,9 +76,7 @@ import org.meshtastic.core.ui.component.EditListPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.ConfigProtos.Config.SecurityConfig -import org.meshtastic.proto.config -import org.meshtastic.proto.copy +import org.meshtastic.proto.Config import java.security.SecureRandom @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -87,15 +84,15 @@ import java.security.SecureRandom fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val node by viewModel.destNode.collectAsStateWithLifecycle() - val securityConfig = state.radioConfig.security + val securityConfig = state.radioConfig.security ?: Config.SecurityConfig() val formState = rememberConfigState(initialValue = securityConfig) - var publicKey by rememberSaveable { mutableStateOf(formState.value.publicKey) } - LaunchedEffect(formState.value.privateKey) { - if (formState.value.privateKey != securityConfig.privateKey) { - publicKey = "".toByteString() - } else if (formState.value.privateKey == securityConfig.privateKey) { - publicKey = securityConfig.publicKey + var publicKey by rememberSaveable { mutableStateOf(formState.value.public_key) } + LaunchedEffect(formState.value.private_key) { + if (formState.value.private_key != securityConfig.private_key) { + publicKey = ByteString.EMPTY + } else if (formState.value.private_key == securityConfig.private_key) { + publicKey = securityConfig.public_key } } @@ -112,7 +109,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa onConfirm = { formState.value = it showKeyGenerationDialog = false - val config = config { security = formState.value } + val config = Config(security = formState.value) viewModel.setConfig(config) }, onDismiss = { showKeyGenerationDialog = false }, @@ -133,7 +130,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa type = "application/*" putExtra( Intent.EXTRA_TITLE, - "${node?.user?.shortName}_keys_${System.currentTimeMillis()}.json", + "${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json", ) } exportConfigLauncher.launch(intent) @@ -154,7 +151,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = config { security = it } + val config = Config(security = it) viewModel.setConfig(config) }, ) { @@ -168,25 +165,25 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa readOnly = true, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChange = { - if (it.size() == 32) { - formState.value = formState.value.copy { this.publicKey = it } + if (it.size == 32) { + formState.value = formState.value.copy(public_key = it) } }, - trailingIcon = { CopyIconButton(valueToCopy = formState.value.publicKey.encodeToString()) }, + trailingIcon = { CopyIconButton(valueToCopy = formState.value.public_key.encodeToString()) }, ) HorizontalDivider() EditBase64Preference( title = stringResource(Res.string.private_key), summary = stringResource(Res.string.config_security_private_key), - value = formState.value.privateKey, + value = formState.value.private_key, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChange = { - if (it.size() == 32) { - formState.value = formState.value.copy { privateKey = it } + if (it.size == 32) { + formState.value = formState.value.copy(private_key = it) } }, - trailingIcon = { CopyIconButton(valueToCopy = formState.value.privateKey.encodeToString()) }, + trailingIcon = { CopyIconButton(valueToCopy = formState.value.private_key.encodeToString()) }, ) HorizontalDivider() NodeActionButton( @@ -211,17 +208,11 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa EditListPreference( title = stringResource(Res.string.admin_key), summary = stringResource(Res.string.config_security_admin_key), - list = formState.value.adminKeyList, + list = formState.value.admin_key, maxCount = 3, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValuesChanged = { - formState.value = - formState.value.copy { - adminKey.clear() - adminKey.addAll(it) - } - }, + onValuesChanged = { formState.value = formState.value.copy(admin_key = it) }, ) } } @@ -230,18 +221,18 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa SwitchPreference( title = stringResource(Res.string.serial_console), summary = stringResource(Res.string.config_security_serial_enabled), - checked = formState.value.serialEnabled, + checked = formState.value.serial_enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { serialEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(serial_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.debug_log_api_enabled), summary = stringResource(Res.string.config_security_debug_log_api_enabled), - checked = formState.value.debugLogApiEnabled, + checked = formState.value.debug_log_api_enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { debugLogApiEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(debug_log_api_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -251,17 +242,17 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa SwitchPreference( title = stringResource(Res.string.managed_mode), summary = stringResource(Res.string.config_security_is_managed), - checked = formState.value.isManaged, - enabled = state.connected && formState.value.adminKeyCount > 0, - onCheckedChange = { formState.value = formState.value.copy { isManaged = it } }, + checked = formState.value.is_managed, + enabled = state.connected && formState.value.admin_key.isNotEmpty(), + onCheckedChange = { formState.value = formState.value.copy(is_managed = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.legacy_admin_channel), - checked = formState.value.adminChannelEnabled, + checked = formState.value.admin_channel_enabled, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { adminChannelEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } @@ -273,7 +264,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa @Composable fun PrivateKeyRegenerateDialog( showKeyGenerationDialog: Boolean, - onConfirm: (SecurityConfig) -> Unit, + onConfirm: (Config.SecurityConfig) -> Unit, onDismiss: () -> Unit = {}, ) { if (showKeyGenerationDialog) { @@ -284,22 +275,16 @@ fun PrivateKeyRegenerateDialog( confirmButton = { TextButton( onClick = { + // Generate a random "f" value + val f = ByteArray(32).apply { SecureRandom().nextBytes(this) } + // Adjust the value to make it valid as an "s" value for eval(). + // According to the specification we need to mask off the 3 + // right-most bits of f[0], mask off the left-most bit of f[31], + // and set the second to left-most bit of f[31]. + f[0] = (f[0].toInt() and 0xF8).toByte() + f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte() val securityInput = - SecurityConfig.newBuilder() - .apply { - clearPrivateKey() - clearPublicKey() - // Generate a random "f" value - val f = ByteArray(32).apply { SecureRandom().nextBytes(this) } - // Adjust the value to make it valid as an "s" value for eval(). - // According to the specification we need to mask off the 3 - // right-most bits of f[0], mask off the left-most bit of f[31], - // and set the second to left-most bit of f[31]. - f[0] = (f[0].toInt() and 0xF8).toByte() - f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte() - privateKey = ByteString.copyFrom(f) - } - .build() + Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY) onConfirm(securityInput) }, ) { diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt index 159847924..cdd08e93c 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -40,14 +39,12 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig.SerialConfig -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val serialConfig = state.moduleConfig.serial + val serialConfig = state.moduleConfig.serial ?: ModuleConfig.SerialConfig() val formState = rememberConfigState(initialValue = serialConfig) val focusManager = LocalFocusManager.current @@ -59,7 +56,7 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { serial = it } + val config = ModuleConfig(serial = it) viewModel.setModuleConfig(config) }, ) { @@ -67,71 +64,65 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack TitledCard(title = stringResource(Res.string.serial_config)) { SwitchPreference( title = stringResource(Res.string.serial_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.echo_enabled), - checked = formState.value.echo, + checked = formState.value.echo ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { echo = it } }, + onCheckedChange = { formState.value = formState.value.copy(echo = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = "RX", - value = formState.value.rxd, + value = formState.value.rxd ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { rxd = it } }, + onValueChanged = { formState.value = formState.value.copy(rxd = it) }, ) HorizontalDivider() EditTextPreference( title = "TX", - value = formState.value.txd, + value = formState.value.txd ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { txd = it } }, + onValueChanged = { formState.value = formState.value.copy(txd = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.serial_baud_rate), enabled = state.connected, - items = - SerialConfig.Serial_Baud.entries - .filter { it != SerialConfig.Serial_Baud.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.baud, - onItemSelected = { formState.value = formState.value.copy { baud = it } }, + items = ModuleConfig.SerialConfig.Serial_Baud.entries.map { it to it.name }, + selectedItem = formState.value.baud ?: ModuleConfig.SerialConfig.Serial_Baud.BAUD_DEFAULT, + onItemSelected = { formState.value = formState.value.copy(baud = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.timeout), - value = formState.value.timeout, + value = formState.value.timeout ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { timeout = it } }, + onValueChanged = { formState.value = formState.value.copy(timeout = it) }, ) HorizontalDivider() DropDownPreference( title = stringResource(Res.string.serial_mode), enabled = state.connected, - items = - SerialConfig.Serial_Mode.entries - .filter { it != SerialConfig.Serial_Mode.UNRECOGNIZED } - .map { it to it.name }, - selectedItem = formState.value.mode, - onItemSelected = { formState.value = formState.value.copy { mode = it } }, + items = ModuleConfig.SerialConfig.Serial_Mode.entries.map { it to it.name }, + selectedItem = formState.value.mode ?: ModuleConfig.SerialConfig.Serial_Mode.DEFAULT, + onItemSelected = { formState.value = formState.value.copy(mode = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.override_console_serial_port), - checked = formState.value.overrideConsoleSerialPort, + checked = formState.value.override_console_serial_port ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { overrideConsoleSerialPort = it } }, + onCheckedChange = { formState.value = formState.value.copy(override_console_serial_port = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt index ad162d7bf..b95dbc7f6 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.layout.Column @@ -45,7 +44,7 @@ import org.meshtastic.core.strings.send import org.meshtastic.core.strings.shutdown_node_name import org.meshtastic.core.strings.shutdown_warning import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.User @Composable fun ShutdownConfirmationDialog( @@ -56,7 +55,7 @@ fun ShutdownConfirmationDialog( icon: ImageVector? = Icons.Rounded.Warning, onConfirm: () -> Unit, ) { - val nodeLongName = node?.user?.longName ?: "Unknown Node" + val nodeLongName = node?.user?.long_name ?: "Unknown Node" AlertDialog( onDismissRequest = {}, @@ -104,11 +103,7 @@ private fun ShutdownDialogContent(nodeLongName: String, isShutdown: Boolean) { @Preview @Composable private fun ShutdownConfirmationDialogPreview() { - val mockNode = - Node( - num = 123, - user = MeshProtos.User.newBuilder().setLongName("Rooftop Router Node").setShortName("ROOF").build(), - ) + val mockNode = Node(num = 123, user = User(long_name = "Rooftop Router Node", short_name = "ROOF")) AppTheme { ShutdownConfirmationDialog(title = "Shutdown?", node = mockNode, onDismiss = {}, onConfirm = {}) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt index d8378ac61..eb5d0523c 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt @@ -33,13 +33,12 @@ import org.meshtastic.core.strings.status_message_config import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig @Composable fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val statusMessageConfig = state.moduleConfig.statusmessage + val statusMessageConfig = + state.moduleConfig.statusmessage ?: org.meshtastic.proto.ModuleConfig.StatusMessageConfig() val formState = rememberConfigState(initialValue = statusMessageConfig) val focusManager = LocalFocusManager.current @@ -51,7 +50,7 @@ fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { statusmessage = it } + val config = org.meshtastic.proto.ModuleConfig(statusmessage = it) viewModel.setModuleConfig(config) }, ) { @@ -59,14 +58,14 @@ fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), TitledCard(title = stringResource(Res.string.status_message_config)) { EditTextPreference( title = stringResource(Res.string.node_status_summary), - value = formState.value.nodeStatus, + value = formState.value.node_status ?: "", maxSize = 80, // status_message max_size:80 enabled = state.connected, isError = false, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { nodeStatus = it } }, + onValueChanged = { formState.value = formState.value.copy(node_status = it) }, ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StoreForwardConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StoreForwardConfigItemList.kt index 068629324..76719a80c 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StoreForwardConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StoreForwardConfigItemList.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 org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions @@ -39,13 +38,12 @@ import org.meshtastic.core.ui.component.EditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val storeForwardConfig = state.moduleConfig.storeForward + val storeForwardConfig = state.moduleConfig.store_forward ?: ModuleConfig.StoreForwardConfig() val formState = rememberConfigState(initialValue = storeForwardConfig) val focusManager = LocalFocusManager.current @@ -57,7 +55,7 @@ fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { storeForward = it } + val config = ModuleConfig(store_forward = it) viewModel.setModuleConfig(config) }, ) { @@ -65,49 +63,49 @@ fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), TitledCard(title = stringResource(Res.string.store_forward_config)) { SwitchPreference( title = stringResource(Res.string.store_forward_enabled), - checked = formState.value.enabled, + checked = formState.value.enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.heartbeat), - checked = formState.value.heartbeat, + checked = formState.value.heartbeat ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { heartbeat = it } }, + onCheckedChange = { formState.value = formState.value.copy(heartbeat = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.number_of_records), - value = formState.value.records, + value = formState.value.records ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { records = it } }, + onValueChanged = { formState.value = formState.value.copy(records = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.history_return_max), - value = formState.value.historyReturnMax, + value = formState.value.history_return_max ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { historyReturnMax = it } }, + onValueChanged = { formState.value = formState.value.copy(history_return_max = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.history_return_window), - value = formState.value.historyReturnWindow, + value = formState.value.history_return_window ?: 0, enabled = state.connected, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { historyReturnWindow = it } }, + onValueChanged = { formState.value = formState.value.copy(history_return_window = it) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.server), - checked = formState.value.isServer, + checked = formState.value.is_server ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { isServer = it } }, + onCheckedChange = { formState.value = formState.value.copy(is_server = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt index 875ab9f1a..0c2655b1a 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt @@ -46,16 +46,16 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString -import org.meshtastic.proto.copy -import org.meshtastic.proto.moduleConfig +import org.meshtastic.proto.ModuleConfig @Composable fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() - val telemetryConfig = state.moduleConfig.telemetry + val telemetryConfig = state.moduleConfig.telemetry ?: ModuleConfig.TelemetryConfig() val formState = rememberConfigState(initialValue = telemetryConfig) - val capabilities = remember(state.metadata?.firmwareVersion) { Capabilities(state.metadata?.firmwareVersion) } + val firmwareVersion = state.metadata?.firmware_version + val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } RadioConfigScreenList( title = stringResource(Res.string.telemetry), @@ -65,7 +65,7 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - val config = moduleConfig { telemetry = it } + val config = ModuleConfig(telemetry = it) viewModel.setModuleConfig(config) }, ) { @@ -75,9 +75,9 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB SwitchPreference( title = stringResource(Res.string.device_telemetry_enabled), summary = stringResource(Res.string.device_telemetry_enabled_summary), - checked = formState.value.deviceTelemetryEnabled, + checked = formState.value.device_telemetry_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { deviceTelemetryEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(device_telemetry_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() @@ -85,86 +85,86 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB val items = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.device_metrics_update_interval_seconds), - selectedItem = formState.value.deviceUpdateInterval.toLong(), + selectedItem = (formState.value.device_update_interval ?: 0).toLong(), enabled = state.connected, items = items.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { deviceUpdateInterval = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(device_update_interval = it.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.environment_metrics_module_enabled), - checked = formState.value.environmentMeasurementEnabled, + checked = formState.value.environment_measurement_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { environmentMeasurementEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(environment_measurement_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() val envItems = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.environment_metrics_update_interval_seconds), - selectedItem = formState.value.environmentUpdateInterval.toLong(), + selectedItem = (formState.value.environment_update_interval ?: 0).toLong(), enabled = state.connected, items = envItems.map { it.value to it.toDisplayString() }, onItemSelected = { - formState.value = formState.value.copy { environmentUpdateInterval = it.toInt() } + formState.value = formState.value.copy(environment_update_interval = it.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.environment_metrics_on_screen_enabled), - checked = formState.value.environmentScreenEnabled, + checked = formState.value.environment_screen_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { environmentScreenEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(environment_screen_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.environment_metrics_use_fahrenheit), - checked = formState.value.environmentDisplayFahrenheit, + checked = formState.value.environment_display_fahrenheit ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { environmentDisplayFahrenheit = it } }, + onCheckedChange = { formState.value = formState.value.copy(environment_display_fahrenheit = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.air_quality_metrics_module_enabled), - checked = formState.value.airQualityEnabled, + checked = formState.value.air_quality_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { airQualityEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(air_quality_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() val airItems = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.air_quality_metrics_update_interval_seconds), - selectedItem = formState.value.airQualityInterval.toLong(), + selectedItem = (formState.value.air_quality_interval ?: 0).toLong(), enabled = state.connected, items = airItems.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { airQualityInterval = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(air_quality_interval = it.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.power_metrics_module_enabled), - checked = formState.value.powerMeasurementEnabled, + checked = formState.value.power_measurement_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { powerMeasurementEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(power_measurement_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() val powerItems = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals } DropDownPreference( title = stringResource(Res.string.power_metrics_update_interval_seconds), - selectedItem = formState.value.powerUpdateInterval.toLong(), + selectedItem = (formState.value.power_update_interval ?: 0).toLong(), enabled = state.connected, items = powerItems.map { it.value to it.toDisplayString() }, - onItemSelected = { formState.value = formState.value.copy { powerUpdateInterval = it.toInt() } }, + onItemSelected = { formState.value = formState.value.copy(power_update_interval = it.toInt()) }, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.power_metrics_on_screen_enabled), - checked = formState.value.powerScreenEnabled, + checked = formState.value.power_screen_enabled ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { powerScreenEnabled = it } }, + onCheckedChange = { formState.value = formState.value.copy(power_screen_enabled = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt index af29d9df3..7a7271231 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt @@ -47,17 +47,17 @@ import org.meshtastic.core.ui.component.RegularPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.proto.copy @Composable fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val userConfig = state.userConfig val formState = rememberConfigState(initialValue = userConfig) - val capabilities = remember(state.metadata?.firmwareVersion) { Capabilities(state.metadata?.firmwareVersion) } + val firmwareVersion = state.metadata?.firmware_version + val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } - val validLongName = formState.value.longName.isNotBlank() - val validShortName = formState.value.shortName.isNotBlank() + val validLongName = (formState.value.long_name ?: "").isNotBlank() + val validShortName = (formState.value.short_name ?: "").isNotBlank() val validNames = validLongName && validShortName val focusManager = LocalFocusManager.current @@ -74,37 +74,37 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: TitledCard(title = stringResource(Res.string.user_config)) { RegularPreference( title = stringResource(Res.string.node_id), - subtitle = formState.value.id, + subtitle = formState.value.id ?: "", onClick = {}, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.long_name), - value = formState.value.longName, + value = formState.value.long_name ?: "", maxSize = 39, // long_name max_size:40 enabled = state.connected, isError = !validLongName, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { longName = it } }, + onValueChanged = { formState.value = formState.value.copy(long_name = it) }, ) HorizontalDivider() EditTextPreference( title = stringResource(Res.string.short_name), - value = formState.value.shortName, + value = formState.value.short_name ?: "", maxSize = 4, // short_name max_size:5 enabled = state.connected, isError = !validShortName, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy { shortName = it } }, + onValueChanged = { formState.value = formState.value.copy(short_name = it) }, ) HorizontalDivider() RegularPreference( title = stringResource(Res.string.hardware_model), - subtitle = formState.value.hwModel.name, + subtitle = formState.value.hw_model?.name ?: "", onClick = {}, ) HorizontalDivider() @@ -112,19 +112,19 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: title = stringResource(Res.string.unmessageable), summary = stringResource(Res.string.unmonitored_or_infrastructure), checked = - formState.value.isUnmessagable || + (formState.value.is_unmessagable ?: false) || (!capabilities.canToggleUnmessageable && formState.value.role.isUnmessageableRole()), - enabled = formState.value.hasIsUnmessagable() || capabilities.canToggleUnmessageable, - onCheckedChange = { formState.value = formState.value.copy { isUnmessagable = it } }, + enabled = formState.value.is_unmessagable != null || capabilities.canToggleUnmessageable, + onCheckedChange = { formState.value = formState.value.copy(is_unmessagable = it) }, containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() SwitchPreference( title = stringResource(Res.string.licensed_amateur_radio), summary = stringResource(Res.string.licensed_amateur_radio_text), - checked = formState.value.isLicensed, + checked = formState.value.is_licensed ?: false, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy { isLicensed = it } }, + onCheckedChange = { formState.value = formState.value.copy(is_licensed = it) }, containerColor = CardDefaults.cardColors().containerColor, ) } diff --git a/gradle.properties b/gradle.properties index f3ddc1719..c0fe8635c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,64 +16,43 @@ # # Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html +org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -# Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750 -org.gradle.jvmargs=-Xmx8g -XX:+UseG1GC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 - -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# Parallelism & Caching org.gradle.parallel=true - -# Disabled due to instability with KSP and modern Gradle features +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.isolated-projects=true +org.gradle.vfs.watch=true org.gradle.configureondemand=false -# Enable caching between builds. -org.gradle.caching=true - -# Enable configuration caching between builds. -org.gradle.configuration-cache=true - -# Enable Isolated Projects (Gradle 9+ optimization) -# Allows parallel configuration of projects by enforcing isolation constraints. -# Disabled due to KSP incompatibility (NullPointerException in ApplicationManager) -org.gradle.isolated-projects=false - -# Watches the file system for changes, allowing Gradle to reuse information about the file system -# between builds. -org.gradle.vfs.watch=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true - -android.enableJetifier=false - -# Kotlin code style for this project: "official" or "obsolete": +# Kotlin Optimization +# Parallelize Kotlin tasks within a single project (great for KMP) +kotlin.parallel.tasks.in.project=true +# Give Kotlin daemon enough breathing room +kotlin.daemon.jvm.options=-Xmx4g -XX:+UseParallelGC kotlin.code.style=official -# Disable build features that are enabled by default, -# https://developer.android.com/build/releases/gradle-plugin#default-changes +# Android (AGP) Optimization +android.useAndroidX=true +android.enableJetifier=false android.nonTransitiveRClass=true +# More aggressive R8 optimizations +android.enableR8.fullMode=true +# Parallel lint analysis +android.experimental.lint.analysisPerComponent=true -dependency.analysis.print.build.health=true - +# KSP 2 Configuration ksp.useKSP2=true -# Force KSP to run in a separate process to avoid IntelliJ ApplicationManager races in parallel builds ksp.run.in.process=false ksp.incremental=true ksp.incremental.classpath=true -# Compose Compiler Reports +# UI & Analysis +dependency.analysis.print.build.health=true enableComposeCompilerMetrics=false enableComposeCompilerReports=false +# Housekeeping +org.gradle.welcome=never android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 122a8b640..19096698a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,8 +43,10 @@ detekt = "1.23.8" devtools-ksp = "2.3.5" markdownRenderer = "0.39.2" osmdroid-android = "6.1.20" -protobuf = "4.33.5" +wire = "5.2.1" vico = "3.0.0-beta.3" +# Removed gradle-doctor +dependency-guard = "0.5.0" [libraries] @@ -106,7 +108,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.8.0" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } -guava = { module = "com.google.guava:guava", version = "33.5.0-jre" } +guava = { module = "com.google.guava:guava", version = "33.5.0-android" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } @@ -116,8 +118,7 @@ maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" } maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } -protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } -protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } +wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } zxing-core = { module = "com.google.zxing:core", version = "3.5.4" } truth = { module = "com.google.truth:truth", version = "1.4.5" } @@ -177,8 +178,7 @@ org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.c osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" } osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" } osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" } -streamsupport-minifuture = { module = "net.sourceforge.streamsupport:streamsupport-minifuture", version = "1.7.4" } - kermit = { module = "co.touchlab:kermit", version = "2.0.8" } +kermit = { module = "co.touchlab:kermit", version = "2.0.8" } usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" } zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version = "4.3.0" } @@ -238,10 +238,12 @@ datadog = { id = "com.datadoghq.dd-sdk-android-gradle-plugin", version = "1.22.0 # Removed dependency-analysis = { id = "com.autonomousapps.dependency-analysis", version = "3.5.1" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } dokka = { id = "org.jetbrains.dokka", version = "2.1.0" } -protobuf = { id = "com.google.protobuf", version = "0.9.6" } +wire = { id = "com.squareup.wire", version.ref = "wire" } room = { id = "androidx.room", version.ref = "room" } spotless = { id = "com.diffplug.spotless", version = "8.2.1" } test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" } +# Removed gradle-doctor +dependency-guard = { id = "com.dropbox.dependency-guard", version.ref = "dependency-guard" } # Meshtastic meshtastic-analytics = { id = "meshtastic.analytics" } diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt index 504429e4a..9f9672b34 100644 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt @@ -25,14 +25,14 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.Position import org.meshtastic.core.service.IMeshService -import org.meshtastic.proto.Portnums -import org.meshtastic.proto.TelemetryProtos +import org.meshtastic.proto.PortNum import kotlin.random.Random private const val TAG = "MeshServiceViewModel" @@ -105,8 +105,8 @@ class MeshServiceViewModel : ViewModel() { val packet = DataPacket( to = DataPacket.ID_BROADCAST, - bytes = text.toByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = text.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, from = DataPacket.ID_LOCAL, time = System.currentTimeMillis(), id = service.packetId, // Correctly sync with radio's ID @@ -173,7 +173,8 @@ class MeshServiceViewModel : ViewModel() { fun requestTelemetry(nodeNum: Int) { meshService?.let { try { - it.requestTelemetry(Random.nextInt(), nodeNum, TelemetryProtos.Telemetry.DEVICE_METRICS_FIELD_NUMBER) + // DEVICE_METRICS_FIELD_NUMBER = 1 + it.requestTelemetry(Random.nextInt(), nodeNum, 1) Log.i(TAG, "Telemetry requested for node $nodeNum") } catch (e: RemoteException) { Log.e(TAG, "Failed to request telemetry", e) @@ -274,8 +275,8 @@ class MeshServiceViewModel : ViewModel() { private fun handleReceivedPacket(action: String, intent: Intent) { val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java) ?: return - if (packet.dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) { - val receivedText = packet.bytes?.let { bytes -> String(bytes) } ?: "" + if (packet.dataType == PortNum.TEXT_MESSAGE_APP.value) { + val receivedText = packet.bytes?.utf8() ?: "" _message.value = "From ${packet.from}: $receivedText" } else { _message.value = "Received port ${action.substringAfterLast(".")} packet"