778 lines
34 KiB
Kotlin
778 lines
34 KiB
Kotlin
package io.annaclemens.xivchat
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.app.Activity
|
|
import android.app.Notification
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.app.Service
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.IntentFilter
|
|
import android.graphics.Bitmap
|
|
import android.graphics.BitmapFactory
|
|
import android.net.Uri
|
|
import android.os.Binder
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.IBinder
|
|
import android.os.Looper
|
|
import android.os.Process
|
|
import android.os.ResultReceiver
|
|
import androidx.activity.viewModels
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.core.app.NotificationManagerCompat
|
|
import androidx.core.graphics.drawable.IconCompat
|
|
import androidx.lifecycle.MutableLiveData
|
|
import androidx.navigation.findNavController
|
|
import com.goterl.lazycode.lazysodium.utils.Key
|
|
import io.annaclemens.xivchat.model.ChatType
|
|
import io.annaclemens.xivchat.model.ServerOperation
|
|
import io.annaclemens.xivchat.model.message.Availability
|
|
import io.annaclemens.xivchat.model.message.ClientBacklog
|
|
import io.annaclemens.xivchat.model.message.ClientCatchUp
|
|
import io.annaclemens.xivchat.model.message.ClientChannel
|
|
import io.annaclemens.xivchat.model.message.ClientMessage
|
|
import io.annaclemens.xivchat.model.message.ClientPlayerList
|
|
import io.annaclemens.xivchat.model.message.ClientPreference
|
|
import io.annaclemens.xivchat.model.message.ClientPreferences
|
|
import io.annaclemens.xivchat.model.message.ClientShutdown
|
|
import io.annaclemens.xivchat.model.message.InputChannel
|
|
import io.annaclemens.xivchat.model.message.Ping
|
|
import io.annaclemens.xivchat.model.message.PlayerData
|
|
import io.annaclemens.xivchat.model.message.PlayerListType
|
|
import io.annaclemens.xivchat.model.message.RelayRegister
|
|
import io.annaclemens.xivchat.model.message.ServerBacklog
|
|
import io.annaclemens.xivchat.model.message.ServerMessage
|
|
import io.annaclemens.xivchat.model.message.ServerPlayerList
|
|
import io.annaclemens.xivchat.model.message.SuccessMessage
|
|
import io.annaclemens.xivchat.ui.friends.FriendListViewModel
|
|
import io.annaclemens.xivchat.ui.messages.MessagesViewModel
|
|
import io.annaclemens.xivchat.ui.servers.ServersViewModel
|
|
import io.annaclemens.xivchat.ui.targeting.TargetingListViewModel
|
|
import io.annaclemens.xivchat.ui.targeting.TargetingPlayer
|
|
import io.annaclemens.xivchat.util.AlertDialogFragment
|
|
import io.annaclemens.xivchat.util.AppKeyExchange
|
|
import io.annaclemens.xivchat.util.CharacterSearchResults
|
|
import io.annaclemens.xivchat.util.SecretMessage
|
|
import io.annaclemens.xivchat.util.TrustDialog
|
|
import io.annaclemens.xivchat.util.hexStringToByteArray
|
|
import io.annaclemens.xivchat.util.toHexString
|
|
import io.ktor.client.*
|
|
import io.ktor.client.engine.android.*
|
|
import io.ktor.client.request.*
|
|
import io.ktor.network.selector.*
|
|
import io.ktor.network.sockets.*
|
|
import io.ktor.util.*
|
|
import io.ktor.utils.io.*
|
|
import io.sentry.Breadcrumb
|
|
import io.sentry.Sentry
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
import kotlinx.coroutines.InternalCoroutinesApi
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.ObsoleteCoroutinesApi
|
|
import kotlinx.coroutines.Runnable
|
|
import kotlinx.coroutines.channels.Channel
|
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
|
import kotlinx.coroutines.channels.ReceiveChannel
|
|
import kotlinx.coroutines.channels.produce
|
|
import kotlinx.coroutines.channels.sendBlocking
|
|
import kotlinx.coroutines.channels.ticker
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withTimeout
|
|
import kotlinx.serialization.decodeFromString
|
|
import kotlinx.serialization.json.Json
|
|
import java.nio.ByteBuffer
|
|
import java.util.*
|
|
import kotlin.collections.HashSet
|
|
|
|
class ConnectionService : Service() {
|
|
companion object {
|
|
private const val NOTIF_CHANNEL_ID = "XIVChat"
|
|
private const val RELAY_HOST = "relay.xiv.chat"
|
|
private const val RELAY_PORT = 14777
|
|
private val RELAY_PUBLIC_KEY = this.byteArrayOfInts(
|
|
194, 81, 22, 123, 80, 172, 145, 167, 212, 251, 198, 173, 55, 160, 11, 18, 247, 11, 210, 6, 98, 43, 102, 73, 54, 255, 214, 233, 144, 193, 98, 47,
|
|
)
|
|
|
|
private fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
|
|
}
|
|
|
|
private lateinit var notif: NotificationManagerCompat
|
|
private var runner: ConnectionRunner? = null
|
|
private val binder: IBinder = LocalBinder(this)
|
|
private val handler = Handler(Looper.getMainLooper())
|
|
private val receiver = ConnectionBroadcastReceiver(this)
|
|
lateinit var app: MainActivity
|
|
val messages = Channel<ClientMessage>(Channel.UNLIMITED)
|
|
var bound = false
|
|
val connecting: MutableLiveData<Boolean> = MutableLiveData(false)
|
|
|
|
class LocalBinder(private val _service: ConnectionService) : Binder() {
|
|
fun getService(): ConnectionService {
|
|
return this._service
|
|
}
|
|
}
|
|
|
|
class ConnectionBroadcastReceiver(private val service: ConnectionService) : BroadcastReceiver() {
|
|
companion object {
|
|
const val DISCONNECT = "io.annaclemens.xivchat.DISCONNECT"
|
|
}
|
|
|
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
if (intent?.action != Companion.DISCONNECT) {
|
|
return
|
|
}
|
|
|
|
this.service.disconnect()
|
|
}
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
this.notif = NotificationManagerCompat.from(this.applicationContext)
|
|
this.registerReceiver(this.receiver, IntentFilter(ConnectionBroadcastReceiver.DISCONNECT))
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
this.runner?.job?.cancel()
|
|
this.stopForeground(true)
|
|
this.unregisterReceiver(this.receiver)
|
|
|
|
if (!this.bound) {
|
|
return
|
|
}
|
|
|
|
val serversVm: ServersViewModel by this.app.viewModels()
|
|
serversVm.connectionService.value = null
|
|
}
|
|
|
|
private fun makeNotification(): Notification {
|
|
val title = this.getText(R.string.notif_title)
|
|
val content = this.getText(R.string.notif_message)
|
|
val openIntent = Intent(this, MainActivity::class.java)
|
|
.putExtra(MainActivity.EXTRA_DESTINATION, R.id.nav_messages_tabs)
|
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
|
val intent = PendingIntent.getActivity(this, 0, openIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
|
|
val disconnectIntent = Intent(ConnectionBroadcastReceiver.DISCONNECT)
|
|
val disconnectPending = PendingIntent.getBroadcast(this, 0, disconnectIntent, 0)
|
|
val disconnectIcon = IconCompat.createWithResource(this, R.drawable.notif_disconnect_icon)
|
|
val disconnectAction = NotificationCompat.Action.Builder(disconnectIcon, this.getString(R.string.notif_disconnect), disconnectPending)
|
|
.build()
|
|
|
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
|
this.notif.createNotificationChannel(NotificationChannel(Companion.NOTIF_CHANNEL_ID, "XIVChat", NotificationManager.IMPORTANCE_DEFAULT))
|
|
}
|
|
return NotificationCompat.Builder(this, Companion.NOTIF_CHANNEL_ID)
|
|
.setSmallIcon(R.drawable.notif_icon)
|
|
.setTicker(title)
|
|
.setWhen(System.currentTimeMillis())
|
|
.setContentTitle(title)
|
|
.setContentText(content)
|
|
.setContentIntent(intent)
|
|
.addAction(disconnectAction)
|
|
.build()
|
|
}
|
|
|
|
override fun onBind(intent: Intent?): IBinder {
|
|
return this.binder
|
|
}
|
|
|
|
fun connect(host: String, port: Int) {
|
|
this.runner = ConnectionRunner(this, host, port)
|
|
Thread(this.runner).start()
|
|
}
|
|
|
|
fun connectRelay(auth: String, target: String) {
|
|
this.runner = ConnectionRunner(this, auth, target)
|
|
Thread(this.runner).start()
|
|
}
|
|
|
|
fun disconnect() {
|
|
this.runner?.disconnectChannel?.sendBlocking(Unit)
|
|
this.app.stopConnectionService()
|
|
}
|
|
|
|
fun requestFriendList() {
|
|
this.runner?.friendListChannel?.sendBlocking(Unit)
|
|
}
|
|
|
|
fun requestChannelChange(channel: InputChannel) {
|
|
this.runner?.channelChannel?.sendBlocking(channel)
|
|
}
|
|
|
|
class ConnectionRunner(
|
|
private val service: ConnectionService,
|
|
private val host: String,
|
|
private val port: Int,
|
|
private val relay: Boolean = false,
|
|
private val relayAuth: String? = null,
|
|
private val relayTarget: String? = null,
|
|
) : Runnable {
|
|
constructor(service: ConnectionService, auth: String, target: String) : this(
|
|
service,
|
|
ConnectionService.RELAY_HOST,
|
|
ConnectionService.RELAY_PORT,
|
|
true,
|
|
auth,
|
|
target,
|
|
)
|
|
|
|
companion object {
|
|
const val DIALOG_COULD_NOT_CONNECT = "could_not_connect"
|
|
const val DIALOG_DISCONNECTED = "disconnected"
|
|
}
|
|
|
|
var job: Job? = null
|
|
private var lastPlayerPortrait: String? = null
|
|
val disconnectChannel = Channel<Unit>(1)
|
|
val friendListChannel = Channel<Unit>(1)
|
|
val channelChannel = Channel<InputChannel>(1)
|
|
|
|
private suspend fun doRelayNegotiation(rxStream: ByteReadChannel, txStream: ByteWriteChannel): Boolean {
|
|
val info = AppKeyExchange.clientHandshake(this.service.app.sodium, this.service.app.keyPair, rxStream, txStream)
|
|
if (!info.remotePublicKey.contentEquals(ConnectionService.RELAY_PUBLIC_KEY)) {
|
|
return false
|
|
}
|
|
|
|
val tx = Key.fromBytes(info.keys.tx)
|
|
val rx = Key.fromBytes(info.keys.rx)
|
|
|
|
// create registration message
|
|
val target = this.relayTarget!!.hexStringToByteArray()
|
|
if (target == null) {
|
|
val dialog = AlertDialogFragment.newInstance(Companion.DIALOG_COULD_NOT_CONNECT, Bundle().apply {
|
|
this.putString("message", "Invalid public key")
|
|
})
|
|
this.service.handler.post { this.service.app.queueDialog(dialog, "connection_error") }
|
|
return false
|
|
}
|
|
val reg = RelayRegister(this.relayAuth!!, target)
|
|
|
|
// send registration message
|
|
val regBytes = reg.encode()
|
|
SecretMessage.writeSecretMessage(this.service.app.sodium, txStream, tx, regBytes)
|
|
|
|
// get response
|
|
val authRespBytes = SecretMessage.readSecretMessage(this.service.app.sodium, rxStream, rx)
|
|
val authResp = SuccessMessage.read(authRespBytes)
|
|
if (!authResp.success) {
|
|
val dialog = AlertDialogFragment.newInstance(Companion.DIALOG_COULD_NOT_CONNECT, Bundle().apply {
|
|
this.putString("message", authResp.info)
|
|
})
|
|
this.service.handler.post { this.service.app.queueDialog(dialog, "connection_error") }
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
@SuppressLint("SetTextI18n")
|
|
private suspend fun asyncRun() {
|
|
val runner = this
|
|
|
|
// let the ui know we're actively connecting
|
|
this.service.handler.post { this.service.connecting.value = true }
|
|
|
|
// connect with a timeout
|
|
val conn: Socket = this.attemptConnection(runner) ?: return
|
|
|
|
val rxStream = conn.openReadChannel()
|
|
val txStream = conn.openWriteChannel(true)
|
|
|
|
// send the magic bytes
|
|
txStream.writeFully(byteArrayOf(14, 20, 67))
|
|
|
|
// negotiate with relay if necessary
|
|
if (this.relay && !this.doRelayNegotiation(rxStream, txStream)) {
|
|
return
|
|
}
|
|
|
|
// do the handshake with the server
|
|
val info = AppKeyExchange.clientHandshake(this.service.app.sodium, this.service.app.keyPair, rxStream, txStream)
|
|
val rx = Key.fromBytes(info.keys.rx)
|
|
val tx = Key.fromBytes(info.keys.tx)
|
|
|
|
// check if key is trusted for this host
|
|
if (!this.doTrustProcess(conn, info)) {
|
|
Sentry.addBreadcrumb(Breadcrumb("Failed trust process").apply {
|
|
this.category = "connection"
|
|
})
|
|
return
|
|
}
|
|
|
|
this.service.handler.post {
|
|
val vm: ServersViewModel by this.service.app.viewModels()
|
|
vm.connected.value = true
|
|
}
|
|
|
|
this.service.handler.post {
|
|
val vm: MessagesViewModel by this@ConnectionRunner.service.app.viewModels()
|
|
val text = this@ConnectionRunner.service.app.getString(R.string.messages_connected)
|
|
vm.addSystemMessage(text)
|
|
}
|
|
|
|
// conn was trusted, so navigate to messages page
|
|
this.service.handler.post {
|
|
this.service.app.findNavController(R.id.nav_host_fragment).navigate(R.id.nav_messages_tabs)
|
|
}
|
|
|
|
// send protocol preferences
|
|
this.sendPreferences(txStream, tx)
|
|
|
|
// request catch-up or backlog
|
|
this.requestBacklog(txStream, tx)
|
|
|
|
// start the foreground service
|
|
this.service.startForeground(1, this.service.makeNotification())
|
|
|
|
// create a producer for messages from the server
|
|
val messagesIn = this.createMessagesIn(conn, rxStream, rx)
|
|
|
|
// start the event loop and continue until we're no longer connected or explicitly told
|
|
// to stop
|
|
EventLoop(this).run(conn, messagesIn, txStream, tx)
|
|
|
|
// close the connection after the event loop has ended
|
|
conn.close()
|
|
|
|
// let the ui know we're no longer connected
|
|
this.service.handler.post {
|
|
val vm: ServersViewModel by this.service.app.viewModels()
|
|
vm.connected.value = false
|
|
vm.lastConnectedServerId = vm.connectedServerId
|
|
vm.connectedServerId = -1
|
|
vm.playerPortrait.value = null
|
|
vm.playerData.value = null
|
|
vm.channel.value = null
|
|
|
|
val friendsVm: FriendListViewModel by this.service.app.viewModels()
|
|
friendsVm.friends.value = null
|
|
}
|
|
|
|
runner.lastPlayerPortrait = null
|
|
|
|
this.service.handler.post {
|
|
val vm: MessagesViewModel by this@ConnectionRunner.service.app.viewModels()
|
|
val text = this@ConnectionRunner.service.app.getString(R.string.messages_disconnected)
|
|
vm.addSystemMessage(text)
|
|
}
|
|
|
|
// stop the service, removing the notification
|
|
this.service.app.stopConnectionService()
|
|
}
|
|
|
|
@OptIn(ExperimentalCoroutinesApi::class)
|
|
private fun createMessagesIn(conn: Socket, rxStream: ByteReadChannel, rx: Key): ReceiveChannel<ByteArray> {
|
|
return CoroutineScope(Dispatchers.IO).produce {
|
|
while (!conn.isClosed) {
|
|
try {
|
|
this.send(SecretMessage.readSecretMessage(this@ConnectionRunner.service.app.sodium, rxStream, rx))
|
|
} catch (ex: ClosedReceiveChannelException) {
|
|
break
|
|
} catch (ex: Exception) {
|
|
ex.printStackTrace()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(KtorExperimentalAPI::class)
|
|
private suspend fun attemptConnection(runner: ConnectionRunner): Socket? {
|
|
Sentry.addBreadcrumb(Breadcrumb("Initiated connection to server").apply {
|
|
this.category = "connection"
|
|
this.data["host"] = runner.host
|
|
this.data["port"] = runner.port
|
|
this.data["relayTarget"] = runner.relayTarget ?: "none"
|
|
})
|
|
|
|
val conn: Socket
|
|
try {
|
|
conn = withTimeout(10_000) {
|
|
aSocket(ActorSelectorManager(Dispatchers.IO))
|
|
.tcp()
|
|
.connect(runner.host, runner.port) {
|
|
this.keepAlive = true
|
|
}
|
|
}
|
|
} catch (ex: Exception) {
|
|
// send error to sentry if this is a relay connection
|
|
if (runner.relay) {
|
|
Sentry.withScope {
|
|
it.setContexts("connection", mapOf(
|
|
"host" to runner.host,
|
|
"port" to runner.port,
|
|
"relay" to runner.relay,
|
|
"relayTarget" to (runner.relayTarget ?: "none"),
|
|
))
|
|
|
|
Sentry.captureException(ex)
|
|
}
|
|
}
|
|
val dialog = AlertDialogFragment.newInstance(Companion.DIALOG_COULD_NOT_CONNECT, Bundle().apply {
|
|
this.putString("message", ex.localizedMessage)
|
|
})
|
|
runner.service.handler.post { this.service.app.queueDialog(dialog, "connection_error") }
|
|
// unbind the service
|
|
this.service.app.stopConnectionService()
|
|
return null
|
|
} finally {
|
|
// let the ui know we're done connecting
|
|
this.service.handler.post { this.service.connecting.value = false }
|
|
}
|
|
|
|
Sentry.addBreadcrumb(Breadcrumb("Connection succeeded").apply {
|
|
this.category = "connection"
|
|
})
|
|
|
|
return conn
|
|
}
|
|
|
|
private suspend fun doTrustProcess(conn: Socket, info: AppKeyExchange.HandshakeInfo): Boolean {
|
|
val trusted: MutableSet<String> = HashSet(this.service.app.preferences.getStringSet("trusted:${this.host}", emptySet()) ?: emptySet())
|
|
if (trusted.contains(info.remotePublicKey.toHexString())) {
|
|
return true
|
|
}
|
|
|
|
// prompt user to verify trust in public key
|
|
val trustChannel = Channel<Boolean>()
|
|
|
|
val receiver = object : ResultReceiver(this.service.handler) {
|
|
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
|
when (resultCode) {
|
|
Activity.RESULT_OK -> trustChannel.sendBlocking(true)
|
|
Activity.RESULT_CANCELED -> trustChannel.sendBlocking(false)
|
|
else -> throw IllegalArgumentException("unexpected result code")
|
|
}
|
|
}
|
|
}
|
|
val dialog = TrustDialog.newInstance(
|
|
receiver,
|
|
info.remotePublicKey,
|
|
this.service.app.keyPair.publicKey.asBytes
|
|
)
|
|
|
|
this.service.handler.post {
|
|
this.service.app.queueDialog(dialog, TrustDialog.TAG)
|
|
}
|
|
|
|
// listen for rejection from user and stop if necessary
|
|
if (!trustChannel.receive()) {
|
|
conn.close()
|
|
this.service.app.stopConnectionService()
|
|
return false
|
|
}
|
|
|
|
// add key to the trusted set
|
|
trusted.add(info.remotePublicKey.toHexString())
|
|
this.service.app.preferences
|
|
.edit()
|
|
.putStringSet("trusted:${this.host}", trusted)
|
|
.apply()
|
|
|
|
return true
|
|
}
|
|
|
|
@OptIn(ExperimentalUnsignedTypes::class)
|
|
private suspend fun requestBacklog(txStream: ByteWriteChannel, tx: Key) {
|
|
val backlogAmount = this.service.app.preferences.getString("backlogAmount", "100")?.toIntOrNull() ?: 100
|
|
if (backlogAmount <= 0) {
|
|
return
|
|
}
|
|
|
|
val messagesVm: MessagesViewModel by this@ConnectionRunner.service.app.viewModels()
|
|
val serversVm: ServersViewModel by this@ConnectionRunner.service.app.viewModels()
|
|
|
|
// find latest message date
|
|
// FIXME: this can ConcurrentModificationException (how do you rwlock in kotlin)
|
|
val lastTimestamp = messagesVm.messages.lastOrNull { it.channel != ChatType.AppSystem.code.toUShort() }?.timestamp
|
|
|
|
val bytes = if (lastTimestamp != null && serversVm.connectedServerId == serversVm.lastConnectedServerId) {
|
|
// reconnect
|
|
|
|
// request catch-up
|
|
val catchUpReq = ClientCatchUp(lastTimestamp)
|
|
catchUpReq.encode()
|
|
} else {
|
|
// new connect
|
|
|
|
// clear existing messages
|
|
messagesVm.clearMessages()
|
|
|
|
// request backlog
|
|
val backlogReq = ClientBacklog(backlogAmount.toUShort())
|
|
backlogReq.encode()
|
|
}
|
|
|
|
SecretMessage.writeSecretMessage(this@ConnectionRunner.service.app.sodium, txStream, tx, bytes)
|
|
}
|
|
|
|
private suspend fun sendPreferences(txStream: ByteWriteChannel, tx: Key) {
|
|
val prefs = ClientPreferences(mapOf(
|
|
ClientPreference.TargetingListSupport to true,
|
|
))
|
|
SecretMessage.writeSecretMessage(this.service.app.sodium, txStream, tx, prefs.encode())
|
|
}
|
|
|
|
private class EventLoop(private val runner: ConnectionRunner) {
|
|
private var loop = true
|
|
|
|
@OptIn(ObsoleteCoroutinesApi::class)
|
|
suspend fun run(conn: Socket, messagesIn: ReceiveChannel<ByteArray>, txStream: ByteWriteChannel, tx: Key) {
|
|
// create a ticker for pinging
|
|
val pingTicker = ticker(delayMillis = 30_000, initialDelayMillis = 30_000)
|
|
|
|
while (!conn.isClosed && this.loop) {
|
|
try {
|
|
this.select(txStream, tx, messagesIn, pingTicker)
|
|
} catch (ex: Exception) {
|
|
ex.printStackTrace()
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(InternalCoroutinesApi::class)
|
|
private suspend fun select(txStream: ByteWriteChannel, tx: Key, messagesIn: ReceiveChannel<ByteArray>, pingTicker: ReceiveChannel<Unit>) {
|
|
kotlinx.coroutines.selects.select<Unit> {
|
|
// being told to disconnect
|
|
this@EventLoop.runner.disconnectChannel.onReceive {
|
|
this@EventLoop.loop = false
|
|
SecretMessage.writeSecretMessage(this@EventLoop.runner.service.app.sodium, txStream, tx, ClientShutdown.encode())
|
|
}
|
|
// receiving a message from the server
|
|
messagesIn.onReceiveOrClosed {
|
|
if (it.isClosed) {
|
|
Sentry.addBreadcrumb(Breadcrumb("Disconnected from server").apply {
|
|
this.category = "connection"
|
|
})
|
|
this@EventLoop.loop = false
|
|
val dialog = AlertDialogFragment.newInstance(Companion.DIALOG_DISCONNECTED)
|
|
this@EventLoop.runner.service.handler.post {
|
|
this@EventLoop.runner.service.app.queueDialog(dialog, "disconnected")
|
|
}
|
|
return@onReceiveOrClosed
|
|
}
|
|
|
|
@Suppress("NAME_SHADOWING")
|
|
val it = it.value
|
|
|
|
if (it.isEmpty()) {
|
|
return@onReceiveOrClosed
|
|
}
|
|
|
|
// get the payload of the message
|
|
val payload = ByteBuffer.wrap(it, 1, it.size - 1)
|
|
|
|
// check what operation this message is
|
|
this@EventLoop.runServerOperation(it[0], payload)
|
|
}
|
|
// receiving a message going to the server
|
|
this@EventLoop.runner.service.messages.onReceive {
|
|
// encode the message and send it to the server
|
|
val bytes = it.encode()
|
|
SecretMessage.writeSecretMessage(this@EventLoop.runner.service.app.sodium, txStream, tx, bytes)
|
|
}
|
|
// receiving a ping tick
|
|
pingTicker.onReceive {
|
|
// send a ping message to the server
|
|
SecretMessage.writeSecretMessage(this@EventLoop.runner.service.app.sodium, txStream, tx, Ping.encode())
|
|
}
|
|
// user asked for friend list
|
|
this@EventLoop.runner.friendListChannel.onReceive {
|
|
val msg = ClientPlayerList(PlayerListType.Friend).encode()
|
|
SecretMessage.writeSecretMessage(this@EventLoop.runner.service.app.sodium, txStream, tx, msg)
|
|
}
|
|
// user asked to change channel
|
|
this@EventLoop.runner.channelChannel.onReceive {
|
|
val msg = ClientChannel(it).encode()
|
|
SecretMessage.writeSecretMessage(this@EventLoop.runner.service.app.sodium, txStream, tx, msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun runServerOperation(code: Byte, payload: ByteBuffer) {
|
|
when (ServerOperation.fromCode(code)) {
|
|
ServerOperation.Message -> {
|
|
// read the message payload and update the messages view model with it
|
|
val message = ServerMessage.read(payload)
|
|
this.runner.service.handler.post {
|
|
val vm: MessagesViewModel by this.runner.service.app.viewModels()
|
|
vm.addMessage(message)
|
|
}
|
|
}
|
|
ServerOperation.Pong -> {
|
|
// do nothing on pongs
|
|
}
|
|
ServerOperation.Shutdown -> this.loop = false
|
|
ServerOperation.Availability -> {
|
|
// parse the payload and tell the messages view model if the server is available
|
|
val availability = Availability.read(payload)
|
|
val vm: MessagesViewModel by this.runner.service.app.viewModels()
|
|
this.runner.service.handler.post { vm.available.value = availability.available }
|
|
}
|
|
ServerOperation.PlayerData -> {
|
|
// parse the payload and update the nav view with info and avatar
|
|
val playerData = PlayerData.read(payload)
|
|
|
|
val vm: ServersViewModel by this.runner.service.app.viewModels()
|
|
this.runner.service.handler.post {
|
|
vm.playerData.value = playerData
|
|
}
|
|
|
|
if (playerData == null) {
|
|
this.runner.lastPlayerPortrait = null
|
|
this.runner.service.handler.post {
|
|
vm.playerPortrait.value = null
|
|
vm.playerData.value = null
|
|
}
|
|
return
|
|
}
|
|
|
|
// only download pfp if it's different than the last successful download
|
|
val nameAndWorld = "${playerData.name}/${playerData.homeWorld}"
|
|
if (nameAndWorld != this.runner.lastPlayerPortrait) {
|
|
// this will run without blocking this coroutine
|
|
this.runner.downloadPlayerPortrait(playerData) { success ->
|
|
if (success) {
|
|
this.runner.lastPlayerPortrait = nameAndWorld
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ServerOperation.Channel -> {
|
|
val channel = io.annaclemens.xivchat.model.message.Channel.read(payload)
|
|
this.runner.service.handler.post {
|
|
val vm: ServersViewModel by this.runner.service.app.viewModels()
|
|
vm.channel.value = channel.name
|
|
}
|
|
}
|
|
ServerOperation.Backlog -> {
|
|
val backlog = ServerBacklog.read(payload)
|
|
val vm: MessagesViewModel by this.runner.service.app.viewModels()
|
|
this.runner.service.handler.post {
|
|
vm.addMessages(backlog.messages.toList())
|
|
}
|
|
}
|
|
ServerOperation.PlayerList -> {
|
|
val list = ServerPlayerList.read(payload)
|
|
when (list.type) {
|
|
PlayerListType.Friend -> {
|
|
val vm: FriendListViewModel by this.runner.service.app.viewModels()
|
|
this.runner.service.handler.post {
|
|
vm.friends.value = list.players.toList()
|
|
}
|
|
}
|
|
PlayerListType.Targeting -> {
|
|
val vm: TargetingListViewModel by this.runner.service.app.viewModels()
|
|
this.runner.service.handler.post {
|
|
val now = Date()
|
|
val data = list.players
|
|
val oldTargeting = vm.targeting.value.orEmpty()
|
|
|
|
val newTargeting = data
|
|
.asSequence()
|
|
.filter { new -> oldTargeting.all { current -> current.player.name != new.name && current.player.homeWorld != new.homeWorld } }
|
|
.map { TargetingPlayer(it, true, now) }
|
|
val finalTargeting = oldTargeting
|
|
.asSequence()
|
|
.map {
|
|
var timestamp = it.timestamp
|
|
val current = data.any { dataPlayer -> dataPlayer.name == it.player.name && dataPlayer.homeWorld == it.player.homeWorld }
|
|
if (current || it.current) {
|
|
timestamp = now
|
|
}
|
|
|
|
TargetingPlayer(it.player, current, timestamp)
|
|
}
|
|
.plus(newTargeting)
|
|
.sortedByDescending { it.current }
|
|
.sortedByDescending { it.timestamp }
|
|
vm.targeting.value = finalTargeting.toList()
|
|
}
|
|
}
|
|
else -> {
|
|
// TODO: other player list types
|
|
}
|
|
}
|
|
}
|
|
ServerOperation.LinkshellList -> {
|
|
// TODO: linkshell list
|
|
}
|
|
null -> {
|
|
// ignore invalid message types
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun downloadPlayerPortrait(playerData: PlayerData, after: suspend (Boolean) -> Unit) {
|
|
val runner = this
|
|
|
|
// create the http client and build the xivapi url for searching for this player
|
|
val client = HttpClient(Android)
|
|
val url = Uri.parse("https://xivapi.com/character/search")
|
|
.buildUpon()
|
|
.appendQueryParameter("name", playerData.name)
|
|
.appendQueryParameter("server", playerData.homeWorld)
|
|
.appendQueryParameter("columns", "Avatar")
|
|
.build()
|
|
// run this on the io dispatcher
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
val image: Bitmap?
|
|
try {
|
|
// download the search results and decode them into a utf-8 string
|
|
val jsonBytes: ByteArray = client.get(url.toString())
|
|
val json = jsonBytes.decodeToString()
|
|
// parse the search results into a class
|
|
val data: CharacterSearchResults = Json {
|
|
this.ignoreUnknownKeys = true
|
|
}.decodeFromString(json)
|
|
// if there were no results, return false (will try again on next zone change)
|
|
if (data.results.isEmpty()) {
|
|
after(false)
|
|
return@launch
|
|
}
|
|
// otherwise, get the avatar url from the first result and download the image bytes
|
|
val imageBytes: ByteArray = client.get(data.results[0].avatar)
|
|
// decode the image into a bitmap for android
|
|
image = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
|
} catch (ex: Exception) {
|
|
// if any error occurred, log it and try again on next zone change
|
|
ex.printStackTrace()
|
|
after(false)
|
|
return@launch
|
|
} finally {
|
|
// make sure we always close the client
|
|
client.close()
|
|
}
|
|
|
|
// if the image got downloaded and parsed and the app is set, tell the app to update the picture
|
|
if (image != null) {
|
|
runner.service.handler.post {
|
|
val vm: ServersViewModel by this@ConnectionRunner.service.app.viewModels()
|
|
vm.playerPortrait.value = image
|
|
}
|
|
// this was a success, so don't try again on next zone change
|
|
after(true)
|
|
return@launch
|
|
}
|
|
after(false)
|
|
}
|
|
}
|
|
|
|
override fun run() {
|
|
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
|
|
|
|
this.job = CoroutineScope(Dispatchers.Default).launch {
|
|
this@ConnectionRunner.asyncRun()
|
|
}
|
|
}
|
|
}
|
|
}
|