mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-25 07:40:36 +00:00
Add Wear ongoing activity for the mesh service
This commit is contained in:
parent
795715a5c8
commit
036364e923
@ -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" }
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user