diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3da1b164..adf7d2ab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,7 +111,7 @@ jobs: - name: Run Android unit tests with coverage run: | cd android - ./gradlew clean testDebugUnitTest jacocoTestReport --stacktrace --no-daemon + ./gradlew clean testGmsDebugUnitTest testFossDebugUnitTest jacocoTestReport --stacktrace --no-daemon - name: Upload coverage to Codecov if: ${{ github.actor != 'dependabot[bot]' }} @@ -183,7 +183,7 @@ jobs: profile: pixel_6 disable-animations: true emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: cd android && ./gradlew clean connectedDebugAndroidTest --stacktrace --no-daemon + script: cd android && ./gradlew clean connectedGmsDebugAndroidTest --stacktrace --no-daemon - name: Upload instrumented test results uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 1c2cd91f..db827999 100644 --- a/README.md +++ b/README.md @@ -621,6 +621,20 @@ Listens for notification action performed events. ### Android Setup +The plugin ships `gms` and `foss` flavours, so every consuming app must pick one +in `gen/android/app/build.gradle.kts`: + +```kotlin +android { + defaultConfig { + missingDimensionStrategy("push", "gms") // or "foss" + } +} +``` + +`foss` drops Firebase Cloud Messaging and the embedded FCM distributor, for +builds shipped through F-Droid or IzzyOnDroid; push then uses UnifiedPush only. + 1. The plugin automatically includes required permissions 2. For custom sounds: - Place sound files in `res/raw/` folder diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 83bef07b..ad691373 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -31,8 +31,7 @@ android { buildConfigField("boolean", "ENABLE_PUSH_NOTIFICATIONS", "$enablePush") } - // FCM pulls proprietary Google libraries that FOSS repos reject. The foss - // flavour drops them, leaving UnifiedPush as the only transport. + // FCM pulls proprietary Google libraries that FOSS repos reject. flavorDimensions += "push" productFlavors { create("gms") @@ -109,8 +108,9 @@ dependencies { implementation(project(":tauri-android")) } +// gms only: aggregating both flavours gives Jacoco two FcmBridge classes at one FQN. tasks.register("jacocoTestReport") { - dependsOn("testDebugUnitTest") + dependsOn("testGmsDebugUnitTest") reports { xml.required.set(true) @@ -127,7 +127,7 @@ tasks.register("jacocoTestReport") { "android/**/*.*" ) - val debugTree = fileTree("${layout.buildDirectory.get().asFile}/tmp/kotlin-classes/debug") { + val debugTree = fileTree("${layout.buildDirectory.get().asFile}/tmp/kotlin-classes/gmsDebug") { exclude(fileFilter) } @@ -135,10 +135,11 @@ tasks.register("jacocoTestReport") { sourceDirectories.setFrom(files(listOf( "$mainSrc/main/java", - "$mainSrc/main/kotlin" + "$mainSrc/main/kotlin", + "$mainSrc/gms/java" ))) classDirectories.setFrom(files(debugTree)) executionData.setFrom(fileTree(layout.buildDirectory.get().asFile) { - include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + include("outputs/unit_test_code_coverage/gmsDebugUnitTest/testGmsDebugUnitTest.exec") }) } diff --git a/android/src/main/java/app/tauri/notification/FcmResult.kt b/android/src/main/java/app/tauri/notification/FcmResult.kt index 9432f292..563d066a 100644 --- a/android/src/main/java/app/tauri/notification/FcmResult.kt +++ b/android/src/main/java/app/tauri/notification/FcmResult.kt @@ -3,10 +3,10 @@ package app.tauri.notification sealed class FcmTokenResult { data class Success(val token: String) : FcmTokenResult() - /** The request ran and failed. Unlike [Unavailable], this triggers `push-error`. */ + /** Ran and failed. Unlike [Unavailable], triggers `push-error`. */ data class Failed(val message: String) : FcmTokenResult() - /** Firebase is absent or threw before the request ran. */ + /** Firebase absent, or threw before the request ran. */ data class Unavailable(val message: String) : FcmTokenResult() } @@ -14,6 +14,6 @@ sealed class FcmDeleteResult { object Deleted : FcmDeleteResult() data class Failed(val message: String) : FcmDeleteResult() - /** No default FirebaseApp, so there was nothing to delete. */ + /** No default FirebaseApp: nothing to delete. */ object NotConfigured : FcmDeleteResult() } diff --git a/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt index 7314ade0..aef6128a 100644 --- a/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt +++ b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt @@ -202,6 +202,8 @@ class UnifiedPushNotifierTest { val posted = shadowNotificationManager().getNotification(null, canonicalId("!enc:example.org"))!! assertTrue(posted.extras.getString(Notification.EXTRA_TEXT)!!.contains("decrypted")) assertEquals("Room 1", posted.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE)) + // The repost must not alert again. + assertTrue(posted.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0) } finally { unmockkObject(PushPayloadDecryptor) } @@ -318,7 +320,8 @@ class UnifiedPushNotifierTest { // A tagged lookup for the same id must find nothing: warm // enrichment/clear uses the untagged key (null, id). assertNull(shadowNotificationManager().getNotification("!r1:example.org", id)) - assertTrue(posted!!.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0) + // A first cold push must alert; only the silent repost sets ONLY_ALERT_ONCE. + assertTrue(posted!!.flags and Notification.FLAG_ONLY_ALERT_ONCE == 0) assertTrue(posted.flags and Notification.FLAG_AUTO_CANCEL != 0) } diff --git a/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts b/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts index 99946fcf..3b2ee029 100644 --- a/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts +++ b/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts @@ -25,6 +25,8 @@ android { targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + // The plugin has a gms/foss flavour dimension; consumers must pick one. + missingDimensionStrategy("push", "gms") } buildTypes { getByName("debug") { diff --git a/src/desktop.rs b/src/desktop.rs index 50084476..95573219 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -402,11 +402,11 @@ impl Notifications { } #[allow(clippy::unused_self)] - pub fn is_ignoring_battery_optimizations(&self) -> crate::Result { + pub const fn is_ignoring_battery_optimizations(&self) -> crate::Result { Ok(true) } - pub fn request_ignore_battery_optimizations(&self) -> crate::Result<()> { + pub const fn request_ignore_battery_optimizations(&self) -> crate::Result<()> { Ok(()) } diff --git a/src/mobile.rs b/src/mobile.rs index 5f48513a..21e95551 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -1,3 +1,6 @@ +// Async on every target for a uniform API; some platform branches never await. +#![allow(clippy::unused_async)] + use serde::de::DeserializeOwned; use tauri::{ AppHandle, Runtime,