From 28261af55c9894f6eaa047836e43b7c69f8ebcb7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:23:17 +0200 Subject: [PATCH] wear: add DM notifications and refresh launcher assets --- wear/build.gradle.kts | 1 + wear/src/main/AndroidManifest.xml | 4 +- .../bitchat/watch/BitchatWatchApplication.kt | 2 + .../java/com/bitchat/watch/MainActivity.kt | 114 ++++++++- .../WearNotificationCoordinator.kt | 230 ++++++++++++++++++ .../notification/WearNotificationPolicy.kt | 18 ++ .../service/WearMeshForegroundService.kt | 126 ++++++++-- .../java/com/bitchat/watch/ui/DmScreen.kt | 4 + .../com/bitchat/watch/ui/WearChatState.kt | 13 +- wear/src/main/res/drawable/ic_launcher.xml | 16 -- .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 15 ++ .../res/drawable/ic_launcher_monochrome.xml | 16 ++ .../src/main/res/drawable/ic_notification.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + wear/src/main/res/values/strings.xml | 13 +- .../WearNotificationPolicyTest.kt | 62 +++++ 18 files changed, 620 insertions(+), 46 deletions(-) create mode 100644 wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt create mode 100644 wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt delete mode 100644 wear/src/main/res/drawable/ic_launcher.xml create mode 100644 wear/src/main/res/drawable/ic_launcher_background.xml create mode 100644 wear/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 wear/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 wear/src/main/res/drawable/ic_notification.xml create mode 100644 wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 12cf5534..ce6a5ad1 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -86,6 +86,7 @@ val sharedSourceIncludes = listOf( "com/bitchat/android/features/voice/**", "com/bitchat/android/ui/debug/DebugSettingsManager.kt", "com/bitchat/android/ui/debug/DebugPreferenceManager.kt", + "com/bitchat/android/ui/NotificationTextUtils.kt", "com/bitchat/android/util/AppConstants.kt", "com/bitchat/android/util/ByteArrayExtensions.kt", "com/bitchat/android/util/ByteArrayWrapper.kt", diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 9fc4d290..531286cf 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -23,7 +23,8 @@ @@ -40,6 +41,7 @@ diff --git a/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt index fa3a518c..f480cfdd 100644 --- a/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt +++ b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt @@ -2,10 +2,12 @@ package com.bitchat.watch import android.app.Application import com.bitchat.android.mesh.PowerManager +import com.bitchat.watch.notification.WearNotificationCoordinator class BitchatWatchApplication : Application() { override fun onCreate() { super.onCreate() PowerManager.getInstance(applicationContext) + WearNotificationCoordinator.getInstance(applicationContext) } } diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index 69debeab..342ad19e 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -37,7 +38,9 @@ import androidx.core.content.ContextCompat import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TextButton import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator import com.bitchat.watch.service.WearMeshForegroundService import com.bitchat.watch.ui.ChatScreen import com.bitchat.watch.ui.DmScreen @@ -60,11 +63,15 @@ class MainActivity : ComponentActivity() { private var hasPermissions by mutableStateOf(false) private var bluetoothEnabled by mutableStateOf(false) private var nicknameChosen by mutableStateOf(false) + private var notificationsGranted by mutableStateOf(false) + private var notificationPromptDismissed by mutableStateOf(false) + private var pendingDmPeer by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) .getBoolean("nickname_chosen", false) + pendingDmPeer = privateMessagePeerFromIntent(intent) refreshState() setContent { BitchatWearTheme { @@ -79,7 +86,18 @@ class MainActivity : ComponentActivity() { .edit().putBoolean("nickname_chosen", true).apply() nicknameChosen = true } - else -> WearNavHost() + !notificationsGranted && !notificationPromptDismissed -> + NotificationPermissionScreen( + onResult = { granted -> + notificationPromptDismissed = !granted + refreshState() + }, + onSkip = { notificationPromptDismissed = true } + ) + else -> WearNavHost( + openDmPeer = pendingDmPeer, + onOpenDmHandled = { pendingDmPeer = null } + ) } } } @@ -87,13 +105,30 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + WearChatState.setAppInForeground(true) + WearChatState.openDmPeer?.let { peerID -> + WearChatState.openDm(peerID) + WearNotificationCoordinator.getInstance(applicationContext).clearConversation(peerID) + } refreshState() } + override fun onPause() { + WearChatState.setAppInForeground(false) + super.onPause() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + privateMessagePeerFromIntent(intent)?.let { pendingDmPeer = it } + } + private fun refreshState() { hasPermissions = requiredPermissions().all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } + notificationsGranted = notificationPermissionGranted() val adapter = getSystemService(BluetoothManager::class.java)?.adapter bluetoothEnabled = adapter?.isEnabled == true if (hasPermissions && bluetoothEnabled) { @@ -102,13 +137,26 @@ class MainActivity : ComponentActivity() { } private fun startMeshService() { - val mesh = WearMeshService.getOrCreate(applicationContext) - mesh.onPrivateMessage = { message -> - message.senderPeerID?.let { WearChatState.onPrivateMessageArrived(it) } - } + WearMeshService.getOrCreate(applicationContext) startForegroundService(Intent(this, WearMeshForegroundService::class.java)) } + private fun privateMessagePeerFromIntent(intent: Intent?): String? { + if (intent?.getBooleanExtra(WearNotificationCoordinator.EXTRA_OPEN_DM, false) != true) { + return null + } + return intent.getStringExtra(WearNotificationCoordinator.EXTRA_PEER_ID) + ?.takeIf { it.isNotBlank() } + } + + private fun notificationPermissionGranted(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + companion object { fun requiredPermissions(): List = buildList { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -116,15 +164,12 @@ class MainActivity : ComponentActivity() { add(Manifest.permission.BLUETOOTH_CONNECT) add(Manifest.permission.BLUETOOTH_ADVERTISE) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - add(Manifest.permission.POST_NOTIFICATIONS) - } } } } @Composable -fun WearNavHost() { +fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { var screen by remember { mutableStateOf(WearScreen.Chat) } val backStack = remember { mutableStateListOf() } @@ -143,6 +188,14 @@ fun WearNavHost() { BackHandler(enabled = backStack.isNotEmpty()) { goBack() } + LaunchedEffect(openDmPeer) { + openDmPeer?.let { peerID -> + backStack.clear() + screen = WearScreen.Dm(peerID) + onOpenDmHandled() + } + } + AnimatedContent( targetState = screen, transitionSpec = { @@ -181,6 +234,49 @@ fun WearNavHost() { } } +@Composable +fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> onResult(granted) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "message alerts", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "get notified when an encrypted direct message arrives", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp, bottom = 10.dp) + ) + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + onResult(true) + } + } + ) { + Text("enable") + } + TextButton(onClick = onSkip) { + Text("not now") + } + } +} + @Composable fun PermissionRequestScreen(onGranted: () -> Unit) { val launcher = rememberLauncherForActivityResult( diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt new file mode 100644 index 00000000..ab69375c --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt @@ -0,0 +1,230 @@ +package com.bitchat.watch.notification + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.content.ContextCompat +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.NotificationTextUtils +import com.bitchat.watch.MainActivity +import com.bitchat.watch.R +import com.bitchat.watch.ui.WearChatState +import java.util.concurrent.ConcurrentHashMap + +/** + * Process-wide owner for local Wear notifications. + * + * Mesh delivery must not depend on an Activity being alive, so the foreground service invokes this + * coordinator directly. UI state is consulted only to suppress an alert for the exact DM that is + * currently visible while the app is resumed. + */ +class WearNotificationCoordinator private constructor(context: Context) { + + companion object { + const val EXTRA_OPEN_DM = "com.bitchat.watch.extra.OPEN_DM" + const val EXTRA_PEER_ID = "com.bitchat.watch.extra.PEER_ID" + + private const val MESSAGE_CHANNEL_ID = "bitchat_watch_messages" + private const val GROUP_KEY_DM = "bitchat_watch_dm_group" + private const val SUMMARY_NOTIFICATION_ID = 2 + private const val CONVERSATION_NOTIFICATION_ID_BASE = 10_000 + + @Volatile + private var instance: WearNotificationCoordinator? = null + + fun getInstance(context: Context): WearNotificationCoordinator { + return instance ?: synchronized(this) { + instance ?: WearNotificationCoordinator(context.applicationContext).also { + instance = it + } + } + } + } + + private data class PendingMessage( + val senderNickname: String, + val preview: String, + val timestamp: Long + ) + + private val appContext = context.applicationContext + private val notificationManager = NotificationManagerCompat.from(appContext) + private val pendingMessages = ConcurrentHashMap>() + + init { + createMessageChannel() + } + + @Synchronized + fun onPrivateMessage( + message: BitchatMessage, + senderPeerID: String, + senderNickname: String + ) { + val shouldNotify = WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = senderPeerID, + senderIsSystem = message.sender == "system", + appInForeground = WearChatState.appInForeground, + openDmPeer = WearChatState.openDmPeer + ) + if (!shouldNotify || !canPostNotifications()) return + + pendingMessages.getOrPut(senderPeerID) { mutableListOf() }.add( + PendingMessage( + senderNickname = senderNickname, + preview = NotificationTextUtils.buildPrivateMessagePreview(message), + timestamp = message.timestamp.time + ) + ) + + postConversationNotification(senderPeerID) + if (pendingMessages.size > 1) { + postSummaryNotification() + } + } + + @Synchronized + fun clearConversation(peerID: String) { + pendingMessages.remove(peerID) + notificationManager.cancel(conversationNotificationId(peerID)) + updateSummaryNotification() + } + + private fun postConversationNotification(peerID: String) { + val messages = pendingMessages[peerID] ?: return + val latest = messages.lastOrNull() ?: return + val sender = Person.Builder() + .setName(latest.senderNickname) + .setKey(peerID) + .build() + val user = Person.Builder() + .setName(appContext.getString(R.string.app_name)) + .build() + val style = NotificationCompat.MessagingStyle(user) + .setConversationTitle(latest.senderNickname) + messages.takeLast(5).forEach { pending -> + style.addMessage(pending.preview, pending.timestamp, sender) + } + + val contentIntent = PendingIntent.getActivity( + appContext, + conversationNotificationId(peerID), + Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(EXTRA_OPEN_DM, true) + putExtra(EXTRA_PEER_ID, peerID) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val publicVersion = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(appContext.getString(R.string.notification_new_private_message)) + .setContentText(appContext.getString(R.string.notification_private_message_hidden)) + .build() + + val notification = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(latest.senderNickname) + .setContentText(latest.preview) + .setContentIntent(contentIntent) + .setStyle(style) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + .setAutoCancel(true) + .setOnlyAlertOnce(messages.size > 1) + .setWhen(latest.timestamp) + .setShowWhen(true) + .setGroup(GROUP_KEY_DM) + .addPerson(sender) + .build() + + try { + notificationManager.notify(conversationNotificationId(peerID), notification) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun postSummaryNotification() { + val totalMessages = pendingMessages.values.sumOf { it.size } + val contentIntent = PendingIntent.getActivity( + appContext, + SUMMARY_NOTIFICATION_ID, + Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val summary = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(appContext.getString(R.string.app_name)) + .setContentText( + appContext.resources.getQuantityString( + R.plurals.notification_private_message_summary, + totalMessages, + totalMessages + ) + ) + .setContentIntent(contentIntent) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY_DM) + .setGroupSummary(true) + .build() + try { + notificationManager.notify(SUMMARY_NOTIFICATION_ID, summary) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun updateSummaryNotification() { + if (pendingMessages.size > 1) { + if (canPostNotifications()) postSummaryNotification() + } else { + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } + } + + private fun createMessageChannel() { + val channel = NotificationChannel( + MESSAGE_CHANNEL_ID, + appContext.getString(R.string.message_channel_name), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = appContext.getString(R.string.message_channel_description) + enableVibration(true) + setShowBadge(true) + lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE + } + appContext.getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + + private fun canPostNotifications(): Boolean { + val permissionGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + appContext, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + return permissionGranted && notificationManager.areNotificationsEnabled() + } + + private fun conversationNotificationId(peerID: String): Int { + return CONVERSATION_NOTIFICATION_ID_BASE + (peerID.hashCode() and 0x3FFFFFFF) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt new file mode 100644 index 00000000..b0edf915 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt @@ -0,0 +1,18 @@ +package com.bitchat.watch.notification + +/** + * Pure notification decisions shared by the watch service and unit tests. + */ +object WearNotificationPolicy { + fun shouldNotifyPrivateMessage( + senderPeerID: String, + senderIsSystem: Boolean, + appInForeground: Boolean, + openDmPeer: String? + ): Boolean { + if (senderIsSystem) return false + return !appInForeground || openDmPeer != senderPeerID + } + + fun activePeerCount(peers: Collection): Int = peers.distinct().size +} 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 855fea41..d1fdac0e 100644 --- a/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt +++ b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt @@ -6,12 +6,29 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.PackageManager 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 com.bitchat.android.model.BitchatMessage +import com.bitchat.android.services.AppStateStore import com.bitchat.watch.MainActivity import com.bitchat.watch.R import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.notification.WearNotificationPolicy +import com.bitchat.watch.ui.WearChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch /** * Keeps the BLE mesh (scan + advertise + GATT) alive while the app is backgrounded or the watch @@ -24,19 +41,39 @@ class WearMeshForegroundService : Service() { const val NOTIFICATION_ID = 1 } + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private lateinit var notificationManager: NotificationManagerCompat + 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() - startForeground() - WearMeshService.getOrCreate(applicationContext).startServices() + startForeground(WearNotificationPolicy.activePeerCount(AppStateStore.peers.value)) + observePeerCount() + mesh.onPrivateMessage = ::handlePrivateMessage + mesh.startServices() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + updateForegroundNotification( + WearNotificationPolicy.activePeerCount(AppStateStore.peers.value) + ) return START_STICKY } override fun onDestroy() { - WearMeshService.peek()?.stopServices() + peerCountJob?.cancel() + peerCountJob = null + if (::mesh.isInitialized) { + mesh.onPrivateMessage = null + mesh.stopServices() + } + serviceScope.cancel() super.onDestroy() } @@ -54,25 +91,78 @@ class WearMeshForegroundService : Service() { getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } - private fun startForeground() { - val launchIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE - ) - val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.mesh_notification_text)) - .setContentIntent(launchIntent) - .setOngoing(true) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .build() + private fun startForeground(activePeers: Int) { + val notification = buildNotification(activePeers) startForeground( NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE ) } + + private fun observePeerCount() { + peerCountJob = serviceScope.launch { + AppStateStore.peers + .map(WearNotificationPolicy::activePeerCount) + .distinctUntilChanged() + .collect(::updateForegroundNotification) + } + } + + private fun updateForegroundNotification(activePeers: Int) { + if (!canPostNotifications()) return + try { + notificationManager.notify(NOTIFICATION_ID, buildNotification(activePeers)) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun handlePrivateMessage(message: BitchatMessage) { + val senderPeerID = message.senderPeerID ?: return + if (message.sender == "system") return + + WearChatState.onPrivateMessageArrived(senderPeerID) + val senderNickname = mesh.getPeerNickname(senderPeerID) + ?: message.sender.takeIf { it.isNotBlank() && it != senderPeerID } + ?: senderPeerID.take(8) + notificationCoordinator.onPrivateMessage( + message = message, + senderPeerID = senderPeerID, + senderNickname = senderNickname + ) + } + + private 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) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.app_name)) + .setContentText( + resources.getQuantityString( + R.plurals.mesh_notification_text, + activePeers, + activePeers + ) + ) + .setContentIntent(launchIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + private fun canPostNotifications(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index f6cd7154..f00e7ef7 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults import androidx.wear.compose.foundation.rotary.rotaryScrollable @@ -33,6 +34,7 @@ import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import com.bitchat.android.services.AppStateStore import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator import com.bitchat.watch.ui.media.FullScreenImageViewer import com.bitchat.watch.ui.theme.BitchatMotion import com.bitchat.watch.ui.theme.ChatVisualTokens @@ -41,6 +43,7 @@ import com.bitchat.watch.ui.theme.colorForPeer @Composable fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) { + val context = LocalContext.current val privateMessages by AppStateStore.privateMessages.collectAsState() val messages = privateMessages[peerID] ?: emptyList() val mesh = WearMeshService.peek() @@ -58,6 +61,7 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) { DisposableEffect(peerID) { WearChatState.openDm(peerID) + WearNotificationCoordinator.getInstance(context).clearConversation(peerID) onDispose { WearChatState.closeDm() } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt index bc6de4b7..16dc2c0a 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt @@ -11,19 +11,30 @@ object WearChatState { private val _unreadDms = MutableStateFlow>(emptyMap()) val unreadDms: StateFlow> = _unreadDms.asStateFlow() + @Volatile + var appInForeground: Boolean = false + private set + @Volatile var openDmPeer: String? = null + @Synchronized fun onPrivateMessageArrived(peerID: String) { - if (openDmPeer == peerID) return + if (appInForeground && openDmPeer == peerID) return _unreadDms.value = _unreadDms.value + (peerID to ((_unreadDms.value[peerID] ?: 0) + 1)) } + fun setAppInForeground(inForeground: Boolean) { + appInForeground = inForeground + } + + @Synchronized fun openDm(peerID: String) { openDmPeer = peerID _unreadDms.value = _unreadDms.value - peerID } + @Synchronized fun closeDm() { openDmPeer = null } diff --git a/wear/src/main/res/drawable/ic_launcher.xml b/wear/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index f1b68e08..00000000 --- a/wear/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..b63113ef --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..dbc4ad64 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_monochrome.xml b/wear/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..a9e8c8af --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_notification.xml b/wear/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..bc355f16 --- /dev/null +++ b/wear/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 7ce8f2b8..2a2ef2f3 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -3,5 +3,16 @@ bitchat Mesh network Keeps the Bluetooth mesh running in the background - Bluetooth mesh active + + Mesh running — %1$d peer + Mesh running — %1$d peers + + Direct messages + Encrypted direct-message alerts + New encrypted message + Unlock to view the message + + %1$d new message + %1$d new messages + diff --git a/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt new file mode 100644 index 00000000..ab84d5c7 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt @@ -0,0 +1,62 @@ +package com.bitchat.watch.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearNotificationPolicyTest { + + @Test + fun `system private messages never notify`() { + assertFalse( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = true, + appInForeground = false, + openDmPeer = null + ) + ) + } + + @Test + fun `visible matching dm suppresses notification`() { + assertFalse( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = true, + openDmPeer = "peer-a" + ) + ) + } + + @Test + fun `backgrounded matching dm still notifies`() { + assertTrue( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = false, + openDmPeer = "peer-a" + ) + ) + } + + @Test + fun `different visible dm still notifies`() { + assertTrue( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = true, + openDmPeer = "peer-b" + ) + ) + } + + @Test + fun `peer count is distinct`() { + assertEquals(2, WearNotificationPolicy.activePeerCount(listOf("peer-a", "peer-a", "peer-b"))) + } +}