From 3659f468e400df97280ca96199771e56e0c2dc37 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:03:17 -0600 Subject: [PATCH] chore(ci): Optimize and stabilize Gradle builds and CI workflows (#4390) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .github/workflows/pull-request.yml | 3 + .github/workflows/reusable-android-build.yml | 24 ++++- .github/workflows/reusable-android-test.yml | 53 ++++++++--- app/build.gradle.kts | 92 ++++++++++--------- .../mesh/concurrent/SyncContinuation.kt | 32 +++++-- .../mesh/service/MeshCommandSender.kt | 1 - .../mesh/service/MeshDataHandler.kt | 2 +- build-logic/convention/build.gradle.kts | 1 + .../AndroidApplicationConventionPlugin.kt | 2 + .../kotlin/AndroidLibraryConventionPlugin.kt | 2 + .../src/main/kotlin/HiltConventionPlugin.kt | 2 +- .../org/meshtastic/buildlogic/Detekt.kt | 1 + .../buildlogic/ProjectExtensions.kt | 18 ++++ build-logic/gradle.properties | 6 +- core/common/build.gradle.kts | 5 +- core/database/build.gradle.kts | 2 +- core/strings/build.gradle.kts | 2 + .../core/ui/component/BitwisePreference.kt | 7 +- .../core/ui/component/ContactSharing.kt | 2 +- .../core/ui/component/DropDownPreference.kt | 10 +- .../core/ui/component/IndoorAirQuality.kt | 6 +- .../core/ui/component/SwitchPreference.kt | 35 ++++--- .../feature/intro/AppIntroductionScreen.kt | 5 +- .../feature/intro/NotificationsScreen.kt | 7 +- .../meshtastic/feature/messaging/Message.kt | 5 +- .../component/MessageActionsBottomSheet.kt | 6 +- gradle.properties | 20 ++-- gradle/develocity.settings.gradle | 10 +- gradle/libs.versions.toml | 6 +- 29 files changed, 236 insertions(+), 131 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index cae01a885..0763c5ae5 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -44,6 +44,8 @@ jobs: needs: lint if: ${{ !cancelled() && !failure() }} uses: ./.github/workflows/reusable-android-build.yml + with: + test_flavors: 'google' secrets: inherit androidTest: @@ -53,6 +55,7 @@ jobs: with: api_levels: '[35]' # Run only on API 35 for PRs test_flavors: 'google' # Run only Google flavor for PRs (faster) + num_shards: 2 # Run tests in parallel across 2 emulators secrets: inherit # This job handles the case when no code changes are detected (docs-only PRs) diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml index 5cbea915c..9820b1b5e 100644 --- a/.github/workflows/reusable-android-build.yml +++ b/.github/workflows/reusable-android-build.yml @@ -23,6 +23,11 @@ on: 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: @@ -77,8 +82,21 @@ jobs: 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 assembleDebug testDebugUnitTest testGoogleDebugUnitTest testFdroidDebugUnitTest koverXmlReport --continue --scan + run: ./gradlew ${{ steps.build-tasks.outputs.tasks }} koverXmlReport -Pci=true --continue --scan env: VERSION_CODE: ${{ env.VERSION_CODE }} @@ -101,14 +119,14 @@ jobs: files: "**/build/test-results/**/*.xml,**/build/outputs/androidTest-results/**/*.xml" - name: Upload F-Droid debug artifact - if: ${{ inputs.upload_artifacts }} + 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 }} + if: ${{ inputs.upload_artifacts && (inputs.test_flavors == 'google' || inputs.test_flavors == 'both') }} uses: actions/upload-artifact@v6 with: name: googleDebug diff --git a/.github/workflows/reusable-android-test.yml b/.github/workflows/reusable-android-test.yml index 2d740dbb0..fedc3c49a 100644 --- a/.github/workflows/reusable-android-test.yml +++ b/.github/workflows/reusable-android-test.yml @@ -12,12 +12,17 @@ on: 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]' # Default to running both if not specified by caller + 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 @@ -31,7 +36,29 @@ on: 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: @@ -39,14 +66,14 @@ jobs: GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} strategy: - matrix: - api-level: ${{ fromJson(inputs.api_levels) }} # Use the input to define the matrix + fail-fast: false + matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }} steps: - name: Checkout code uses: actions/checkout@v6 with: submodules: 'recursive' - fetch-depth: 1 # Shallow clone - no version code calculation needed + fetch-depth: 1 - name: Set up JDK 17 uses: actions/setup-java@v5 @@ -78,13 +105,13 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-${{ matrix.api-level }} + 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 }} + 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 @@ -94,25 +121,23 @@ jobs: - name: Determine test tasks id: test-tasks run: | - if [ "${{ inputs.test_flavors }}" = "google" ]; then + if [ "${{ matrix.flavor }}" = "google" ]; then echo "tasks=connectedGoogleDebugAndroidTest" >> $GITHUB_OUTPUT - elif [ "${{ inputs.test_flavors }}" = "fdroid" ]; then - echo "tasks=connectedFdroidDebugAndroidTest" >> $GITHUB_OUTPUT else - echo "tasks=connectedFdroidDebugAndroidTest connectedGoogleDebugAndroidTest" >> $GITHUB_OUTPUT + echo "tasks=connectedFdroidDebugAndroidTest" >> $GITHUB_OUTPUT fi - - name: Run Android Instrumented Tests and Generate Coverage + - 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 }} + 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 --continue --scan && ( killall -INT crashpad_handler || true ) + script: ./gradlew ${{ steps.test-tasks.outputs.tasks }} koverXmlReport -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() }} @@ -136,7 +161,7 @@ jobs: if: ${{ always() && inputs.upload_artifacts }} uses: actions/upload-artifact@v6 with: - name: android-test-reports-api-${{ matrix.api-level }} + name: android-test-reports-api-${{ matrix.api_level }}-${{ matrix.flavor }}-shard-${{ matrix.shard }} path: | **/build/outputs/androidTest-results/connected/** **/build/reports/androidTests/connected/** diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c47e75b3..e9a7d563d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,49 +77,55 @@ configure { // because some of our libs have bogus languages that google play // doesn't like and we need to strip them (gr) @Suppress("UnstableApiUsage") - androidResources.localeFilters.addAll( - listOf( - "en", - "ar", - "bg", - "ca", - "cs", - "de", - "el", - "es", - "et", - "fi", - "fr", - "ga", - "gl", - "hr", - "ht", - "hu", - "is", - "it", - "iw", - "ja", - "ko", - "lt", - "nl", - "no", - "pl", - "pt", - "pt-rBR", - "ro", - "ru", - "sk", - "sl", - "sq", - "sr", - "srp", - "sv", - "tr", - "uk", - "zh-rCN", - "zh-rTW", - ), - ) + val ci = project.findProperty("ci")?.toString()?.toBoolean() ?: false + if (ci) { + println("CI build detected - limiting locale filters for faster packaging") + androidResources.localeFilters.addAll(listOf("en")) + } else { + androidResources.localeFilters.addAll( + listOf( + "en", + "ar", + "bg", + "ca", + "cs", + "de", + "el", + "es", + "et", + "fi", + "fr", + "ga", + "gl", + "hr", + "ht", + "hu", + "is", + "it", + "iw", + "ja", + "ko", + "lt", + "nl", + "no", + "pl", + "pt", + "pt-rBR", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "srp", + "sv", + "tr", + "uk", + "zh-rCN", + "zh-rTW", + ), + ) + } ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") } dependenciesInfo { diff --git a/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt b/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt index ee81e75a4..1625736a4 100644 --- a/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt +++ b/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.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.concurrent /** A deferred execution object (with various possible implementations) */ interface Continuation { @@ -45,25 +44,36 @@ class CallbackContinuation(private val cb: (Result) -> Unit) : Continua */ class SyncContinuation : Continuation { - private val mbox = java.lang.Object() + private val lock = java.util.concurrent.locks.ReentrantLock() + private val condition = lock.newCondition() private var result: Result? = null override fun resume(res: Result) { - synchronized(mbox) { + lock.lock() + try { result = res - mbox.notify() + condition.signal() + } finally { + lock.unlock() } } // Wait for the result (or throw an exception) + @Suppress("NestedBlockDepth") fun await(timeoutMsecs: Long = 0): T { - synchronized(mbox) { + lock.lock() + try { val startT = System.currentTimeMillis() while (result == null) { - mbox.wait(timeoutMsecs) - - if (timeoutMsecs > 0 && ((System.currentTimeMillis() - startT) >= timeoutMsecs)) { - throw Exception("SyncContinuation timeout") + if (timeoutMsecs > 0) { + val remaining = timeoutMsecs - (System.currentTimeMillis() - startT) + if (remaining <= 0) { + throw Exception("SyncContinuation timeout") + } + // await returns false if waiting time elapsed + condition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS) + } else { + condition.await() } } @@ -73,6 +83,8 @@ class SyncContinuation : Continuation { } else { throw Exception("This shouldn't happen") } + } finally { + lock.unlock() } } } 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 ab901dd66..7f9246eb3 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -293,7 +293,6 @@ constructor( TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance() TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance() TelemetryType.HOST -> hostMetrics = TelemetryProtos.HostMetrics.getDefaultInstance() - else -> {} } } .toByteString() 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 161e402cd..e6f0a2204 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -757,7 +757,7 @@ constructor( } } - @Suppress("LongMethod") + @Suppress("LongMethod", "KotlinConstantConditions") private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch { val emoji = packet.decoded.payload.toByteArray().decodeToString() val fromId = dataMapper.toNodeID(packet.from) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index ec49dfda7..3be1b7057 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { compileOnly(libs.androidx.room.gradlePlugin) compileOnly(libs.secrets.gradlePlugin) compileOnly(libs.spotless.gradlePlugin) + compileOnly(libs.test.retry.gradlePlugin) 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 81832272c..790c07b4d 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -21,6 +21,7 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure import org.meshtastic.buildlogic.configureKotlinAndroid +import org.meshtastic.buildlogic.configureTestOptions class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { @@ -64,6 +65,7 @@ class AndroidApplicationConventionPlugin : Plugin { buildConfig = true } } + configureTestOptions() } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 0ded09806..5419c7eb2 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -23,6 +23,7 @@ import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure import org.meshtastic.buildlogic.configureFlavors import org.meshtastic.buildlogic.configureKotlinAndroid +import org.meshtastic.buildlogic.configureTestOptions import org.meshtastic.buildlogic.disableUnnecessaryAndroidTests class AndroidLibraryConventionPlugin : Plugin { @@ -50,6 +51,7 @@ class AndroidLibraryConventionPlugin : Plugin { extensions.configure { disableUnnecessaryAndroidTests(target) } + configureTestOptions() } } } diff --git a/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt index cc9ae4b76..326893bc0 100644 --- a/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt @@ -31,7 +31,7 @@ class HiltConventionPlugin : Plugin { // fixme: remove when hilt supports kotlin 2.3.x "ksp"(libs.library("kotlin-metadata-jvm")) - "ksp"(libs.library("hilt.compiler")) + "ksp"(libs.library("hilt-compiler")) "implementation"(libs.library("hilt-android")) } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt index 40b63faa5..db7893af1 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt @@ -28,6 +28,7 @@ internal fun Project.configureDetekt(extension: DetektExtension) = extension.app config.setFrom("$rootDir/config/detekt/detekt.yml") buildUponDefaultConfig = true allRules = false + parallel = true // Default sources source.setFrom( 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 8d5889e13..39683ae35 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,7 +23,10 @@ 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.Test +import org.gradle.api.tasks.testing.logging.TestLogEvent import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.withType import org.gradle.plugin.use.PluginDependency import java.io.FileInputStream import java.util.Properties @@ -52,3 +55,18 @@ val Project.configProperties: Properties } return properties } + +/** + * Configure common test options like parallel execution and logging. + */ +internal fun Project.configureTestOptions() { + tasks.withType().configureEach { + // Parallelize unit tests + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + + // Show test results in the console + testLogging { + events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) + } + } +} diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index 5e07c65d0..d1e078b9f 100644 --- a/build-logic/gradle.properties +++ b/build-logic/gradle.properties @@ -1,6 +1,8 @@ # Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 org.gradle.parallel=true org.gradle.caching=true -org.gradle.configureondemand=true +org.gradle.configureondemand=false org.gradle.configuration-cache=true -org.gradle.configuration-cache.parallel=true +# Disabled for stability +org.gradle.configuration-cache.parallel=false +org.gradle.isolated-projects=false diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 6997642ac..1413b781e 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -21,5 +21,8 @@ kotlin { @Suppress("UnstableApiUsage") android { androidResources.enable = false } - sourceSets { androidMain.dependencies { implementation(libs.androidx.core.ktx) } } + sourceSets { + androidMain.dependencies { implementation(libs.androidx.core.ktx) } + commonTest.dependencies { implementation(kotlin("test")) } + } } diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 91617c28e..fb325de64 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -27,7 +27,7 @@ configure { namespace = "org.meshtastic.core.database" sourceSets { // Adds exported schema location as test app assets. - named("androidTest") { assets.srcDirs(files("$projectDir/schemas")) } + named("androidTest") { assets.directories.add("$projectDir/schemas") } } } diff --git a/core/strings/build.gradle.kts b/core/strings/build.gradle.kts index 87ccab9d2..4f7c8998a 100644 --- a/core/strings/build.gradle.kts +++ b/core/strings/build.gradle.kts @@ -23,6 +23,8 @@ plugins { kotlin { @Suppress("UnstableApiUsage") android { androidResources.enable = true } + + sourceSets { commonTest.dependencies { implementation(kotlin("test")) } } } compose.resources { diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/BitwisePreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/BitwisePreference.kt index 920fc8b75..547340a6e 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/BitwisePreference.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/BitwisePreference.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.fillMaxWidth @@ -92,9 +91,9 @@ fun BitwisePreference( } PreferenceFooter( enabled = enabled, - negativeText = Res.string.clear, + negativeText = org.jetbrains.compose.resources.stringResource(Res.string.clear), onNegativeClicked = { onItemSelected(0) }, - positiveText = Res.string.close, + positiveText = org.jetbrains.compose.resources.stringResource(Res.string.close), onPositiveClicked = { expanded = false }, ) } 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 9bc6449d0..bc798b493 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 @@ -260,7 +260,7 @@ fun userFieldsToString(user: MeshProtos.User): String { 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.isOptional) { + } else if (fieldDescriptor.isRepeated || fieldDescriptor.hasDefaultValue() || fieldDescriptor.hasPresence()) { val defaultValue = fieldDescriptor.defaultValue val valueString = if (fieldDescriptor.isRepeated) { 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 17eb242a3..bbd4460d4 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 @@ -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 @@ -78,8 +77,11 @@ fun DropDownPreference( val descriptor = (selectedItem as ProtocolMessageEnum).descriptorForType @Suppress("UNCHECKED_CAST") - enum?.filter { entries -> descriptor.values.any { it.name == entries.name && it.options.deprecated } } - as? List ?: emptyList() // Safe cast to List or return emptyList if cast fails + ( + enum?.filter { entries -> descriptor.values.any { it.name == entries.name && it.options.deprecated } } + ?: emptyList() + ) + as List } else { emptyList() } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt index 60b59b495..d70b5daa2 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt @@ -114,7 +114,7 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil return } var isLegendOpen by remember { mutableStateOf(false) } - val iaqEnum = if (iaq != null) getIaq(iaq) else null + val iaqEnum = getIaq(iaq) val gradient = Brush.linearGradient(colors = Iaq.entries.map { it.color }) if (iaqEnum != null) { @@ -164,7 +164,7 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil IaqDisplayMode.Gauge -> { CircularProgressIndicator( - progress = iaq / 500f, + progress = { iaq / 500f }, modifier = Modifier.size(60.dp).clickable { isLegendOpen = true }, strokeWidth = 8.dp, color = iaqEnum.color, @@ -178,7 +178,7 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil modifier = Modifier.clickable { isLegendOpen = true }, ) { LinearProgressIndicator( - progress = iaq / 500f, + progress = { iaq / 500f }, modifier = Modifier.fillMaxWidth().height(20.dp), color = iaqEnum.color, ) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.kt index e2366ec64..7c3c7dc00 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SwitchPreference.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.animation.AnimatedContent @@ -47,24 +46,22 @@ fun SwitchPreference( containerColor: Color? = null, loading: Boolean = false, ) { + val defaultColors = ListItemDefaults.colors() + + @Suppress("DEPRECATION") + val currentColors = + if (enabled) { + defaultColors + } else { + defaultColors.copy( + headlineColor = defaultColors.contentColor.copy(alpha = 0.5f), + supportingTextColor = defaultColors.supportingContentColor.copy(alpha = 0.5f), + ) + } + .let { if (containerColor != null) it.copy(containerColor = containerColor) else it } + ListItem( - colors = - ListItemDefaults.colors() - .copy( - headlineColor = - if (enabled) { - ListItemDefaults.colors().headlineColor - } else { - ListItemDefaults.colors().headlineColor.copy(alpha = 0.5f) - }, - supportingTextColor = - if (enabled) { - ListItemDefaults.colors().supportingTextColor - } else { - ListItemDefaults.colors().supportingTextColor.copy(alpha = 0.5f) - }, - containerColor = containerColor ?: ListItemDefaults.colors().containerColor, - ), + colors = currentColors, modifier = (padding?.let { Modifier.padding(it) } ?: modifier).toggleable( value = checked, diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt index ee35c04ac..e6945d78e 100644 --- a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt +++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.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.intro import android.Manifest @@ -81,7 +80,7 @@ fun AppIntroductionScreen(onDone: () -> Unit) { // For Android Tiramisu (API 33) and above, this requests POST_NOTIFICATIONS // For lower versions, notificationPermissionState will be null, and this branch isn't // taken. - notificationPermissionState?.launchPermissionRequest() + notificationPermissionState.launchPermissionRequest() } }, ) diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt index 023fa86b2..5cb6816e9 100644 --- a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/NotificationsScreen.kt +++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/NotificationsScreen.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.intro import android.content.Intent @@ -23,9 +22,9 @@ import android.provider.Settings import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.outlined.BatteryAlert -import androidx.compose.material.icons.outlined.Message import androidx.compose.material.icons.outlined.SpeakerPhone import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -74,7 +73,7 @@ internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, on val features = remember { listOf( FeatureUIData( - icon = Icons.Outlined.Message, + icon = Icons.AutoMirrored.Outlined.Message, titleRes = Res.string.incoming_messages, subtitleRes = Res.string.notifications_for_channel_and_direct_messages, ), 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 f56cdf275..1f13e6da9 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 @@ -48,6 +48,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.rounded.SpeakerNotes import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.ChatBubbleOutline @@ -57,7 +58,6 @@ import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.FilterListOff import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.SelectAll -import androidx.compose.material.icons.rounded.SpeakerNotes import androidx.compose.material.icons.rounded.SpeakerNotesOff import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff @@ -828,7 +828,8 @@ private fun QuickChatToggleMenuItem(showQuickChat: Boolean, onDismiss: () -> Uni }, leadingIcon = { Icon( - imageVector = if (showQuickChat) Icons.Rounded.SpeakerNotesOff else Icons.Rounded.SpeakerNotes, + imageVector = + if (showQuickChat) Icons.Rounded.SpeakerNotesOff else Icons.AutoMirrored.Rounded.SpeakerNotes, contentDescription = title, ) }, diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index ad313b59b..0762fea99 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -27,10 +27,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Reply import androidx.compose.material.icons.rounded.AddReaction import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Reply import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -86,7 +86,9 @@ fun MessageActionsContent( ListItem( headlineContent = { Text(stringResource(Res.string.reply)) }, - leadingContent = { Icon(Icons.Rounded.Reply, contentDescription = stringResource(Res.string.reply)) }, + leadingContent = { + Icon(Icons.AutoMirrored.Rounded.Reply, contentDescription = stringResource(Res.string.reply)) + }, modifier = Modifier.clickable(onClick = onReply), ) diff --git a/gradle.properties b/gradle.properties index 746b192b5..f3ddc1719 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,14 +25,15 @@ # 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:MaxMetaspaceSize=2g -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +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 org.gradle.parallel=true -org.gradle.configureondemand=true +# Disabled due to instability with KSP and modern Gradle features +org.gradle.configureondemand=false # Enable caching between builds. org.gradle.caching=true @@ -40,9 +41,14 @@ 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 +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 @@ -60,12 +66,14 @@ android.nonTransitiveRClass=true dependency.analysis.print.build.health=true +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 -enableComposeCompilerMetrics=true -enableComposeCompilerReports=true +enableComposeCompilerMetrics=false +enableComposeCompilerReports=false android.newDsl=false - diff --git a/gradle/develocity.settings.gradle b/gradle/develocity.settings.gradle index 6dcb90cd0..e71f16c30 100644 --- a/gradle/develocity.settings.gradle +++ b/gradle/develocity.settings.gradle @@ -50,7 +50,8 @@ develocity { enabled = true } remote(HttpBuildCache) { - useExpectContinue = true + // Some servers have issues with Expect: 100-continue and return 403. + useExpectContinue = false def cacheUrl = getMeshProperty("GRADLE_CACHE_URL")?.trim() def cacheUsername = getMeshProperty("GRADLE_CACHE_USERNAME")?.trim() def cachePassword = getMeshProperty("GRADLE_CACHE_PASSWORD")?.trim() @@ -59,7 +60,8 @@ develocity { def isLogic = settingsDir.name == "build-logic" println "Meshtastic ${isLogic ? 'Build Logic' : 'Build'}: Remote cache URL found." - url = cacheUrl + // Ensure trailing slash for Develocity/GE servers + url = cacheUrl.endsWith("/") ? cacheUrl : "${cacheUrl}/" if (cacheUsername && cachePassword) { credentials { @@ -71,8 +73,8 @@ develocity { allowInsecureProtocol = true allowUntrustedServer = true - // 403 fix: Only push if we have credentials OR if we're local. - // On CI, we only push if we have the keys to the kingdom. + // Keep push enabled if credentials are provided. + // This naturally disables push for fork PRs which don't have access to secrets. push = (cacheUsername && cachePassword) enabled = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fd45b5587..0cd14d780 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ kotlinx-serialization = "1.10.0" ktlint = "1.7.1" kover = "0.9.5" mockk = "1.14.9" +testRetry = "1.6.4" # Compose Multiplatform compose-multiplatform = "1.10.0" @@ -122,10 +123,9 @@ truth = { module = "com.google.truth:truth", version = "1.4.5" } # Jetbrains kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-metadata-jvm = { module = "org.jetbrains.kotlinx:kotlinx-metadata-jvm", version = "0.9.0" } dokka-gradlePlugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "2.1.0" } -# fixme remove when hilt updates to support kotlin 2.3.x -kotlin-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin"} kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.4.0" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-android" } @@ -204,6 +204,7 @@ ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.g secrets-gradlePlugin = {module = "com.google.android.secrets-gradle-plugin:com.google.android.secrets-gradle-plugin.gradle.plugin", version = "1.1.0"} serialization-gradlePlugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "kotlin" } spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version = "8.2.1" } +test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" } [plugins] # Android @@ -240,6 +241,7 @@ dokka = { id = "org.jetbrains.dokka", version = "2.1.0" } protobuf = { id = "com.google.protobuf", version = "0.9.6" } 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" } # Meshtastic meshtastic-analytics = { id = "meshtastic.analytics" }