Add Wear ongoing activity for the mesh service

This commit is contained in:
callebtc 2026-10-06 13:55:29 +02:00
parent 795715a5c8
commit 036364e923
6 changed files with 152 additions and 18 deletions

View File

@ -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" }

View File

@ -54,6 +54,11 @@
<sha256 value="530708656d380605077ea8bca9ae1372fbd1af38e375f411d4e43263bd510c3c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation" version="1.8.1">
<artifact name="annotation-1.8.1.module">
<sha256 value="e6386e85afdd865044e216575e493ed39a63a6325bd925379a21429c3941c8b5" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation" version="1.9.1">
<artifact name="annotation-1.9.1.module">
<sha256 value="f204b05b728a97561718bc716242e47c629c0085a80ee74fca53d4d638bcbe3f" origin="Generated by Gradle"/>
@ -1941,6 +1946,14 @@
<sha256 value="1f72f836339d03c6eb013f65075e76ca87075a577578eb4f95f74a3a5d253128" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.wear" name="wear-ongoing" version="1.1.0">
<artifact name="wear-ongoing-1.1.0.aar">
<sha256 value="a83a5a348eef057f02c4a05c435cead99bc5ae28c7fd6e66088dff39c9366675" origin="Generated by Gradle"/>
</artifact>
<artifact name="wear-ongoing-1.1.0.module">
<sha256 value="a7500515f8b9ffdb3337532643e6a74c60307edae5f40855c129ad5d2e7a5a94" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.wear" name="wear-tooling-preview" version="1.0.0">
<artifact name="wear-tooling-preview-1.0.0.aar">
<sha256 value="a918b2cab21826f75f678189f4f0a0963f953f387b8ab700f3fb9fa03745dace" origin="Generated by Gradle"/>

View File

@ -65,6 +65,11 @@ android {
}
}
// Robolectric downloads its Android runtime outside Gradle dependency resolution.
tasks.withType<org.gradle.api.tasks.testing.Test>().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

View File

@ -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

View File

@ -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(

View File

@ -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())
}
}