diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index 0c91a030..8d140dd8 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -76,6 +76,9 @@ import chat.stoat.BuildConfig import chat.stoat.R import chat.stoat.StoatApplication import chat.stoat.api.HitRateLimitException +import chat.stoat.api.STOAT_BASE +import chat.stoat.api.STOAT_BASE_DEFAULT +import chat.stoat.api.STOAT_WEB_APP_DEFAULT import chat.stoat.api.StoatAPI import chat.stoat.api.StoatHttp import chat.stoat.api.api @@ -86,6 +89,7 @@ import chat.stoat.api.settings.Experiments import chat.stoat.api.settings.GeoStateProvider import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.SyncedSettings +import chat.stoat.api.updateStoatWebApp import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.voice.VoicePermissionSwitch import chat.stoat.core.model.schemas.HealthNotice @@ -207,6 +211,11 @@ class MainActivityViewModel @Inject constructor( isConnected.emit(hasInternetConnection()) + val stoatInstanceUrl = kvStorage.get("stoatInstanceUrl"); + if(!stoatInstanceUrl.isNullOrBlank()) { + updateStoatWebApp(stoatInstanceUrl); + } + Log.d("MainActivity", "Checking if we can reach Stoat") if (!isConnected.value) return@launch startWithoutDestination() @@ -222,13 +231,29 @@ class MainActivityViewModel @Inject constructor( "We have a session token, checking if it's valid and if we can still reach Stoat" ) - val canReachStoat = canReachStoat() - val valid = try { - StoatAPI.checkSessionToken(token) - } catch (e: Throwable) { - false + var valid = false; + var canReachStoat = false; + for(attempt in 1..2) { + canReachStoat = canReachStoat(); + if(canReachStoat) { + valid = try { + StoatAPI.checkSessionToken(token) + } catch (e: Throwable) { + false + } + } else { + Log.d("MainActivity", "Cannot reach $STOAT_BASE, trying $STOAT_BASE_DEFAULT"); + // Fall back to the default instance, + // otherwise the user might get stuck trying to connect to an unreachable instance + updateStoatWebApp(STOAT_WEB_APP_DEFAULT); + } + + if(canReachStoat && valid) { + break; + } } + if (canReachStoat && !valid) { Log.d("MainActivity", "Session token is invalid, could not log in") couldNotLogIn.emit(true) diff --git a/app/src/main/java/chat/stoat/api/StoatAPI.kt b/app/src/main/java/chat/stoat/api/StoatAPI.kt index 08f6e950..26585fc0 100644 --- a/app/src/main/java/chat/stoat/api/StoatAPI.kt +++ b/app/src/main/java/chat/stoat/api/StoatAPI.kt @@ -4,6 +4,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.compose.runtime.mutableStateMapOf +import androidx.core.net.toUri import chat.stoat.BuildConfig import chat.stoat.StoatApplication import chat.stoat.api.StoatAPI.initialize @@ -56,20 +57,68 @@ import chat.stoat.core.model.schemas.Channel as ChannelSchema private const val USE_ALPHA_API = false -val STOAT_BASE = +val STOAT_BASE_DEFAULT = if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.stoat.chat/0.8" + +var STOAT_BASE = STOAT_BASE_DEFAULT + const val STOAT_SUPPORT = "https://support.stoat.chat" const val STOAT_MARKETING = "https://stoat.chat" -val STOAT_FILES = +val STOAT_FILES_DEFAULT = if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.stoatusercontent.com" +var STOAT_FILES = STOAT_FILES_DEFAULT; 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_WEB_APP_DEFAULT = "https://stoat.chat" +var STOAT_WEB_APP = "https://stoat.chat" + + + const val STOAT_INVITES = "https://stt.gg" -val STOAT_WEBSOCKET = +val STOAT_WEBSOCKET_DEFAULT = if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://events.stoat.chat" +var STOAT_WEBSOCKET = STOAT_WEBSOCKET_DEFAULT const val STOAT_KJBOOK = "https://stoatchat.github.io/for-android" +fun updateStoatWebApp(webApp: String = STOAT_WEB_APP_DEFAULT) { + var sanitisedBaseUrl = webApp.trim(); + + if(sanitisedBaseUrl.isBlank()) { + sanitisedBaseUrl = STOAT_WEB_APP_DEFAULT; + } + + if(!sanitisedBaseUrl.matches(Regex("^https?://.+"))) { + // Default to https if no scheme provided + sanitisedBaseUrl = "https://${sanitisedBaseUrl}"; + } + + var parsed = sanitisedBaseUrl.toUri(); + + val portPart = if (parsed.port == 80 || parsed.port == 443) "" else ":${parsed.port}"; + val root = "${parsed.scheme}://${parsed.host}${portPart}"; + val rootWithPort = "${parsed.scheme}://${parsed.host}:${parsed.port}"; + if(sanitisedBaseUrl.matches(Regex("^$root/+$")) || sanitisedBaseUrl.matches(Regex("^$rootWithPort/+$"))) { + // Remove trailing slashes + // (not sure if it'd actually cause an issue but might as well clean it up) + sanitisedBaseUrl = rootWithPort; + } + + val isDefaultInstance = sanitisedBaseUrl == STOAT_WEB_APP_DEFAULT; + + if(!isDefaultInstance) { + STOAT_WEB_APP = sanitisedBaseUrl; + STOAT_BASE = "$sanitisedBaseUrl/api"; + val wssRoot = sanitisedBaseUrl.replace(Regex("^http(s?):"), "ws$1:"); + STOAT_WEBSOCKET = "$wssRoot/ws"; + STOAT_FILES = "$sanitisedBaseUrl/autumn"; + } else { + STOAT_WEB_APP = STOAT_WEB_APP_DEFAULT; + STOAT_BASE = STOAT_BASE_DEFAULT; + STOAT_WEBSOCKET = STOAT_WEBSOCKET_DEFAULT; + STOAT_FILES = STOAT_FILES_DEFAULT; + } +} + fun String.api(): String { return "$STOAT_BASE$this" } diff --git a/app/src/main/java/chat/stoat/composables/generic/FormTextField.kt b/app/src/main/java/chat/stoat/composables/generic/FormTextField.kt index ec6e5dd3..48bc79c7 100644 --- a/app/src/main/java/chat/stoat/composables/generic/FormTextField.kt +++ b/app/src/main/java/chat/stoat/composables/generic/FormTextField.kt @@ -23,7 +23,8 @@ fun FormTextField( action: ImeAction = ImeAction.Done, supportingText: @Composable (() -> Unit)? = null, singleLine: Boolean = true, - enabled: Boolean = true + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, ) { TextField( value = value, @@ -34,6 +35,7 @@ fun FormTextField( label = { Text(label) }, supportingText = supportingText, enabled = enabled, - modifier = modifier + modifier = modifier, + placeholder = placeholder ) } 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 19e0f5c2..a27fbf6e 100644 --- a/app/src/main/java/chat/stoat/screens/login/LoginScreen.kt +++ b/app/src/main/java/chat/stoat/screens/login/LoginScreen.kt @@ -1,5 +1,6 @@ package chat.stoat.screens.login +import android.content.res.Configuration import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,8 +13,12 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.overscroll +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.TextObfuscationMode import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -31,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -50,10 +56,13 @@ import androidx.navigation.NavController import chat.stoat.R import chat.stoat.StoatApplication import chat.stoat.api.STOAT_WEB_APP +import chat.stoat.api.STOAT_WEB_APP_DEFAULT import chat.stoat.api.StoatAPI import chat.stoat.api.routes.account.EmailPasswordAssessment import chat.stoat.api.routes.account.negotiateAuthentication import chat.stoat.api.routes.onboard.needsOnboarding +import chat.stoat.api.updateStoatWebApp +import chat.stoat.composables.generic.CollapsibleCard import chat.stoat.composables.generic.FormTextField import chat.stoat.composables.generic.Weblink import chat.stoat.persistence.KVStorage @@ -74,6 +83,10 @@ class LoginViewModel @Inject constructor( val password: String get() = _password + private var _stoatInstanceUrl by mutableStateOf(STOAT_WEB_APP) + val stoatInstanceUrl: String + get() = _stoatInstanceUrl + private var _error by mutableStateOf(null) val error: String? get() = _error @@ -120,14 +133,15 @@ class LoginViewModel @Inject constructor( kvStorage.set("sessionId", id) val onboard = needsOnboarding(token) - if (onboard) { - _navigateTo = "onboarding" + if (onboard) { _navigateTo = "onboarding" return@launch } StoatAPI.loginAs(token) StoatAPI.setSessionId(response.firstUserHints.token) + kvStorage.set("stoatInstanceUrl", _stoatInstanceUrl); + _navigateTo = "home" } catch (e: Error) { _error = e.message ?: "Unknown error" @@ -148,6 +162,11 @@ class LoginViewModel @Inject constructor( fun setPassword(password: String) { _password = password } + + fun setStoatInstanceUrl(url: String = STOAT_WEB_APP) { + _stoatInstanceUrl = url; + updateStoatWebApp(url); + } } @Composable @@ -189,12 +208,16 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi } } + val configuration = LocalConfiguration.current; + Column( modifier = Modifier .fillMaxSize() - .padding(20.dp) + .padding(10.dp) .imePadding() - .safeDrawingPadding(), + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .overscroll(rememberOverscrollEffect()), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -217,11 +240,16 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi ) Column( - modifier = Modifier - .width(270.dp), + modifier = (when(configuration.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> Modifier.fillMaxWidth() + else -> Modifier.width(270.dp) + }) + .overscroll(rememberOverscrollEffect()) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { + FormTextField( value = viewModel.email, label = stringResource(R.string.email), @@ -277,6 +305,23 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi modifier = Modifier.padding(vertical = 7.dp) ) + CollapsibleCard( + header = {Text(text = stringResource(R.string.login_advanced_options))}, + modifier = Modifier.width(300.dp) + ) { + FormTextField( + value = viewModel.stoatInstanceUrl, + label = stringResource(R.string.stoat_instance), // TODO: needs translations + type = KeyboardType.Uri, + action = ImeAction.Next, + onChange = viewModel::setStoatInstanceUrl, + modifier = Modifier + .padding(vertical = 5.dp), + placeholder = {Text(text = STOAT_WEB_APP_DEFAULT)}, + supportingText = {Text(text = stringResource(R.string.stoat_instance_hint))} + ) + } + if (viewModel.error != null) { Text( text = viewModel.error!!, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c4dfeff..9c4b62f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,9 @@ Find your community, connect with the world. Stoat is one of the best ways to stay connected with your friends and community, anywhere, anytime. + Stoat host + This is the base URL of the Stoat instance you\'re logging in to. If you\'re unsure, leave this field blank to use the official Stoat instance. + Advanced options Email Password