Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions app/src/main/java/me/capcom/smsgateway/helpers/SSEManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ import java.util.concurrent.atomic.AtomicBoolean

class SSEManager(
private val url: String,
private val authToken: String
private val tokenProvider: () -> String?,
) {
private val client = OkHttpClient.Builder()
.readTimeout(1, TimeUnit.HOURS)
.build()
private val scope = CoroutineScope(Dispatchers.IO + Job())

@Volatile
private var eventSource: EventSource? = null
private var reconnectAttempts = 0
private val isDisconnecting = AtomicBoolean(false)
Expand All @@ -36,18 +37,21 @@ class SSEManager(

fun connect() {
isDisconnecting.set(false)
val token = tokenProvider() ?: return disconnect()

scope.launch {
try {
val request = Request.Builder()
.url(url)
.apply {
header("Authorization", "Bearer $authToken")
header("Authorization", "Bearer $token")
}
.build()

eventSource = EventSources.createFactory(client)
val source = EventSources.createFactory(client)
.newEventSource(request, object : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) {
if (isDisconnecting.get()) return
Log.d(TAG, "SSE connected")
reconnectAttempts = 0
onConnected?.invoke()
Expand All @@ -59,11 +63,13 @@ class SSEManager(
type: String?,
data: String
) {
if (isDisconnecting.get()) return
Log.d(TAG, "Event received: $type - $data")
onEvent?.invoke(type, data)
}

override fun onClosed(eventSource: EventSource) {
if (isDisconnecting.get()) return
Log.d(TAG, "SSE connection closed")
onClosed?.invoke()
scheduleReconnect()
Expand All @@ -74,11 +80,23 @@ class SSEManager(
t: Throwable?,
response: Response?
) {
if (isDisconnecting.get()) return
Log.e(TAG, "SSE error", t)
onError?.invoke(t)
scheduleReconnect()
}
})

// This body has no suspension point, so disconnect()'s
// cancelChildren() cannot stop it once started: it may store the
// stream after teardown already cleared the field. Store first,
// then verify — paired with disconnect() setting the flag before
// it grabs the field, one of the two always cancels the loser.
eventSource = source
if (isDisconnecting.get()) {
eventSource = null
source.cancel()
}
} catch (e: Exception) {
Log.e(TAG, "Connection failed", e)
scheduleReconnect()
Expand All @@ -88,12 +106,14 @@ class SSEManager(

fun disconnect() {
isDisconnecting.set(true)
scope.launch {
eventSource?.cancel()
eventSource = null
reconnectAttempts = 0
}
// Kill delayed reconnects and in-flight launches FIRST: this teardown must
// never be scheduled as a child, because cancelChildren() would cancel it
// before it runs and leave the authenticated stream alive.
scope.coroutineContext.cancelChildren()
val source = eventSource
eventSource = null
reconnectAttempts = 0
source?.cancel()
}

private fun scheduleReconnect() {
Expand All @@ -111,8 +131,10 @@ class SSEManager(
scope.launch {
eventSource?.cancel()
eventSource = null
if (isDisconnecting.get()) return@launch
Log.d(TAG, "Reconnecting in ${delay}ms (attempt $reconnectAttempts)")
kotlinx.coroutines.delay(delay)
if (isDisconnecting.get()) return@launch
connect()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,13 @@ class EventsReceiver : EventsReceiver() {
Log.d("EventsReceiver", "Event: $it")

if (!settings.enabled) return@collect
if (settings.fcmToken != null) return@collect

SSEForegroundService.start(get())
if (settings.fcmToken != null) {
// FCM is the active transport; tear down the SSE fallback so
// the same cloud command is not delivered twice.
SSEForegroundService.stop(get())
} else {
SSEForegroundService.start(get())
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Expand Down Expand Up @@ -179,7 +183,7 @@ class EventsReceiver : EventsReceiver() {

if (!settings.enabled) return@collect

RegistrationWorker.start(get(), settings.fcmToken, true)
RegistrationWorker.start(get(), settings.fcmToken)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ class SSEForegroundService : Service() {
private val sseManager by lazy {
SSEManager(
"${settings.serverUrl}/events",
requireNotNull(
settings.registrationInfo?.token
) { "Authentication token is required for SSE connection" }
// Resolve the bearer token fresh on every connect() so account
// replacement picks up the new credentials without recreating the
// service. Returns null while unregistered, in which case connect()
// tears down gracefully instead of throwing.
tokenProvider = { settings.registrationInfo?.token }
)
.apply {
onConnected = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import androidx.work.WorkRequest
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import me.capcom.smsgateway.App
import me.capcom.smsgateway.modules.gateway.GatewayService
import me.capcom.smsgateway.modules.logs.LogsService
import me.capcom.smsgateway.modules.logs.db.LogEntry
import org.koin.core.component.KoinComponent
Expand All @@ -27,17 +26,12 @@ class RegistrationWorker(

override suspend fun doWork(): Result {
try {
val token = inputData.getString(DATA_TOKEN)
val isUpdate = inputData.getBoolean(DATA_IS_UPDATE, false)
val fcmToken = inputData.getString(DATA_TOKEN)

when (isUpdate) {
true -> App.instance.gatewayService.updateDevice(applicationContext, token ?: return Result.success())
false -> App.instance.gatewayService.registerDevice(
applicationContext,
token,
GatewayService.RegistrationMode.Anonymous
)
}
App.instance.gatewayService.updateDevice(
applicationContext,
fcmToken,
)

return Result.success()
} catch (e: Exception) {
Expand All @@ -58,9 +52,8 @@ class RegistrationWorker(
private const val NAME = "RegistrationWorker"

private const val DATA_TOKEN = "token"
private const val DATA_IS_UPDATE = "isUpdate"

fun start(context: Context, token: String?, isUpdate: Boolean) {
fun start(context: Context, token: String?) {
val work = OneTimeWorkRequestBuilder<RegistrationWorker>()
.setConstraints(
Constraints.Builder()
Expand All @@ -75,7 +68,6 @@ class RegistrationWorker(
.setInputData(
workDataOf(
DATA_TOKEN to token,
DATA_IS_UPDATE to isUpdate,
)
)
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import me.capcom.smsgateway.modules.events.EventBus
import me.capcom.smsgateway.modules.events.EventsReceiver
import me.capcom.smsgateway.modules.gateway.GatewaySettings
import me.capcom.smsgateway.modules.receiver.events.MessagesExportRequestedEvent
import org.koin.core.component.get
import org.koin.core.component.inject

class EventsReceiver : EventsReceiver() {
private val service by inject<ReceiverService>()
private val gatewaySettings by inject<GatewaySettings>()

override suspend fun collect(eventBus: EventBus) {
coroutineScope {
Expand All @@ -19,6 +21,14 @@ class EventsReceiver : EventsReceiver() {
eventBus.collect<MessagesExportRequestedEvent> { event ->
Log.d("EventsReceiver", "Event: $event")

// This receiver is not stopped by GatewayService.stop(), so it keeps
// collecting after unregister. Drop the command unless the device is
// still registered with the cloud server.
if (gatewaySettings.registrationInfo == null) {
Log.d("EventsReceiver", "Export request ignored: device is not registered")
return@collect
}

service.export(
context = get(),
period = event.since to event.until,
Expand Down
20 changes: 17 additions & 3 deletions app/src/main/java/me/capcom/smsgateway/services/PushService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,29 @@ import org.koin.core.component.inject
class PushService : FirebaseMessagingService(), KoinComponent {
private val logsService by inject<LogsService>()
private val eventsRouter by inject<EventsRouter>()
private val gatewaySettings by inject<GatewaySettings>()

override fun onNewToken(token: String) {
RegistrationWorker.start(this@PushService, token, true)
RegistrationWorker.start(this@PushService, token)
}

override fun onMessageReceived(message: RemoteMessage) {
try {
Log.d(this.javaClass.name, message.data.toString())

// The server keeps the push token after unregister, so it may still deliver
// commands for the previous registration. Reject them here, at the entry
// point, rather than letting each consumer re-check the registration.
if (gatewaySettings.registrationInfo == null) {
Log.d(this.javaClass.name, "Push message ignored: device is not registered")
logsService.insert(
priority = LogEntry.Priority.INFO,
module = this.javaClass.simpleName,
message = "Push message ignored: device is not registered"
)
return
}

val event = message.data["event"]?.let { ExternalEventType.valueOf(it) }
?: ExternalEventType.MessageEnqueued
val data = message.data["data"]
Expand Down Expand Up @@ -62,7 +76,7 @@ class PushService : FirebaseMessagingService(), KoinComponent {
module = PushService::class.java.simpleName,
message = "FCM registration skipped: SSE_ONLY channel configured"
)
RegistrationWorker.start(context, null, false)
RegistrationWorker.start(context, null)
return
}

Expand Down Expand Up @@ -96,7 +110,7 @@ class PushService : FirebaseMessagingService(), KoinComponent {
)

// Log and toast
RegistrationWorker.start(context, token, false)
RegistrationWorker.start(context, token)
}
}
}
Expand Down
Loading
Loading