for-android/app/src/main/java/chat/revolt/api/RevoltAPI.kt

220 lines
6.6 KiB
Kotlin

package chat.revolt.api
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import chat.revolt.BuildConfig
import chat.revolt.api.internals.Members
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf
import chat.revolt.api.schemas.Emoji
import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.Server
import chat.revolt.api.schemas.User
import chat.revolt.api.unreads.Unreads
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.header
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import chat.revolt.api.schemas.Channel as ChannelSchema
const val REVOLT_BASE = "https://api.revolt.chat"
const val REVOLT_SUPPORT = "https://support.revolt.chat"
const val REVOLT_MARKETING = "https://revolt.chat"
const val REVOLT_FILES = "https://autumn.revolt.chat"
const val REVOLT_JANUARY = "https://jan.revolt.chat"
const val REVOLT_APP = "https://app.revolt.chat"
const val REVOLT_WEBSOCKET = "wss://ws.revolt.chat"
fun buildUserAgent(accessMethod: String = "Ktor"): String {
return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} ${BuildConfig.APPLICATION_ID} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}; Analysis ${BuildConfig.ANALYSIS_ENABLED} ${BuildConfig.ANALYSIS_BASEURL}; (Kotlin ${KotlinVersion.CURRENT})"
}
private const val BACKEND_IS_STABLE = false
@OptIn(ExperimentalSerializationApi::class)
val RevoltJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
val RevoltHttp = HttpClient(OkHttp) {
install(DefaultRequest)
install(ContentNegotiation) {
json(RevoltJson)
}
install(WebSockets)
if (BACKEND_IS_STABLE) {
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 5)
retryOnException(maxRetries = 5)
modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString())
}
exponentialDelay()
}
}
install(Logging) { level = LogLevel.INFO }
engine {
addInterceptor { chain ->
val request = chain.request().newBuilder()
.header(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
.build()
chain.proceed(request)
}
}
defaultRequest {
url(REVOLT_BASE)
header("User-Agent", buildUserAgent())
}
}
val mainHandler = Handler(Looper.getMainLooper())
object RevoltAPI {
const val TOKEN_HEADER_NAME = "x-session-token"
val userCache = mutableStateMapOf<String, User>()
val serverCache = mutableStateMapOf<String, Server>()
val channelCache = mutableStateMapOf<String, ChannelSchema>()
val emojiCache = mutableStateMapOf<String, Emoji>()
val messageCache = mutableStateMapOf<String, Message>()
val members = Members()
val unreads = Unreads()
var selfId: String? = null
var sessionToken: String = ""
private set
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
val realtimeContext = newSingleThreadContext("RealtimeContext")
val wsFrameChannel = Channel<Any>(Channel.UNLIMITED)
private var socketCoroutine: Job? = null
fun setSessionHeader(token: String) {
sessionToken = token
}
suspend fun loginAs(token: String) {
setSessionHeader(token)
fetchSelf()
startSocketOps()
unreads.sync()
}
suspend fun connectWS() {
socketCoroutine = CoroutineScope(Dispatchers.IO).launch {
try {
withContext(realtimeContext) {
RealtimeSocket.connect(sessionToken)
}
} catch (e: Exception) {
if (e is InterruptedException) {
Log.d("RevoltAPI", "Socket interrupted")
} else {
Log.e("RevoltAPI", "WebSocket error", e)
}
RealtimeSocket.updateDisconnectionState(DisconnectionState.Disconnected)
}
}
}
private suspend fun startSocketOps() {
connectWS()
// Send a ping every roughly 30 seconds else the socket dies
// Same interval as the web clients (/revolt.js)
// Note: This will run even if the socket is closed (sendPing will just exit early)
mainHandler.post(object : Runnable {
override fun run() {
runBlocking {
RealtimeSocket.sendPing()
}
mainHandler.postDelayed(this, 30 * 1000)
}
})
}
suspend fun initialize() {
if (sessionToken != "") {
fetchSelf()
}
}
/**
* Returns true if the user is logged in and the current user has been fetched at least once.
* Call [initialize] to fetch the current user first, else this will return false.
*/
fun isLoggedIn(): Boolean {
return selfId != null
}
/**
* Clears the API client's state completely.
*/
fun logout() {
selfId = null
sessionToken = ""
userCache.clear()
serverCache.clear()
channelCache.clear()
emojiCache.clear()
messageCache.clear()
members.clear()
unreads.clear()
socketCoroutine?.cancel()
mainHandler.removeCallbacksAndMessages(null)
}
/**
* Checks if a session token is valid.
*/
suspend fun checkSessionToken(token: String): Boolean {
return try {
setSessionHeader(token)
fetchSelf()
true
} catch (e: Exception) {
false
}
}
}
@Serializable
data class RevoltError(val type: String)