diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 405f6dc3..c4de3b3f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ accompanist-permissions = "0.37.3" # Wear OS wear-compose = "1.6.2" wear-tooling-preview = "1.0.0" +wear-ongoing = "1.1.0" # Cryptography bouncycastle = "1.85" @@ -105,6 +106,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" } androidx-wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wear-compose" } androidx-wear-tooling-preview = { module = "androidx.wear:wear-tooling-preview", version.ref = "wear-tooling-preview" } +androidx-wear-ongoing = { module = "androidx.wear:wear-ongoing", version.ref = "wear-ongoing" } # Cryptography bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4dad1c4d..a11f1a73 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -54,6 +54,11 @@ + + + + + @@ -1941,6 +1946,14 @@ + + + + + + + + diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index a4d37f68..a1e2dcc8 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -65,6 +65,11 @@ android { } } +// Robolectric downloads its Android runtime outside Gradle dependency resolution. +tasks.withType().configureEach { + systemProperty("robolectric.dependency.repo.url", "https://repo.maven.apache.org/maven2") +} + composeCompiler { // Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified // class-file iteration order. Keep the normal R8 mapping, but omit that @@ -187,6 +192,7 @@ dependencies { implementation(libs.androidx.wear.compose.foundation) implementation(libs.androidx.wear.compose.material3) implementation(libs.androidx.wear.tooling.preview) + implementation(libs.androidx.wear.ongoing) implementation(libs.androidx.compose.material.icons.extended) // Lifecycle diff --git a/wear/gradle.lockfile b/wear/gradle.lockfile index 8b9ed0e2..61e2635b 100644 --- a/wear/gradle.lockfile +++ b/wear/gradle.lockfile @@ -155,6 +155,7 @@ androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileCl androidx.wear.compose:compose-foundation:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.wear.compose:compose-material-core:1.6.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.wear.compose:compose-material3:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.wear:wear-ongoing:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.wear:wear-tooling-preview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath diff --git a/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt index ff1e1cbb..86dd9ddc 100644 --- a/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt +++ b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt @@ -11,8 +11,9 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.wear.ongoing.OngoingActivity +import androidx.wear.ongoing.Status import com.bitchat.android.model.BitchatMessage import com.bitchat.android.services.AppStateStore import com.bitchat.watch.MainActivity @@ -42,14 +43,14 @@ class WearMeshForegroundService : Service() { } private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private lateinit var notificationManager: NotificationManagerCompat + private lateinit var notificationBuilder: NotificationCompat.Builder + private lateinit var ongoingActivity: OngoingActivity private lateinit var notificationCoordinator: WearNotificationCoordinator private lateinit var mesh: WearMeshService private var peerCountJob: Job? = null override fun onCreate() { super.onCreate() - notificationManager = NotificationManagerCompat.from(this) notificationCoordinator = WearNotificationCoordinator.getInstance(applicationContext) mesh = WearMeshService.getOrCreate(applicationContext) createChannel() @@ -74,6 +75,7 @@ class WearMeshForegroundService : Service() { mesh.stopServices() } serviceScope.cancel() + stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() } @@ -109,10 +111,12 @@ class WearMeshForegroundService : Service() { } } - private fun updateForegroundNotification(activePeers: Int) { + internal fun updateForegroundNotification(activePeers: Int) { if (!canPostNotifications()) return try { - notificationManager.notify(NOTIFICATION_ID, buildNotification(activePeers)) + val text = meshStatusText(activePeers) + notificationBuilder.setContentText(text) + ongoingActivity.update(this, Status.forPart(Status.TextPart(text))) } catch (_: SecurityException) { // Permission can be revoked between the preflight check and notify(). } @@ -133,35 +137,39 @@ class WearMeshForegroundService : Service() { ) } - private fun buildNotification(activePeers: Int): Notification { + internal fun buildNotification(activePeers: Int): Notification { val launchIntent = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - return NotificationCompat.Builder(this, CHANNEL_ID) + notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(getString(R.string.app_name)) - .setContentText( - if (activePeers == 0) { - getString(R.string.mesh_notification_no_peers) - } else { - resources.getQuantityString( - R.plurals.mesh_notification_text, - activePeers, - activePeers - ) - } - ) + .setContentText(meshStatusText(activePeers)) .setContentIntent(launchIntent) .setOngoing(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_SERVICE) + // setOngoing(true) alone does not create Wear's watch-face return affordance. + // Retain the activity and builder so peer changes update the same session. + ongoingActivity = OngoingActivity.Builder(this, NOTIFICATION_ID, notificationBuilder) + .setStaticIcon(R.drawable.ic_notification) + .setTouchIntent(launchIntent) .build() + ongoingActivity.apply(this) + return notificationBuilder.build() } + private fun meshStatusText(activePeers: Int): String = + if (activePeers == 0) { + getString(R.string.mesh_notification_no_peers) + } else { + resources.getQuantityString(R.plurals.mesh_notification_text, activePeers, activePeers) + } + private fun canPostNotifications(): Boolean { return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission( diff --git a/wear/src/test/java/com/bitchat/watch/service/WearMeshForegroundServiceTest.kt b/wear/src/test/java/com/bitchat/watch/service/WearMeshForegroundServiceTest.kt new file mode 100644 index 00000000..1ca94cd9 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/service/WearMeshForegroundServiceTest.kt @@ -0,0 +1,104 @@ +package com.bitchat.watch.service + +import android.Manifest +import android.app.Application +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import androidx.wear.ongoing.OngoingActivity +import com.bitchat.watch.MainActivity +import com.bitchat.watch.R +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.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class, manifest = Config.NONE) +class WearMeshForegroundServiceTest { + private lateinit var service: WearMeshForegroundService + private lateinit var manager: NotificationManager + + @Before + fun setUp() { + // Attach without onCreate: notification tests must never start Bluetooth services. + service = spy(Robolectric.buildService(WearMeshForegroundService::class.java).get()) + // Keep shared phone tests independent of Wear's minSdk/manifest/resources. + val resources = spy(service.resources) + doReturn(resources).`when`(service).resources + doReturn("Bitchat").`when`(resources).getString(R.string.app_name) + doReturn("Mesh running").`when`(resources).getString(R.string.mesh_notification_no_peers) + for (count in listOf(1, 3)) { + doReturn("$count peers").`when`(resources) + .getQuantityString(R.plurals.mesh_notification_text, count, count) + } + shadowOf(service.application).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + manager = service.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(NotificationChannel( + WearMeshForegroundService.CHANNEL_ID, "Mesh", NotificationManager.IMPORTANCE_LOW + )) + } + + private fun postInitialNotification() { + service.startForeground(WearMeshForegroundService.NOTIFICATION_ID, service.buildNotification(0)) + } + + @Test + fun `mesh notification exposes ongoing activity and immutable return action`() { + postInitialNotification() + val activity = OngoingActivity.recoverOngoingActivity(service)!! + assertEquals(WearMeshForegroundService.NOTIFICATION_ID, activity.notificationId) + assertEquals(R.drawable.ic_notification, activity.staticIcon.resId) + assertEquals(service.getString(R.string.mesh_notification_no_peers), + activity.status!!.getText(service, 0).toString()) + assertTrue(activity.touchIntent.isImmutable) + assertEquals(MainActivity::class.java.name, + shadowOf(activity.touchIntent).savedIntent.component!!.className) + val notification = manager.activeNotifications.single().notification + assertTrue(notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertEquals(notification.contentIntent, activity.touchIntent) + } + + @Test + fun `peer changes preserve the session and update both status surfaces`() { + postInitialNotification() + val original = OngoingActivity.recoverOngoingActivity(service)!! + for (count in listOf(1, 3, 0)) { + service.updateForegroundNotification(count) + val updated = OngoingActivity.recoverOngoingActivity(service)!! + assertEquals(original.timestamp, updated.timestamp) + assertEquals(original.touchIntent, updated.touchIntent) + val notification = manager.activeNotifications.single().notification + val expected = if (count == 0) service.getString(R.string.mesh_notification_no_peers) + else service.resources.getQuantityString(R.plurals.mesh_notification_text, count, count) + assertEquals(expected, notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString()) + assertEquals(expected, updated.status!!.getText(service, 0).toString()) + } + } + + @Test + fun `stopping the mesh service removes the ongoing activity`() { + postInitialNotification() + assertNotNull(OngoingActivity.recoverOngoingActivity(service)) + service.onDestroy() + assertNull(OngoingActivity.recoverOngoingActivity(service)) + } + + @Test + fun `revoked notification permission skips updates without crashing`() { + postInitialNotification() + shadowOf(service.application).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + service.updateForegroundNotification(3) + assertEquals("Mesh running", OngoingActivity.recoverOngoingActivity(service)!! + .status!!.getText(service, 0).toString()) + } +}