WIP: Websockets
This commit is contained in:
parent
97f1f5eb90
commit
26b408c828
5 changed files with 233 additions and 11 deletions
|
@ -173,7 +173,7 @@ class ApiService {
|
||||||
/* This annotation ensures that proguard still works in production builds,
|
/* This annotation ensures that proguard still works in production builds,
|
||||||
* see https://stackoverflow.com/a/62753300/1440785 */
|
* see https://stackoverflow.com/a/62753300/1440785 */
|
||||||
@Keep
|
@Keep
|
||||||
private data class Message(
|
data class Message(
|
||||||
val id: String,
|
val id: String,
|
||||||
val time: Long,
|
val time: Long,
|
||||||
val event: String,
|
val event: String,
|
||||||
|
@ -187,7 +187,7 @@ class ApiService {
|
||||||
)
|
)
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
private data class MessageAttachment(
|
data class MessageAttachment(
|
||||||
val name: String,
|
val name: String,
|
||||||
val type: String?,
|
val type: String?,
|
||||||
val size: Long?,
|
val size: Long?,
|
||||||
|
|
|
@ -11,7 +11,8 @@ import kotlinx.coroutines.*
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class SubscriberConnection(
|
class JsonConnection(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
private val repository: Repository,
|
private val repository: Repository,
|
||||||
private val api: ApiService,
|
private val api: ApiService,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
|
@ -20,7 +21,7 @@ class SubscriberConnection(
|
||||||
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
|
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
|
||||||
private val notificationListener: (Subscription, Notification) -> Unit,
|
private val notificationListener: (Subscription, Notification) -> Unit,
|
||||||
private val serviceActive: () -> Boolean
|
private val serviceActive: () -> Boolean
|
||||||
) {
|
) : Connection {
|
||||||
private val subscriptionIds = topicsToSubscriptionIds.values
|
private val subscriptionIds = topicsToSubscriptionIds.values
|
||||||
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
|
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
|
||||||
private val url = topicUrl(baseUrl, topicsStr)
|
private val url = topicUrl(baseUrl, topicsStr)
|
||||||
|
@ -29,7 +30,7 @@ class SubscriberConnection(
|
||||||
private lateinit var call: Call
|
private lateinit var call: Call
|
||||||
private lateinit var job: Job
|
private lateinit var job: Job
|
||||||
|
|
||||||
fun start(scope: CoroutineScope) {
|
override fun start() {
|
||||||
job = scope.launch(Dispatchers.IO) {
|
job = scope.launch(Dispatchers.IO) {
|
||||||
Log.d(TAG, "[$url] Starting connection for subscriptions: $topicsToSubscriptionIds")
|
Log.d(TAG, "[$url] Starting connection for subscriptions: $topicsToSubscriptionIds")
|
||||||
|
|
||||||
|
@ -81,17 +82,17 @@ class SubscriberConnection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun since(): Long {
|
override fun since(): Long {
|
||||||
return since
|
return since
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancel() {
|
override fun cancel() {
|
||||||
Log.d(TAG, "[$url] Cancelling connection")
|
Log.d(TAG, "[$url] Cancelling connection")
|
||||||
if (this::job.isInitialized) job?.cancel()
|
if (this::job.isInitialized) job?.cancel()
|
||||||
if (this::call.isInitialized) call?.cancel()
|
if (this::call.isInitialized) call?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun matches(otherSubscriptionIds: Collection<Long>): Boolean {
|
override fun matches(otherSubscriptionIds: Collection<Long>): Boolean {
|
||||||
return subscriptionIds.toSet() == otherSubscriptionIds.toSet()
|
return subscriptionIds.toSet() == otherSubscriptionIds.toSet()
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,12 +54,20 @@ import java.util.concurrent.ConcurrentHashMap
|
||||||
* - https://github.com/robertohuertasm/endless-service/blob/master/app/src/main/java/com/robertohuertas/endless/EndlessService.kt
|
* - https://github.com/robertohuertasm/endless-service/blob/master/app/src/main/java/com/robertohuertas/endless/EndlessService.kt
|
||||||
* - https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd
|
* - https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface Connection {
|
||||||
|
fun start()
|
||||||
|
fun cancel()
|
||||||
|
fun since(): Long
|
||||||
|
fun matches(otherSubscriptionIds: Collection<Long>): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
class SubscriberService : Service() {
|
class SubscriberService : Service() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var isServiceStarted = false
|
private var isServiceStarted = false
|
||||||
private val repository by lazy { (application as Application).repository }
|
private val repository by lazy { (application as Application).repository }
|
||||||
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
|
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
|
||||||
private val connections = ConcurrentHashMap<String, SubscriberConnection>() // Base URL -> Connection
|
private val connections = ConcurrentHashMap<String, Connection>() // Base URL -> Connection
|
||||||
private val api = ApiService()
|
private val api = ApiService()
|
||||||
private var notificationManager: NotificationManager? = null
|
private var notificationManager: NotificationManager? = null
|
||||||
private var serviceNotification: Notification? = null
|
private var serviceNotification: Notification? = null
|
||||||
|
@ -174,9 +182,14 @@ class SubscriberService : Service() {
|
||||||
}
|
}
|
||||||
if (!connections.containsKey(baseUrl)) {
|
if (!connections.containsKey(baseUrl)) {
|
||||||
val serviceActive = { -> isServiceStarted }
|
val serviceActive = { -> isServiceStarted }
|
||||||
val connection = SubscriberConnection(repository, api, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, serviceActive)
|
val connection = if (true) {
|
||||||
|
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
|
||||||
|
WsConnection(repository, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, alarmManager)
|
||||||
|
} else {
|
||||||
|
JsonConnection(this, repository, api, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, serviceActive)
|
||||||
|
}
|
||||||
connections[baseUrl] = connection
|
connections[baseUrl] = connection
|
||||||
connection.start(this)
|
connection.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
207
app/src/main/java/io/heckel/ntfy/service/WsConnection.kt
Normal file
207
app/src/main/java/io/heckel/ntfy/service/WsConnection.kt
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
package io.heckel.ntfy.service
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import io.heckel.ntfy.data.*
|
||||||
|
import io.heckel.ntfy.msg.ApiService
|
||||||
|
import io.heckel.ntfy.util.joinTags
|
||||||
|
import io.heckel.ntfy.util.toPriority
|
||||||
|
import io.heckel.ntfy.util.topicUrlWs
|
||||||
|
import okhttp3.*
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class WsConnection(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val sinceTime: Long,
|
||||||
|
private val topicsToSubscriptionIds: Map<String, Long>, // Topic -> Subscription ID
|
||||||
|
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
|
||||||
|
private val notificationListener: (Subscription, Notification) -> Unit,
|
||||||
|
private val alarmManager: AlarmManager
|
||||||
|
) : Connection {
|
||||||
|
private val client: OkHttpClient
|
||||||
|
//private val reconnectHandler = Handler()
|
||||||
|
//private val reconnectCallback = Runnable { start() }
|
||||||
|
private var errorCount = 0
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
private var state: State? = null
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
private val subscriptionIds = topicsToSubscriptionIds.values
|
||||||
|
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
|
||||||
|
private val sinceVal = if (sinceTime == 0L) "all" else sinceTime.toString()
|
||||||
|
private val wsurl = topicUrlWs(baseUrl, topicsStr, sinceVal)
|
||||||
|
|
||||||
|
init {
|
||||||
|
val builder = OkHttpClient.Builder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
//.pingInterval(1, TimeUnit.MINUTES)
|
||||||
|
.pingInterval(30, TimeUnit.SECONDS)
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
client = builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(): Request {
|
||||||
|
return Request.Builder()
|
||||||
|
.url(wsurl)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun start() {
|
||||||
|
if (state == State.Connecting || state == State.Connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
state = State.Connecting
|
||||||
|
val nextId = ID.incrementAndGet()
|
||||||
|
Log.d(TAG, "WebSocket($nextId): starting...")
|
||||||
|
webSocket = client.newWebSocket(request(), Listener(nextId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun cancel() {
|
||||||
|
if (webSocket != null) {
|
||||||
|
Log.d(TAG, "WebSocket(" + ID.get() + "): closing existing connection.")
|
||||||
|
state = State.Disconnected
|
||||||
|
webSocket!!.close(1000, "")
|
||||||
|
webSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun since(): Long {
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun matches(otherSubscriptionIds: Collection<Long>): Boolean {
|
||||||
|
return subscriptionIds.toSet() == otherSubscriptionIds.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun scheduleReconnect(seconds: Long) {
|
||||||
|
if (state == State.Connecting || state == State.Connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = State.Scheduled
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
Log.d(TAG,
|
||||||
|
"WebSocket: scheduling a restart in "
|
||||||
|
+ seconds
|
||||||
|
+ " second(s) (via alarm manager)"
|
||||||
|
)
|
||||||
|
val future = Calendar.getInstance()
|
||||||
|
future.add(Calendar.SECOND, seconds.toInt())
|
||||||
|
alarmManager.setExact(
|
||||||
|
AlarmManager.RTC_WAKEUP,
|
||||||
|
future.timeInMillis,
|
||||||
|
"reconnect-tag", { start() },
|
||||||
|
null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "WebSocket: scheduling a restart in $seconds second(s)")
|
||||||
|
//reconnectHandler.removeCallbacks(reconnectCallback)
|
||||||
|
//reconnectHandler.postDelayed(reconnectCallback, TimeUnit.SECONDS.toMillis(seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Listener(private val id: Long) : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
syncExec {
|
||||||
|
state = State.Connected
|
||||||
|
Log.d(TAG, "WebSocket(" + id + "): opened")
|
||||||
|
if (errorCount > 0) {
|
||||||
|
Log.d(TAG, "reconnected")
|
||||||
|
errorCount = 0
|
||||||
|
}
|
||||||
|
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
syncExec {
|
||||||
|
Log.d(TAG, "WebSocket(" + id + "): received message " + text)
|
||||||
|
val message = gson.fromJson(text, ApiService.Message::class.java)
|
||||||
|
if (message.event == ApiService.EVENT_MESSAGE) {
|
||||||
|
val topic = message.topic
|
||||||
|
val attachment = if (message.attachment?.url != null) {
|
||||||
|
Attachment(
|
||||||
|
name = message.attachment.name,
|
||||||
|
type = message.attachment.type,
|
||||||
|
size = message.attachment.size,
|
||||||
|
expires = message.attachment.expires,
|
||||||
|
url = message.attachment.url,
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
val notification = Notification(
|
||||||
|
id = message.id,
|
||||||
|
subscriptionId = 0, // TO BE SET downstream
|
||||||
|
timestamp = message.time,
|
||||||
|
title = message.title ?: "",
|
||||||
|
message = message.message,
|
||||||
|
priority = toPriority(message.priority),
|
||||||
|
tags = joinTags(message.tags),
|
||||||
|
click = message.click ?: "",
|
||||||
|
attachment = attachment,
|
||||||
|
notificationId = Random.nextInt(),
|
||||||
|
deleted = false
|
||||||
|
)
|
||||||
|
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@syncExec
|
||||||
|
val subscription = repository.getSubscription(subscriptionId) ?: return@syncExec
|
||||||
|
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
|
||||||
|
notificationListener(subscription, notificationWithSubscriptionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
syncExec {
|
||||||
|
if (state == State.Connected) {
|
||||||
|
Log.w(TAG, "WebSocket(" + id + "): closed")
|
||||||
|
}
|
||||||
|
state = State.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
val code = if (response != null) "StatusCode: " + response.code else ""
|
||||||
|
val message = response?.message ?: ""
|
||||||
|
Log.e(TAG, "WebSocket($id): failure $code Message: $message", t)
|
||||||
|
syncExec {
|
||||||
|
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
|
||||||
|
state = State.Disconnected
|
||||||
|
if ((response != null) && (response.code >= 400) && (response.code <= 499)) {
|
||||||
|
Log.d(TAG, "bad request")
|
||||||
|
cancel()
|
||||||
|
return@syncExec
|
||||||
|
}
|
||||||
|
errorCount++
|
||||||
|
val minutes: Int = Math.min(errorCount * 2 - 1, 20)
|
||||||
|
//scheduleReconnect(TimeUnit.MINUTES.toSeconds(minutes.toLong()))
|
||||||
|
scheduleReconnect(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncExec(runnable: Runnable) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (ID.get() == id) {
|
||||||
|
runnable.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class State {
|
||||||
|
Scheduled, Connecting, Connected, Disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NtfyWsConnection"
|
||||||
|
private val ID = AtomicLong(0)
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import kotlin.math.abs
|
||||||
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
|
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
|
||||||
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
|
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
|
||||||
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
|
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
|
||||||
|
fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"
|
||||||
fun topicUrlJsonPoll(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=$since"
|
fun topicUrlJsonPoll(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=$since"
|
||||||
fun topicShortUrl(baseUrl: String, topic: String) =
|
fun topicShortUrl(baseUrl: String, topic: String) =
|
||||||
topicUrl(baseUrl, topic)
|
topicUrl(baseUrl, topic)
|
||||||
|
|
Loading…
Reference in a new issue