mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
wear: add DM notifications and refresh launcher assets
This commit is contained in:
parent
2e8f6ffc35
commit
28261af55c
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
10
wear/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
wear/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
15
wear/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
15
wear/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
16
wear/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
16
wear/src/main/res/drawable/ic_launcher_monochrome.xml
Normal 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>
|
||||
10
wear/src/main/res/drawable/ic_notification.xml
Normal file
10
wear/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
6
wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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")))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user