From 036364e9230cb2fe079f9ae396a275bba4bd94fb Mon Sep 17 00:00:00 2001
From: callebtc <93376500+callebtc@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:55:29 +0200
Subject: [PATCH] Add Wear ongoing activity for the mesh service
---
gradle/libs.versions.toml | 2 +
gradle/verification-metadata.xml | 13 +++
wear/build.gradle.kts | 6 +
wear/gradle.lockfile | 1 +
.../service/WearMeshForegroundService.kt | 44 +++++---
.../service/WearMeshForegroundServiceTest.kt | 104 ++++++++++++++++++
6 files changed, 152 insertions(+), 18 deletions(-)
create mode 100644 wear/src/test/java/com/bitchat/watch/service/WearMeshForegroundServiceTest.kt
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())
+ }
+}