desktop: notifications support (#2754)

* desktop: notifications support

* adapted external lib for interacting with notificatioins

* disabled some functions
This commit is contained in:
Stanislav Dmitrenko
2023-07-28 21:01:08 +03:00
committed by GitHub
parent 02d00944ff
commit 2b715a0d8c
12 changed files with 276 additions and 113 deletions

View File

@@ -107,39 +107,18 @@ fun processNotificationIntent(intent: Intent?) {
val chatId = intent.getStringExtra("chatId")
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
if (chatId != null) {
withBGApi {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
chatModel.controller.changeActiveUser(userId, null)
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel)
}
ntfManager.openChatAction(userId, chatId)
}
}
NtfManager.ShowChatsAction -> {
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
withBGApi {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
chatModel.controller.changeActiveUser(userId, null)
}
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
}
ntfManager.showChatsAction(userId)
}
NtfManager.AcceptCallAction -> {
val chatId = intent.getStringExtra("chatId")
if (chatId == null || chatId == "") return
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
chatModel.clearOverlays.value = true
val invitation = chatModel.callInvitations[chatId]
if (invitation == null) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
ntfManager.acceptCallAction(chatId)
}
}
}
@@ -201,19 +180,6 @@ fun processExternalIntent(intent: Intent?) {
fun isMediaIntent(intent: Intent): Boolean =
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
// Still decrypting database
if (chatModel.chatRunning.value == null) {
val step = 50L
for (i in 0..(timeout / step)) {
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
break
}
delay(step)
}
}
}
//fun testJson() {
// val str: String = """
// """.trimIndent()

View File

@@ -139,14 +139,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
androidAppContext = this
APPLICATION_ID = BuildConfig.APPLICATION_ID
ntfManager = object : chat.simplex.common.platform.NtfManager() {
override fun notifyContactConnected(user: User, contact: Contact) = NtfManager.notifyContactConnected(user, contact)
override fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) = NtfManager.notifyContactRequestReceived(user, cInfo)
override fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) = NtfManager.notifyMessageReceived(user, cInfo, cItem)
override fun notifyCallInvitation(invitation: RcvCallInvitation) = NtfManager.notifyCallInvitation(invitation)
override fun hasNotificationsForChat(chatId: String): Boolean = NtfManager.hasNotificationsForChat(chatId)
override fun cancelNotificationsForChat(chatId: String) = NtfManager.cancelNotificationsForChat(chatId)
override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List<NotificationAction>) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions)
override fun createNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert()
override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions.map { it.first })
override fun androidCreateNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert()
override fun cancelCallNotification() = NtfManager.cancelCallNotification()
override fun cancelAllNotifications() = NtfManager.cancelAllNotifications()
}

View File

@@ -15,7 +15,6 @@ import chat.simplex.app.*
import chat.simplex.app.TAG
import chat.simplex.app.views.call.IncomingCallActivity
import chat.simplex.app.views.call.getKeyguardManager
import chat.simplex.common.views.chatlist.acceptContactRequest
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
@@ -82,31 +81,6 @@ object NtfManager {
}
}
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
displayNotification(
user = user,
chatId = cInfo.id,
displayName = cInfo.displayName,
msgText = generalGetString(MR.strings.notification_new_contact_request),
image = cInfo.image,
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
)
}
fun notifyContactConnected(user: User, contact: Contact) {
displayNotification(
user = user,
chatId = contact.id,
displayName = contact.displayName,
msgText = generalGetString(MR.strings.notification_contact_connected)
)
}
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
if (!user.showNotifications) return
Log.d(TAG, "notifyMessageReceived $chatId")
@@ -243,19 +217,6 @@ object NtfManager {
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
private fun hideSecrets(cItem: ChatItem): String {
val md = cItem.formattedText
return if (md != null) {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
res
} else {
cItem.text
}
}
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
Log.d(TAG, "chatPendingIntent for $intentAction")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
@@ -299,18 +260,7 @@ object NtfManager {
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
val m = SimplexApp.context.chatModel
when (intent.action) {
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
val isCurrentUser = m.currentUser.value?.userId == userId
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
(m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
} else {
null
}
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
acceptContactRequest(apiId, cInfo, isCurrentUser, m)
cancelNotificationsForChat(chatId)
}
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, chatId)
RejectCallAction -> {
val invitation = m.callInvitations[chatId]
if (invitation != null) {

View File

@@ -55,6 +55,7 @@ allprojects {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
maven("https://oss.sonatype.org/content/repositories/snapshots")
maven("https://jitpack.io")
}
}

View File

@@ -95,6 +95,7 @@ kotlin {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.1")
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.7")
}
}

View File

@@ -277,6 +277,7 @@ actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? {
actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? {
return try {
val ext = if (asPng) "png" else "jpg"
tmpDir.mkdir()
return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply {
outputStream().use { out ->
image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out)

View File

@@ -13,14 +13,14 @@ actual fun SetNotificationsModeAdditions() {
val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
LaunchedEffect(notificationsPermissionState.hasPermission) {
if (notificationsPermissionState.hasPermission) {
ntfManager.createNtfChannelsMaybeShowAlert()
ntfManager.androidCreateNtfChannelsMaybeShowAlert()
} else {
notificationsPermissionState.launchPermissionRequest()
}
}
} else {
LaunchedEffect(Unit) {
ntfManager.createNtfChannelsMaybeShowAlert()
ntfManager.androidCreateNtfChannelsMaybeShowAlert()
}
}
}

View File

@@ -2,6 +2,12 @@ package chat.simplex.common.platform
import chat.simplex.common.model.*
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.chatlist.acceptContactRequest
import chat.simplex.common.views.chatlist.openChat
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import kotlinx.coroutines.delay
enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
@@ -10,14 +16,104 @@ enum class NotificationAction {
lateinit var ntfManager: NtfManager
abstract class NtfManager {
abstract fun notifyContactConnected(user: User, contact: Contact)
abstract fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest)
abstract fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem)
fun notifyContactConnected(user: User, contact: Contact) = displayNotification(
user = user,
chatId = contact.id,
displayName = contact.displayName,
msgText = generalGetString(MR.strings.notification_contact_connected)
)
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) = displayNotification(
user = user,
chatId = cInfo.id,
displayName = cInfo.displayName,
msgText = generalGetString(MR.strings.notification_new_contact_request),
image = cInfo.image,
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, cInfo.id) })
)
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun acceptContactRequestAction(userId: Long?, chatId: ChatId) {
val isCurrentUser = ChatModel.currentUser.value?.userId == userId
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
(ChatModel.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
} else {
null
}
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
acceptContactRequest(apiId, cInfo, isCurrentUser, ChatModel)
cancelNotificationsForChat(chatId)
}
fun openChatAction(userId: Long?, chatId: ChatId) {
withBGApi {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
chatModel.controller.changeActiveUser(userId, null)
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel)
}
}
fun showChatsAction(userId: Long?) {
withBGApi {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
chatModel.controller.changeActiveUser(userId, null)
}
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
}
}
fun acceptCallAction(chatId: ChatId) {
chatModel.clearOverlays.value = true
val invitation = chatModel.callInvitations[chatId]
if (invitation == null) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
} else {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
}
abstract fun notifyCallInvitation(invitation: RcvCallInvitation)
abstract fun hasNotificationsForChat(chatId: String): Boolean
abstract fun cancelNotificationsForChat(chatId: String)
abstract fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList())
abstract fun createNtfChannelsMaybeShowAlert()
abstract fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<Pair<NotificationAction, () -> Unit>> = emptyList())
abstract fun cancelCallNotification()
abstract fun cancelAllNotifications()
// Android only
abstract fun androidCreateNtfChannelsMaybeShowAlert()
private suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
// Still decrypting database
if (chatModel.chatRunning.value == null) {
val step = 50L
for (i in 0..(timeout / step)) {
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
break
}
delay(step)
}
}
}
private fun hideSecrets(cItem: ChatItem): String {
val md = cItem.formattedText
return if (md != null) {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
res
} else {
cItem.text
}
}
}

View File

@@ -87,7 +87,7 @@ fun showApp() = application {
}
}
}
var windowFocused by remember { mutableStateOf(true) }
var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.performLA.value && delay > 0) {
@@ -119,6 +119,7 @@ class SimplexWindowState {
val openMultipleDialog = DialogState<List<File>>()
val saveDialog = DialogState<File?>()
val toasts = mutableStateListOf<Pair<String, Long>>()
var windowFocused = mutableStateOf(true)
}
data class DialogParams(

View File

@@ -0,0 +1,153 @@
package chat.simplex.common.model
import androidx.compose.ui.graphics.*
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import com.sshtools.twoslices.*
import java.awt.*
import java.awt.TrayIcon.MessageType
import java.io.File
import javax.imageio.ImageIO
object NtfManager {
private val prevNtfs = arrayListOf<Pair<ChatId, Slice>>()
fun notifyCallInvitation(invitation: RcvCallInvitation) {
if (simplexWindowState.windowFocused.value) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
val image = invitation.contact.image
val text = generalGetString(
if (invitation.callType.media == CallMediaType.Video) {
if (invitation.sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
} else {
if (invitation.sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
}
)
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
generalGetString(MR.strings.notification_preview_somebody)
else
invitation.contact.displayName
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
MR.images.icon_foreground_common.image.toComposeImageBitmap()
else
base64ToBitmap(image)
val actions = listOf(
generalGetString(MR.strings.accept) to { ntfManager.acceptCallAction(invitation.contact.id) },
generalGetString(MR.strings.reject) to { ChatModel.callManager.endCall(invitation = invitation) }
)
displayNotificationViaLib(contactId, title, text, prepareIconPath(largeIcon), actions) {
ntfManager.openChatAction(invitation.user.userId, contactId)
}
}
fun hasNotificationsForChat(chatId: ChatId) = false//prevNtfs.any { it.first == chatId }
fun cancelNotificationsForChat(chatId: ChatId) {
val ntf = prevNtfs.firstOrNull { it.first == chatId }
if (ntf != null) {
prevNtfs.remove(ntf)
/*try {
ntf.second.close()
} catch (e: Exception) {
// Can be java.lang.UnsupportedOperationException, for example. May do nothing
println("Failed to close notification: ${e.stackTraceToString()}")
}*/
}
}
fun cancelAllNotifications() {
// prevNtfs.forEach { try { it.second.close() } catch (e: Exception) { println("Failed to close notification: ${e.stackTraceToString()}") } }
prevNtfs.clear()
}
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) {
if (!user.showNotifications) return
Log.d(TAG, "notifyMessageReceived $chatId")
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(MR.strings.notification_preview_somebody) else displayName
val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(MR.strings.notification_preview_new_message) else msgText
val largeIcon = when {
actions.isEmpty() -> null
image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> MR.images.icon_foreground_common.image.toComposeImageBitmap()
else -> base64ToBitmap(image)
}
displayNotificationViaLib(chatId, title, content, prepareIconPath(largeIcon), actions.map { it.first.name to it.second }) {
ntfManager.openChatAction(user.userId, chatId)
}
}
private fun displayNotificationViaLib(
chatId: String,
title: String,
text: String,
iconPath: String?,
actions: List<Pair<String, () -> Unit>>,
defaultAction: (() -> Unit)?
) {
val builder = Toast.builder()
.title(title)
.content(text)
if (iconPath != null) {
builder.icon(iconPath)
}
if (defaultAction != null) {
builder.defaultAction(defaultAction)
}
actions.forEach {
builder.action(it.first, it.second)
}
prevNtfs.add(chatId to builder.toast())
}
private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) {
tmpDir.mkdir()
val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png"))
try {
ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream())
newFile.absolutePath
} catch (e: Exception) {
println("Failed to write an icon to tmpDir: ${e.stackTraceToString()}")
null
}
} else null
private fun displayNotification(title: String, text: String, icon: ImageBitmap?) = when (desktopPlatform) {
DesktopPlatform.LINUX_X86_64, DesktopPlatform.LINUX_AARCH64 -> linuxDisplayNotification(title, text, prepareIconPath(icon))
DesktopPlatform.WINDOWS_X86_64 -> windowsDisplayNotification(title, text, icon)
DesktopPlatform.MAC_X86_64, DesktopPlatform.MAC_AARCH64 -> macDisplayNotification(title, text, prepareIconPath(icon))
}
private fun linuxDisplayNotification(title: String, text: String, iconPath: String?) {
if (iconPath != null) {
Runtime.getRuntime().exec(arrayOf("notify-send", "-i", iconPath, title, text))
} else {
Toast.toast(ToastType.INFO, title, text)
Runtime.getRuntime().exec(arrayOf("notify-send", title, text))
}
}
private fun windowsDisplayNotification(title: String, text: String, icon: ImageBitmap?) {
if (SystemTray.isSupported()) {
val tray = SystemTray.getSystemTray()
tray.remove(tray.trayIcons.firstOrNull { it.toolTip == "SimpleX" })
val trayIcon = TrayIcon(icon?.toAwtImage(), "SimpleX")
trayIcon.isImageAutoSize = true
tray.add(trayIcon)
trayIcon.displayMessage(title, text, MessageType.INFO)
} else {
Log.e(TAG, "System tray not supported!")
}
}
private fun macDisplayNotification(title: String, text: String, iconPath: String?) {
Runtime.getRuntime().exec(arrayOf("osascript", "-e", """display notification "${text.replace("\"", "\\\"")}" with title "${title.replace("\"", "\\\"")}""""))
}
}

View File

@@ -11,17 +11,14 @@ actual val appPlatform = AppPlatform.DESKTOP
val defaultLocale: Locale = Locale.getDefault()
fun initApp() {
ntfManager = object : NtfManager() { // LALAL
override fun notifyContactConnected(user: User, contact: Contact) {}
override fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {}
override fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {}
override fun notifyCallInvitation(invitation: RcvCallInvitation) {}
override fun hasNotificationsForChat(chatId: String): Boolean = false
override fun cancelNotificationsForChat(chatId: String) {}
override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List<NotificationAction>) {}
override fun createNtfChannelsMaybeShowAlert() {}
ntfManager = object : NtfManager() {
override fun notifyCallInvitation(invitation: RcvCallInvitation) = chat.simplex.common.model.NtfManager.notifyCallInvitation(invitation)
override fun hasNotificationsForChat(chatId: String): Boolean = chat.simplex.common.model.NtfManager.hasNotificationsForChat(chatId)
override fun cancelNotificationsForChat(chatId: String) = chat.simplex.common.model.NtfManager.cancelNotificationsForChat(chatId)
override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) = chat.simplex.common.model.NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions)
override fun androidCreateNtfChannelsMaybeShowAlert() {}
override fun cancelCallNotification() {}
override fun cancelAllNotifications() {}
override fun cancelAllNotifications() = chat.simplex.common.model.NtfManager.cancelAllNotifications()
}
applyAppLocale()
withBGApi {

View File

@@ -1,5 +1,5 @@
package chat.simplex.common.platform
import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.simplexWindowState
actual fun allowedToShowNotification(): Boolean = true
actual fun allowedToShowNotification(): Boolean = !simplexWindowState.windowFocused.value