diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index 5e06f7e9..b944c75e 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -76,6 +76,7 @@ import chat.stoat.StoatApplication import chat.stoat.api.HitRateLimitException import chat.stoat.api.StoatAPI import chat.stoat.api.StoatHttp +import chat.stoat.api.StoatInstance import chat.stoat.api.api import chat.stoat.api.routes.microservices.geo.queryGeo import chat.stoat.api.routes.microservices.health.healthCheck @@ -312,8 +313,11 @@ class MainActivityViewModel( init { Log.d("MainActivity", "Starting up") - doPreStartupTasks() - checkLoggedInState() + viewModelScope.launch { + StoatInstance.restore(kvStorage) + doPreStartupTasks() + checkLoggedInState() + } } } diff --git a/app/src/main/java/chat/stoat/api/StoatAPI.kt b/app/src/main/java/chat/stoat/api/StoatAPI.kt index 01ecccf3..00822a97 100644 --- a/app/src/main/java/chat/stoat/api/StoatAPI.kt +++ b/app/src/main/java/chat/stoat/api/StoatAPI.kt @@ -56,18 +56,20 @@ import chat.stoat.core.model.schemas.Channel as ChannelSchema private const val USE_ALPHA_API = false -val STOAT_BASE = - if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.stoat.chat/0.8" +val STOAT_BASE: String + get() = if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else StoatInstance.apiBase const val STOAT_SUPPORT = "https://support.stoat.chat" const val STOAT_MARKETING = "https://stoat.chat" -val STOAT_FILES = - if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.stoatusercontent.com" -val STOAT_PROXY = - if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://proxy.stoatusercontent.com" -const val STOAT_WEB_APP = "https://stoat.chat" -const val STOAT_INVITES = "https://stt.gg" -val STOAT_WEBSOCKET = - if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://events.stoat.chat" +val STOAT_FILES: String + get() = if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else StoatInstance.filesBase +val STOAT_PROXY: String + get() = if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else StoatInstance.proxyBase +val STOAT_WEB_APP: String + get() = if (USE_ALPHA_API) "https://alpha.revolt.chat" else StoatInstance.webApp +val STOAT_INVITES: String + get() = if (USE_ALPHA_API) "https://alpha.revolt.chat" else StoatInstance.invites +val STOAT_WEBSOCKET: String + get() = if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else StoatInstance.websocket fun String.api(): String { return "$STOAT_BASE$this" @@ -385,4 +387,4 @@ data class RateLimitResponse(@SerialName("retry_after") val retryAfter: Int) { internal const val NO_RETRY_AFTER = Int.MIN_VALUE class HitRateLimitException(retryAfter: Int = NO_RETRY_AFTER) : - Exception(if (retryAfter == NO_RETRY_AFTER) "Hit rate limit" else "Hit rate limit, retry after ${retryAfter}ms") \ No newline at end of file + Exception(if (retryAfter == NO_RETRY_AFTER) "Hit rate limit" else "Hit rate limit, retry after ${retryAfter}ms") diff --git a/app/src/main/java/chat/stoat/api/StoatInstance.kt b/app/src/main/java/chat/stoat/api/StoatInstance.kt new file mode 100644 index 00000000..59783d0f --- /dev/null +++ b/app/src/main/java/chat/stoat/api/StoatInstance.kt @@ -0,0 +1,113 @@ +package chat.stoat.api + +import chat.stoat.persistence.KVStorage +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +object StoatInstance { + const val KV_INSTANCE_API_BASE = "instanceApiBase" + + private const val DEFAULT_API_BASE = "https://api.stoat.chat/0.8" + private const val DEFAULT_FILES_BASE = "https://cdn.stoatusercontent.com" + private const val DEFAULT_PROXY_BASE = "https://proxy.stoatusercontent.com" + private const val DEFAULT_WEB_APP = "https://stoat.chat" + private const val DEFAULT_INVITES = "https://stt.gg" + private const val DEFAULT_WEBSOCKET = "wss://events.stoat.chat" + + var apiBase: String = DEFAULT_API_BASE + private set + var filesBase: String = DEFAULT_FILES_BASE + private set + var proxyBase: String = DEFAULT_PROXY_BASE + private set + var webApp: String = DEFAULT_WEB_APP + private set + var invites: String = DEFAULT_INVITES + private set + var websocket: String = DEFAULT_WEBSOCKET + private set + + suspend fun restore(kvStorage: KVStorage) { + configure(kvStorage.get(KV_INSTANCE_API_BASE) ?: DEFAULT_API_BASE) + } + + suspend fun configure(rawApiBase: String, kvStorage: KVStorage? = null) { + val normalizedApiBase = normalizeApiBase(rawApiBase) + applyFallbacks(normalizedApiBase) + discoverInstanceMetadata(normalizedApiBase) + kvStorage?.set(KV_INSTANCE_API_BASE, normalizedApiBase) + } + + fun normalizeApiBase(rawApiBase: String): String { + val trimmed = rawApiBase.trim().ifBlank { DEFAULT_API_BASE } + val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed" + return withScheme.trimEnd('/') + } + + private fun applyFallbacks(normalizedApiBase: String) { + apiBase = normalizedApiBase + if (normalizedApiBase == DEFAULT_API_BASE) { + filesBase = DEFAULT_FILES_BASE + proxyBase = DEFAULT_PROXY_BASE + webApp = DEFAULT_WEB_APP + invites = DEFAULT_INVITES + websocket = DEFAULT_WEBSOCKET + return + } + + val serviceRoot = normalizedApiBase.removeSuffix("/0.8").removeSuffix("/api") + filesBase = "$serviceRoot/autumn" + proxyBase = "$serviceRoot/january" + webApp = serviceRoot + invites = serviceRoot + websocket = serviceRoot + .replaceFirst("https://", "wss://") + .replaceFirst("http://", "ws://") + "/ws" + } + + private suspend fun discoverInstanceMetadata(normalizedApiBase: String) { + val client = HttpClient(OkHttp) { + install(ContentNegotiation) { + json(StoatJson) + } + } + + try { + val metadata = client.get(normalizedApiBase).body() + metadata.ws?.trimEnd('/')?.let { websocket = it } + metadata.revolt?.trimEnd('/')?.let { webApp = it } + metadata.features?.autumn?.url?.trimEnd('/')?.let { filesBase = it } + metadata.features?.january?.url?.trimEnd('/')?.let { proxyBase = it } + } catch (_: Exception) { + // Keep derived fallbacks. Login requests will surface connection/auth errors to the user. + } finally { + client.close() + } + } +} + +@Serializable +private data class InstanceMetadata( + val revolt: String? = null, + val ws: String? = null, + val features: InstanceFeatures? = null +) + +@Serializable +private data class InstanceFeatures( + val autumn: InstanceFeatureEndpoint? = null, + val january: InstanceFeatureEndpoint? = null +) + +@Serializable +private data class InstanceFeatureEndpoint( + val enabled: Boolean = false, + @SerialName("url") + val url: String? = null +) diff --git a/app/src/main/java/chat/stoat/screens/login/LoginScreen.kt b/app/src/main/java/chat/stoat/screens/login/LoginScreen.kt index 13de42bb..1ce27899 100644 --- a/app/src/main/java/chat/stoat/screens/login/LoginScreen.kt +++ b/app/src/main/java/chat/stoat/screens/login/LoginScreen.kt @@ -50,6 +50,7 @@ import chat.stoat.R import chat.stoat.StoatApplication import chat.stoat.api.STOAT_WEB_APP import chat.stoat.api.StoatAPI +import chat.stoat.api.StoatInstance import chat.stoat.api.routes.account.EmailPasswordAssessment import chat.stoat.api.routes.account.negotiateAuthentication import chat.stoat.api.routes.onboard.needsOnboarding @@ -71,6 +72,10 @@ class LoginViewModel( val password: String get() = _password + private var _instanceUrl by mutableStateOf(StoatInstance.apiBase) + val instanceUrl: String + get() = _instanceUrl + private var _error by mutableStateOf(null) val error: String? get() = _error @@ -83,10 +88,18 @@ class LoginViewModel( val mfaResponse: EmailPasswordAssessment? get() = _mfaResponse + init { + viewModelScope.launch { + _instanceUrl = kvStorage.get(StoatInstance.KV_INSTANCE_API_BASE) ?: StoatInstance.apiBase + } + } + fun doLogin() { _error = null viewModelScope.launch { + StoatInstance.configure(_instanceUrl, kvStorage) + val response = try { negotiateAuthentication(_email, _password) } catch (e: Exception) { @@ -145,6 +158,10 @@ class LoginViewModel( fun setPassword(password: String) { _password = password } + + fun setInstanceUrl(instanceUrl: String) { + _instanceUrl = instanceUrl + } } @Composable @@ -231,6 +248,18 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = koinVi contentType = ContentType.EmailAddress } ) + FormTextField( + value = viewModel.instanceUrl, + label = stringResource(R.string.instance_api_url), + type = KeyboardType.Uri, + action = ImeAction.Next, + onChange = viewModel::setInstanceUrl, + supportingText = { + Text(text = stringResource(R.string.instance_api_url_hint)) + }, + modifier = Modifier + .padding(bottom = 25.dp) + ) SecureTextField( passwordTextFieldState, label = { Text(stringResource(R.string.password)) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index df3bc88f..bec6fed1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,8 @@ Email Password + Instance API URL + Use the default Stoat API, or enter a self-hosted Revolt API URL. Hide password Show password