android/app/src/main/java/io/annaclemens/xivchat/ConnectionService.kt

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()
}
}
}
}