package com.github.gotify.service import android.app.* import android.content.Intent import android.content.IntentFilter import android.graphics.Color import android.net.ConnectivityManager import android.net.Uri import android.os.Build import android.os.IBinder import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.github.gotify.* import com.github.gotify.api.Callback import com.github.gotify.api.ClientFactory import com.github.gotify.client.api.MessageApi import com.github.gotify.client.model.Message import com.github.gotify.log.Log import com.github.gotify.log.UncaughtExceptionHandler import com.github.gotify.messages.Extras import com.github.gotify.messages.MessagesActivity import com.github.gotify.picasso.PicassoHandler import com.github.gotify.service.WebSocketConnection.BadRequestRunnable import com.github.gotify.service.WebSocketConnection.OnNetworkFailureRunnable import io.noties.markwon.Markwon import java.util.concurrent.atomic.AtomicLong class WebSocketService : Service() { companion object { val NEW_MESSAGE_BROADCAST = "${WebSocketService::class.java.name}.NEW_MESSAGE" private const val NOT_LOADED = -2L } private lateinit var settings: Settings private var connection: WebSocketConnection? = null private val lastReceivedMessage = AtomicLong(NOT_LOADED) private lateinit var missingMessageUtil: MissedMessageUtil private lateinit var picassoHandler: PicassoHandler private lateinit var markwon: Markwon override fun onCreate() { super.onCreate() settings = Settings(this) val client = ClientFactory.clientToken( settings.url(), settings.sslSettings(), settings.token() ) missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java)) Log.i("Create ${javaClass.simpleName}") picassoHandler = PicassoHandler(this, settings) markwon = MarkwonFactory.createForNotification(this, picassoHandler.get()) } override fun onDestroy() { super.onDestroy() if (connection != null) { connection!!.close() } Log.w("Destroy ${javaClass.simpleName}") } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { Log.init(this) if (connection != null) { connection!!.close() } Log.i("Starting ${javaClass.simpleName}") super.onStartCommand(intent, flags, startId) Thread { startPushService() }.start() return START_STICKY } private fun startPushService() { UncaughtExceptionHandler.registerCurrentThread() showForegroundNotification(getString(R.string.websocket_init)) if (lastReceivedMessage.get() == NOT_LOADED) { missingMessageUtil.lastReceivedMessage { lastReceivedMessage.set(it) } } val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager connection = WebSocketConnection( settings.url(), settings.sslSettings(), settings.token(), cm, alarmManager ) .onOpen { onOpen() } .onClose { onClose() } .onBadRequest(object : BadRequestRunnable { override fun execute(message: String) { onBadRequest(message) } }) .onNetworkFailure(object : OnNetworkFailureRunnable { override fun execute(minutes: Int) { onNetworkFailure(minutes) } }) .onMessage { onMessage(it) } .onReconnected { notifyMissedNotifications() } .start() val intentFilter = IntentFilter() intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION) picassoHandler.updateAppIds() } private fun onClose() { showForegroundNotification( getString(R.string.websocket_closed), getString(R.string.websocket_reconnect) ) ClientFactory.userApiWithToken(settings) .currentUser() .enqueue(Callback.call({ doReconnect() }) { exception -> if (exception.code() == 401) { showForegroundNotification( getString(R.string.user_action), getString(R.string.websocket_closed_logout) ) } else { Log.i("WebSocket closed but the user still authenticated, trying to reconnect") doReconnect() } }) } private fun doReconnect() { if (connection == null) { return } connection!!.scheduleReconnect(15) } private fun onBadRequest(message: String) { showForegroundNotification(getString(R.string.websocket_could_not_connect), message) } private fun onNetworkFailure(minutes: Int) { val status = getString(R.string.websocket_not_connected) val intervalUnit = resources .getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes) showForegroundNotification( status, "${getString(R.string.websocket_reconnect)} $intervalUnit" ) } private fun onOpen() { showForegroundNotification(getString(R.string.websocket_listening)) } private fun notifyMissedNotifications() { val messageId = lastReceivedMessage.get() if (messageId == NOT_LOADED) { return } val messages = missingMessageUtil.missingMessages(messageId) if (messages.size > 5) { onGroupedMessages(messages) } else { for (message in messages) { onMessage(message) } } } private fun onGroupedMessages(messages: List) { var highestPriority = 0L for (message in messages) { if (lastReceivedMessage.get() < message.id) { lastReceivedMessage.set(message.id) highestPriority = highestPriority.coerceAtLeast(message.priority) } broadcast(message) } val size = messages.size showNotification( NotificationSupport.ID.GROUPED, getString(R.string.missed_messages), getString(R.string.grouped_message, size), highestPriority, null ) } private fun onMessage(message: Message) { if (lastReceivedMessage.get() < message.id) { lastReceivedMessage.set(message.id) } broadcast(message) showNotification( message.id, message.title, message.message, message.priority, message.extras, message.appid ) } private fun broadcast(message: Message) { val intent = Intent() intent.action = NEW_MESSAGE_BROADCAST intent.putExtra("message", Utils.JSON.toJson(message)) sendBroadcast(intent) } override fun onBind(intent: Intent): IBinder? = null private fun showForegroundNotification(title: String, message: String? = null) { val notificationIntent = Intent(this, MessagesActivity::class.java) val pendingIntent = PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE ) val notificationBuilder = NotificationCompat.Builder(this, NotificationSupport.Channel.FOREGROUND) notificationBuilder.setSmallIcon(R.drawable.ic_gotify) notificationBuilder.setOngoing(true) notificationBuilder.priority = NotificationCompat.PRIORITY_MIN notificationBuilder.setShowWhen(false) notificationBuilder.setWhen(0) notificationBuilder.setContentTitle(title) if (message != null) { notificationBuilder.setContentText(message) notificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) } notificationBuilder.setContentIntent(pendingIntent) notificationBuilder.color = ContextCompat.getColor(applicationContext, R.color.colorPrimary) startForeground(NotificationSupport.ID.FOREGROUND, notificationBuilder.build()) } private fun showNotification( id: Int, title: String, message: String, priority: Long, extras: Map? ) { showNotification(id.toLong(), title, message, priority, extras, -1L) } private fun showNotification( id: Long, title: String, message: String, priority: Long, extras: Map?, appId: Long ) { var intent: Intent val intentUrl = Extras.getNestedValue( String::class.java, extras, "android::action", "onReceive", "intentUrl" ) if (intentUrl != null) { intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(intentUrl) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK startActivity(intent) } val url = Extras.getNestedValue( String::class.java, extras, "client::notification", "click", "url" ) if (url != null) { intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(url) } else { intent = Intent(this, MessagesActivity::class.java) } val contentIntent = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val b = NotificationCompat.Builder( this, NotificationSupport.convertPriorityToChannel(priority) ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { showNotificationGroup(priority) } b.setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL) .setWhen(System.currentTimeMillis()) .setSmallIcon(R.drawable.ic_gotify) .setLargeIcon(picassoHandler.getIcon(appId)) .setTicker("${getString(R.string.app_name)} - $title") .setGroup(NotificationSupport.Group.MESSAGES) .setContentTitle(title) .setDefaults(Notification.DEFAULT_LIGHTS or Notification.DEFAULT_SOUND) .setLights(Color.CYAN, 1000, 5000) .setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary)) .setContentIntent(contentIntent) var formattedMessage = message as CharSequence lateinit var newMessage: String if (Extras.useMarkdown(extras)) { formattedMessage = markwon.toMarkdown(message) newMessage = formattedMessage.toString() } b.setContentText(newMessage) b.setStyle(NotificationCompat.BigTextStyle().bigText(formattedMessage)) val notificationImageUrl = Extras.getNestedValue( String::class.java, extras, "client::notification", "bigImageUrl" ) if (notificationImageUrl != null) { try { b.setStyle( NotificationCompat.BigPictureStyle() .bigPicture(picassoHandler.getImageFromUrl(notificationImageUrl)) ) } catch (e: Exception) { Log.e("Error loading bigImageUrl", e) } } val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(Utils.longToInt(id), b.build()) } @RequiresApi(Build.VERSION_CODES.N) fun showNotificationGroup(priority: Long) { val intent = Intent(this, MessagesActivity::class.java) val contentIntent = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val builder = NotificationCompat.Builder( this, NotificationSupport.convertPriorityToChannel(priority) ) builder.setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL) .setWhen(System.currentTimeMillis()) .setSmallIcon(R.drawable.ic_gotify) .setTicker(getString(R.string.app_name)) .setGroup(NotificationSupport.Group.MESSAGES) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setContentTitle(getString(R.string.grouped_notification_text)) .setGroupSummary(true) .setContentText(getString(R.string.grouped_notification_text)) .setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary)) .setContentIntent(contentIntent) val notificationManager = this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(-5, builder.build()) } }