feat: geo support

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-07-26 07:12:29 +02:00
parent 2721d74c04
commit a92a07e2ed
12 changed files with 851 additions and 559 deletions

View File

@ -79,10 +79,12 @@ import chat.revolt.api.HitRateLimitException
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import chat.revolt.api.api
import chat.revolt.api.routes.microservices.geo.queryGeo
import chat.revolt.api.routes.microservices.health.healthCheck
import chat.revolt.api.routes.onboard.needsOnboarding
import chat.revolt.api.schemas.HealthNotice
import chat.revolt.api.settings.Experiments
import chat.revolt.api.settings.GeoStateProvider
import chat.revolt.api.settings.LoadedSettings
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.composables.generic.HealthAlert
@ -184,6 +186,17 @@ class MainActivityViewModel @Inject constructor(
Experiments.hydrateWithKv()
Log.d("MainActivity", "Performing health check")
doHealthCheck()
Log.d("MainActivity", "Performing update geo state")
updateGeoState()
}
}
private suspend fun updateGeoState() {
try {
Log.d("MainActivity", "Querying geo state")
GeoStateProvider.updateGeoState(queryGeo())
} catch (e: Exception) {
Log.e("MainActivity", "Failed to query geo state", e)
}
}

View File

@ -0,0 +1,35 @@
package chat.revolt.api.routes.microservices.geo
import chat.revolt.api.HitRateLimitException
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.buildUserAgent
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlinx.serialization.Serializable
@Serializable
data class GeoResponse(
val countryCode: String,
val isAgeRestrictedGeo: Boolean,
)
suspend fun queryGeo(): GeoResponse {
try {
val response = RevoltHttp.get("https://geo.revolt.chat/?client=android") {
header("User-Agent", buildUserAgent("Ktor queryGeo"))
}
if (response.status == HttpStatusCode.OK) {
return RevoltJson.decodeFromString(response.bodyAsText())
} else throw Exception("Failed to query geo: ${response.status.value} ${response.status.description}")
} catch (e: Exception) {
throw Exception("Failed to query geo: ${e.message}", e).also {
if (e is HitRateLimitException) {
throw e
}
}
}
}

View File

@ -0,0 +1,18 @@
package chat.revolt.api.settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import chat.revolt.api.routes.microservices.geo.GeoResponse
object GeoStateProvider {
var geoState by mutableStateOf<GeoResponse?>(null)
private set
fun updateGeoState(newGeoState: GeoResponse?) {
checkNotNull(newGeoState) { "You shall not unset this value" }
check(if (geoState?.isAgeRestrictedGeo == true) newGeoState.isAgeRestrictedGeo else true) { "You shall not apply a laxer value" }
geoState = newGeoState
}
}

View File

@ -16,7 +16,7 @@ fun ChannelIcon(channelType: ChannelType, modifier: Modifier = Modifier) {
when (channelType) {
ChannelType.TextChannel -> {
Icon(
painter = painterResource(R.drawable.icn_tag_24dp),
painter = painterResource(R.drawable.icn_grid_3x3_24dp),
contentDescription = stringResource(R.string.channel_text),
modifier = modifier
)

View File

@ -87,6 +87,7 @@ import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.ServerFlags
import chat.revolt.api.schemas.User
import chat.revolt.api.schemas.has
import chat.revolt.api.settings.GeoStateProvider
import chat.revolt.api.settings.NotificationSettingsProvider
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.composables.generic.GroupIcon
@ -914,7 +915,17 @@ fun ChannelItem(
.fillMaxWidth()) {
when (iconType) {
is ChannelItemIconType.Channel -> {
ChannelIcon(iconType.type)
when {
GeoStateProvider.geoState?.isAgeRestrictedGeo == true &&
channel.nsfw == true -> {
Icon(
painter = painterResource(R.drawable.icn_grid_3x3_off_24dp),
contentDescription = stringResource(R.string.geogate_channel_icon_alt),
)
}
else -> ChannelIcon(iconType.type)
}
}
is ChannelItemIconType.Painter -> {

View File

@ -0,0 +1,92 @@
package chat.revolt.composables.vectorassets
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val GeoGateUX: ImageVector
@Composable
get() {
if (_GeoGateUX != null) {
return _GeoGateUX!!
}
_GeoGateUX = ImageVector.Builder(
name = "GeoGate",
defaultWidth = 282.dp,
defaultHeight = 342.dp,
viewportWidth = 282f,
viewportHeight = 342f
).apply {
path(fill = SolidColor(MaterialTheme.colorScheme.onBackground)) {
moveTo(173.19f, 30.41f)
lineTo(207.56f, 26.67f)
lineTo(214.24f, 88.06f)
lineTo(171.27f, 92.73f)
lineTo(167.48f, 80.72f)
lineTo(133.1f, 84.46f)
lineTo(136.83f, 118.76f)
curveTo(148.6f, 118.88f, 160.25f, 121.25f, 171.13f, 125.76f)
curveTo(182.29f, 130.38f, 192.44f, 137.16f, 200.98f, 145.71f)
curveTo(209.53f, 154.25f, 216.31f, 164.4f, 220.93f, 175.56f)
curveTo(225.56f, 186.73f, 227.94f, 198.69f, 227.94f, 210.78f)
curveTo(227.94f, 235.19f, 218.24f, 258.59f, 200.98f, 275.85f)
curveTo(183.72f, 293.11f, 160.32f, 302.8f, 135.91f, 302.8f)
curveTo(123.83f, 302.8f, 111.86f, 300.42f, 100.69f, 295.8f)
curveTo(89.53f, 291.17f, 79.38f, 284.4f, 70.84f, 275.85f)
curveTo(53.58f, 258.59f, 43.88f, 235.19f, 43.88f, 210.78f)
curveTo(43.88f, 186.37f, 53.58f, 162.96f, 70.84f, 145.71f)
curveTo(79.38f, 137.16f, 89.53f, 130.38f, 100.69f, 125.76f)
curveTo(108.32f, 122.6f, 116.32f, 120.49f, 124.48f, 119.47f)
lineTo(114.15f, 24.41f)
lineTo(169.4f, 18.4f)
lineTo(173.19f, 30.41f)
close()
moveTo(64.22f, 194.31f)
curveTo(63.03f, 199.55f, 62.29f, 205.07f, 62.29f, 210.78f)
curveTo(62.29f, 248.03f, 89.92f, 278.76f, 125.86f, 283.64f)
lineTo(126.71f, 265.99f)
curveTo(121.83f, 265.99f, 117.14f, 264.05f, 113.69f, 260.6f)
curveTo(110.24f, 257.15f, 108.3f, 252.47f, 108.3f, 247.59f)
verticalLineTo(238.39f)
lineTo(64.22f, 194.31f)
close()
moveTo(163.52f, 146.36f)
curveTo(163.52f, 151.24f, 161.58f, 155.92f, 158.13f, 159.38f)
curveTo(154.68f, 162.83f, 149.99f, 164.77f, 145.11f, 164.77f)
horizontalLineTo(126.71f)
verticalLineTo(183.17f)
curveTo(126.71f, 185.61f, 125.74f, 187.95f, 124.01f, 189.68f)
curveTo(122.29f, 191.4f, 119.95f, 192.37f, 117.51f, 192.37f)
horizontalLineTo(99.1f)
verticalLineTo(210.78f)
horizontalLineTo(154.32f)
curveTo(156.76f, 210.78f, 159.1f, 211.75f, 160.82f, 213.48f)
curveTo(162.55f, 215.2f, 163.52f, 217.54f, 163.52f, 219.98f)
verticalLineTo(247.59f)
horizontalLineTo(172.72f)
curveTo(180.91f, 247.59f, 187.81f, 253.02f, 190.21f, 260.38f)
curveTo(198.23f, 251.63f, 204.01f, 241.07f, 207.06f, 229.59f)
curveTo(210.1f, 218.12f, 210.33f, 206.07f, 207.7f, 194.5f)
curveTo(205.08f, 182.92f, 199.69f, 172.15f, 191.99f, 163.11f)
curveTo(184.3f, 154.07f, 174.53f, 147.03f, 163.52f, 142.59f)
verticalLineTo(146.36f)
close()
}
path(fill = SolidColor(MaterialTheme.colorScheme.error)) {
moveTo(281.1f, 314.38f)
lineTo(246.16f, 341.74f)
lineTo(0.5f, 28.08f)
lineTo(35.43f, 0.72f)
lineTo(281.1f, 314.38f)
close()
}
}.build()
return _GeoGateUX!!
}
@Suppress("ObjectPropertyName")
private var _GeoGateUX: ImageVector? = null

View File

@ -0,0 +1,81 @@
package chat.revolt.screens.chat.views.channel
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.api.settings.GeoStateProvider
import chat.revolt.composables.vectorassets.GeoGateUX
@Composable
fun ChannelScreenGeoGate(
onAcknowledge: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
) {
Image(
imageVector = GeoGateUX,
contentDescription = null,
modifier = Modifier.size(128.dp),
)
Text(
text = stringResource(R.string.geogate_header),
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Center
),
modifier = Modifier.padding(horizontal = 16.dp)
)
when (GeoStateProvider.geoState?.countryCode) {
"GB" -> {
Text(
text = stringResource(R.string.geogate_description_variant_osa_uk_25),
style = MaterialTheme.typography.bodyMedium.copy(
textAlign = TextAlign.Center
),
modifier = Modifier.padding(horizontal = 16.dp)
)
}
else -> {
Text(
text = stringResource(R.string.geogate_description),
style = MaterialTheme.typography.bodyMedium.copy(
textAlign = TextAlign.Center
),
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
Button(onClick = { onAcknowledge() }) {
Text(stringResource(R.string.geogate_acknowledge))
}
}
}
@Preview(showBackground = true)
@Composable
private fun GeoGatePreview() {
ChannelScreenGeoGate { }
}

View File

@ -43,6 +43,7 @@ import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.routes.user.fetchUser
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message
import chat.revolt.api.settings.GeoStateProvider
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.callbacks.UiCallback
@ -103,6 +104,7 @@ class ChannelScreenViewModel @Inject constructor(
var editingMessage by mutableStateOf<String?>(null)
var ageGateUnlocked by mutableStateOf<Boolean?>(null)
var showGeoGate by mutableStateOf(false)
init {
viewModelScope.launch {
@ -126,6 +128,10 @@ class ChannelScreenViewModel @Inject constructor(
this.denyMessageFieldReasonResource = R.string.typing_blank
this.editingMessage = null
this.ageGateUnlocked = channel?.nsfw != true
this.showGeoGate = when {
channel?.nsfw == true && GeoStateProvider.geoState?.isAgeRestrictedGeo == true -> true
else -> false
}
viewModelScope.launch {
if (ageGateUnlocked != true) {
ageGateUnlocked = AgeGateUnlockedStorageProvider.getAgeGateUnlocked()

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M320,800L320,640L160,640L160,560L320,560L320,400L160,400L160,320L320,320L320,160L400,160L400,320L560,320L560,160L640,160L640,320L800,320L800,400L640,400L640,560L800,560L800,640L640,640L640,800L560,800L560,640L400,640L400,800L320,800ZM400,560L560,560L560,400L400,400L400,560Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M753,640L673,560L800,560L800,640L753,640ZM640,527L433,320L560,320L560,160L640,160L640,320L800,320L800,400L640,400L640,527ZM400,287L320,207L320,160L400,160L400,287ZM791,904L640,753L640,800L560,800L560,673L527,640L400,640L400,800L320,800L320,640L160,640L160,560L320,560L320,433L287,400L160,400L160,320L207,320L56,169L112,112L848,848L791,904Z"/>
</vector>

View File

@ -794,4 +794,10 @@
<string name="keyboard_shortcut_messaging">Messaging</string>
<string name="keyboard_shortcut_messaging_new_line">New Line</string>
<string name="keyboard_shortcut_messaging_send_message">Send Message</string>
<string name="geogate_header">Not available in your region</string>
<string name="geogate_channel_icon_alt">Unavailable channel</string>
<string name="geogate_description">Revolt may block content in certain jurisdictions in response to legislation or legal notices</string>
<string name="geogate_description_variant_osa_uk_25" translatable="false">This channel is not available in your region while we review options on legal compliance.</string>
<string name="geogate_acknowledge">Acknowledge</string>
</resources>