wear: add DM notifications and refresh launcher assets

This commit is contained in:
callebtc 2026-07-29 01:23:17 +02:00
parent 2e8f6ffc35
commit 28261af55c
18 changed files with 620 additions and 46 deletions

View File

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

View File

@ -23,7 +23,8 @@
<application
android:name=".BitchatWatchApplication"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">
@ -40,6 +41,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/BitchatLaunchTheme">
<intent-filter>

View File

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

View File

@ -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<String?>(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<String> = 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>(WearScreen.Chat) }
val backStack = remember { mutableStateListOf<WearScreen>() }
@ -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(

View File

@ -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<String, MutableList<PendingMessage>>()
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)
}
}

View File

@ -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<String>): Int = peers.distinct().size
}

View File

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

View File

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

View File

@ -11,19 +11,30 @@ object WearChatState {
private val _unreadDms = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadDms: StateFlow<Map<String, Int>> = _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
}

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#000000"
android:pathData="M0,0h48v48h-48z" />
<path
android:fillColor="#32D74B"
android:pathData="M10,14l10,10l-10,10v-6l5,-4l-5,-4z" />
<path
android:fillColor="#32D74B"
android:pathData="M22,32h16v4h-16z" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
<path
android:pathData="M-0.2617076 -0.1145289H135.7284V135.5812H-0.2617076V-0.1145289Z"
android:fillColor="#000000" />
</vector>

View File

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#FFFFFF" />
</group>
</vector>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:name="vector"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#000000" />
</group>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,3h16c1.1,0 2,0.9 2,2v11c0,1.1 -0.9,2 -2,2H7l-5,4V5c0,-1.1 0.9,-2 2,-2z" />
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@ -3,5 +3,16 @@
<string name="app_name">bitchat</string>
<string name="mesh_channel_name">Mesh network</string>
<string name="mesh_channel_description">Keeps the Bluetooth mesh running in the background</string>
<string name="mesh_notification_text">Bluetooth mesh active</string>
<plurals name="mesh_notification_text">
<item quantity="one">Mesh running — %1$d peer</item>
<item quantity="other">Mesh running — %1$d peers</item>
</plurals>
<string name="message_channel_name">Direct messages</string>
<string name="message_channel_description">Encrypted direct-message alerts</string>
<string name="notification_new_private_message">New encrypted message</string>
<string name="notification_private_message_hidden">Unlock to view the message</string>
<plurals name="notification_private_message_summary">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
</resources>

View File

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