summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2026-03-27 12:23:59 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2026-03-29 23:10:34 +0200
commit952354cb33a69df1a58b1a32858e5143882f72e2 (patch)
treefbb215f393548c3ae69954b5a9be8a055ce92ebc
parent726ce1a3fddf0eb613ef8f8d15fe2a30dbd76c10 (diff)
downloadmullvadvpn-hackday-remote-compose.tar.xz
mullvadvpn-hackday-remote-compose.zip
An attempt was madehackday-remote-compose
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt5
-rw-r--r--android/gradle/libs.versions.toml15
-rw-r--r--android/gradle/verification-metadata.old.xml (renamed from android/gradle/verification-metadata.xml)0
-rw-r--r--android/lib/feature/settings/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/api/FaqRemoteNavKey.kt6
-rw-r--r--android/lib/feature/settings/impl/build.gradle.kts7
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteScreen.kt48
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteViewModel.kt53
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/SettingsScreen.kt25
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/FaqRemoteEntryProvider.kt13
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/SettingsEntryProvider.kt2
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Downloader.kt5
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Parser.kt186
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Server.kt67
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqBlock.kt10
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqItem.kt3
-rw-r--r--android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/RichText.kt10
16 files changed, 451 insertions, 4 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
index 9ce95697d5..c4e288296d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
@@ -52,7 +52,9 @@ import net.mullvad.mullvadvpn.feature.problemreport.impl.viewlogs.ViewLogsViewMo
import net.mullvad.mullvadvpn.feature.redeemvoucher.impl.VoucherDialogViewModel
import net.mullvad.mullvadvpn.feature.serveripoverride.impl.ServerIpOverridesViewModel
import net.mullvad.mullvadvpn.feature.serveripoverride.impl.reset.ResetServerIpOverridesConfirmationViewModel
+import net.mullvad.mullvadvpn.feature.settings.impl.FaqRemoteViewModel
import net.mullvad.mullvadvpn.feature.settings.impl.SettingsViewModel
+import net.mullvad.mullvadvpn.feature.settings.impl.server.Server
import net.mullvad.mullvadvpn.feature.splittunneling.impl.SplitTunnelingViewModel
import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.ApplicationsProvider
import net.mullvad.mullvadvpn.feature.vpnsettings.impl.VpnSettingsViewModel
@@ -255,6 +257,8 @@ val uiModule = module {
single { RelayListScrollConnection() }
+ single { Server(androidContext()) }
+
// View models
viewModel { AccountViewModel(get(), get(), get()) }
viewModel { DeleteAccountConfirmationViewModel(get(), get()) }
@@ -417,6 +421,7 @@ val uiModule = module {
}
viewModel { AppearanceViewModel(get()) }
viewModel { AutoConnectAndLockdownModeViewModel(isPlayBuild = IS_PLAY_BUILD) }
+ viewModel { FaqRemoteViewModel(get()) }
// This view model must be single so we correctly attach lifecycle and share it with activity
single { MullvadAppViewModel(get(), get()) }
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 8d1f6af9fa..fa458b4252 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -4,7 +4,7 @@
# See Bump SDK or container image template in Linear for more details.
compile-sdk = "36"
build-tools = "36.1.0"
-min-sdk = "28"
+min-sdk = "30"
target-sdk = "36"
jvm-toolchain = "21"
ndk = "27.3.13750724"
@@ -75,6 +75,10 @@ turbine = "1.2.1"
annotation-jvm = "1.9.1"
junit-version = "4.13.2"
material = "1.13.0"
+remote-compose = "1.0.0-alpha07"
+jsoup = "1.22.1"
+remote-creation-compose = "1.0.0-alpha06"
+remote-player-compose = "1.0.0-alpha06"
[libraries]
accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist" }
@@ -135,6 +139,7 @@ grpc-kotlin-stub = { module = "io.grpc:grpc-kotlin-stub", version.ref = "grpc-ko
grpc-okhttp = { module = "io.grpc:grpc-okhttp", version.ref = "grpc" }
grpc-protobuf-lite = { module = "io.grpc:grpc-protobuf-lite", version.ref = "grpc" }
grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" }
+jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }
@@ -173,6 +178,14 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
+remote-compose-core = { module = "androidx.compose.remote:remote-core", version.ref = "remote-compose" }
+remote-compose-creation = { module = "androidx.compose.remote:remote-creation", version.ref = "remote-compose" }
+remote-compose-creation-core = { module = "androidx.compose.remote:remote-creation-core", version.ref = "remote-compose" }
+remote-compose-creation-android = { module = "androidx.compose.remote:remote-creation-android", version.ref = "remote-compose" }
+remote-compose-creation-compose = { module = "androidx.compose.remote:remote-creation-compose", version.ref = "remote-compose" }
+remote-compose-player-core = { module = "androidx.compose.remote:remote-player-core", version.ref = "remote-compose" }
+remote-compose-player-compose = { module = "androidx.compose.remote:remote-player-compose", version.ref = "remote-compose" }
+remote-compose-tooling-preview = { module = "androidx.compose.remote:remote-tooling-preview", version.ref = "remote-compose" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
junit = { group = "junit", name = "junit", version.ref = "junit-version" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.old.xml
index c775d4a7c0..c775d4a7c0 100644
--- a/android/gradle/verification-metadata.xml
+++ b/android/gradle/verification-metadata.old.xml
diff --git a/android/lib/feature/settings/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/api/FaqRemoteNavKey.kt b/android/lib/feature/settings/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/api/FaqRemoteNavKey.kt
new file mode 100644
index 0000000000..161add6623
--- /dev/null
+++ b/android/lib/feature/settings/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/api/FaqRemoteNavKey.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.feature.settings.api
+
+import kotlinx.parcelize.Parcelize
+import net.mullvad.mullvadvpn.core.NavKey2
+
+@Parcelize data object FaqRemoteNavKey : NavKey2
diff --git a/android/lib/feature/settings/impl/build.gradle.kts b/android/lib/feature/settings/impl/build.gradle.kts
index a7d325b986..8c9d171e6f 100644
--- a/android/lib/feature/settings/impl/build.gradle.kts
+++ b/android/lib/feature/settings/impl/build.gradle.kts
@@ -25,4 +25,11 @@ dependencies {
implementation(libs.koin.compose)
implementation(libs.arrow)
implementation(libs.protobuf.kotlin.lite)
+ implementation(libs.jsoup)
+ implementation(libs.remote.compose.creation.compose)
+ implementation(libs.remote.compose.player.core)
+ implementation(libs.remote.compose.player.compose)
+ implementation(libs.remote.compose.core)
+ implementation(libs.remote.compose.creation.core)
+ implementation(libs.remote.compose.creation.android)
}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteScreen.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteScreen.kt
new file mode 100644
index 0000000000..2c8bb8cbad
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteScreen.kt
@@ -0,0 +1,48 @@
+package net.mullvad.mullvadvpn.feature.settings.impl
+
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.remote.player.compose.RemoteDocumentPlayer
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.dropUnlessResumed
+import net.mullvad.mullvadvpn.core.Navigator
+import net.mullvad.mullvadvpn.lib.common.Lc
+import net.mullvad.mullvadvpn.lib.ui.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.lib.ui.component.ScaffoldWithSmallTopBar
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadCircularProgressIndicatorLarge
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun FaqRemote(navigator: Navigator) {
+ val viewmodel = koinViewModel<FaqRemoteViewModel>()
+ val state by viewmodel.uiState.collectAsStateWithLifecycle()
+ FaqRemoteScreen(state = state, onBackClick = dropUnlessResumed { navigator.goBack() })
+}
+
+@Composable
+private fun FaqRemoteScreen(state: Lc<Unit, FaqRemoteState>, onBackClick: () -> Unit) {
+ ScaffoldWithSmallTopBar(
+ appBarTitle = stringResource(R.string.faqs_and_guides),
+ navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ ) { modifier ->
+ BoxWithConstraints(modifier = modifier) {
+ val screenWidth = constraints.maxWidth
+ val screenHeight = constraints.maxHeight
+
+ when (state) {
+ is Lc.Loading -> {
+ MullvadCircularProgressIndicatorLarge()
+ }
+ is Lc.Content -> {
+ RemoteDocumentPlayer(
+ document = state.value.document,
+ documentWidth = screenWidth,
+ documentHeight = screenHeight,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteViewModel.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteViewModel.kt
new file mode 100644
index 0000000000..ba25cc4bfc
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/FaqRemoteViewModel.kt
@@ -0,0 +1,53 @@
+package net.mullvad.mullvadvpn.feature.settings.impl
+
+import androidx.compose.remote.core.CoreDocument
+import androidx.compose.remote.creation.RemoteComposeWriter
+import androidx.compose.remote.creation.compose.capture.RemoteComposeCapture
+import androidx.compose.remote.creation.compose.widgets.RemoteComposeWidget
+import androidx.compose.remote.player.core.RemoteDocument
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.WhileSubscribed
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.feature.settings.impl.server.Server
+import net.mullvad.mullvadvpn.lib.common.Lc
+import net.mullvad.mullvadvpn.lib.common.constant.VIEW_MODEL_STOP_TIMEOUT
+
+data class FaqRemoteState(val document: CoreDocument)
+
+class FaqRemoteViewModel(private val server: Server) : ViewModel() {
+
+ private val document = MutableStateFlow<CoreDocument?>(null)
+
+ val uiState =
+ document
+ .filterNotNull()
+ .map { Lc.Content(FaqRemoteState(document = it)) }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(VIEW_MODEL_STOP_TIMEOUT),
+ Lc.Loading(Unit),
+ )
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ val remoteDocument = server.main()
+ //val bytes = remoteDocument.bytes
+
+ //val playerDocument = CoreDocument()
+ Logger.d { "remoteDocument:$remoteDocument" }
+
+ //playerDocument.
+ //document.value = playerDocument
+ //RemoteComposeCapture
+ document.value = RemoteDocument(remoteDocument.bytes).document
+ }
+ }
+}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/SettingsScreen.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/SettingsScreen.kt
index 7d72d706fe..e8cab9953b 100644
--- a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/SettingsScreen.kt
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/SettingsScreen.kt
@@ -30,6 +30,7 @@ import net.mullvad.mullvadvpn.feature.daita.api.DaitaNavKey
import net.mullvad.mullvadvpn.feature.multihop.api.MultihopNavKey
import net.mullvad.mullvadvpn.feature.notification.api.NotificationSettingsNavKey
import net.mullvad.mullvadvpn.feature.problemreport.api.ProblemReportNavKey
+import net.mullvad.mullvadvpn.feature.settings.api.FaqRemoteNavKey
import net.mullvad.mullvadvpn.feature.splittunneling.api.SplitTunnelingNavKey
import net.mullvad.mullvadvpn.feature.vpnsettings.api.VpnSettingsNavKey
import net.mullvad.mullvadvpn.lib.common.Lc
@@ -68,6 +69,8 @@ private fun PreviewSettingsScreen(
onDaitaClick = {},
onBackClick = {},
onNotificationSettingsCellClick = {},
+ onAppObfuscationClick = {},
+ onFaqClick = {}
)
}
}
@@ -91,6 +94,7 @@ fun Settings(navigator: Navigator) {
onNotificationSettingsCellClick =
dropUnlessResumed { navigator.navigate(NotificationSettingsNavKey) },
onAppObfuscationClick = dropUnlessResumed { navigator.navigate(AppearanceNavKey) },
+ onFaqClick = dropUnlessResumed { navigator.navigate(FaqRemoteNavKey) }
)
}
@@ -106,7 +110,8 @@ fun SettingsScreen(
onDaitaClick: () -> Unit,
onBackClick: () -> Unit,
onNotificationSettingsCellClick: () -> Unit,
- onAppObfuscationClick: () -> Unit = {},
+ onAppObfuscationClick: () -> Unit,
+ onFaqClick: () -> Unit,
) {
ScaffoldWithMediumTopBar(
appBarTitle = stringResource(id = R.string.settings),
@@ -135,6 +140,7 @@ fun SettingsScreen(
onDaitaClick = onDaitaClick,
onNotificationSettingsCellClick = onNotificationSettingsCellClick,
onAppObfuscationClick = onAppObfuscationClick,
+ onFaqClick = onFaqClick,
)
}
}
@@ -152,7 +158,8 @@ private fun LazyListScope.content(
onMultihopClick: () -> Unit,
onDaitaClick: () -> Unit,
onNotificationSettingsCellClick: () -> Unit,
- onAppObfuscationClick: () -> Unit = {},
+ onAppObfuscationClick: () -> Unit,
+ onFaqClick: () -> Unit,
) {
if (state.isLoggedIn) {
itemWithDivider {
@@ -208,7 +215,9 @@ private fun LazyListScope.content(
itemWithDivider { ReportProblem(onReportProblemCellClick) }
- if (!state.isPlayBuild) {
+ if (state.isPlayBuild) {
+ itemWithDivider { RemoteFaq(onClick = onFaqClick) }
+ } else {
itemWithDivider { FaqAndGuides() }
}
@@ -260,6 +269,16 @@ private fun FaqAndGuides() {
}
@Composable
+private fun RemoteFaq(onClick: () -> Unit) {
+ val faqGuideLabel = stringResource(id = R.string.faqs_and_guides)
+ NavigationListItem(
+ title = faqGuideLabel,
+ onClick = onClick,
+ position = Position.Middle,
+ )
+}
+
+@Composable
private fun PrivacyPolicy(state: SettingsUiState) {
val privacyPolicyLabel = stringResource(id = R.string.privacy_policy_label)
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/FaqRemoteEntryProvider.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/FaqRemoteEntryProvider.kt
new file mode 100644
index 0000000000..7107573a45
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/FaqRemoteEntryProvider.kt
@@ -0,0 +1,13 @@
+package net.mullvad.mullvadvpn.feature.settings.impl.navigation
+
+import androidx.navigation3.runtime.EntryProviderScope
+import net.mullvad.mullvadvpn.core.NavKey2
+import net.mullvad.mullvadvpn.core.Navigator
+import net.mullvad.mullvadvpn.feature.settings.api.FaqRemoteNavKey
+import net.mullvad.mullvadvpn.feature.settings.impl.FaqRemote
+
+internal fun EntryProviderScope<NavKey2>.faqRemoteEntry(navigator: Navigator) {
+ entry<FaqRemoteNavKey>() {
+ FaqRemote(navigator = navigator)
+ }
+}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/SettingsEntryProvider.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/SettingsEntryProvider.kt
index fe9f5660d5..2fae30f986 100644
--- a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/SettingsEntryProvider.kt
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/navigation/SettingsEntryProvider.kt
@@ -9,4 +9,6 @@ import net.mullvad.mullvadvpn.feature.settings.impl.Settings
fun EntryProviderScope<NavKey2>.settingsEntry(navigator: Navigator) {
entry<SettingsNavKey>(metadata = topLevelTransition()) { Settings(navigator = navigator) }
+
+ faqRemoteEntry(navigator)
}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Downloader.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Downloader.kt
new file mode 100644
index 0000000000..e474b51397
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Downloader.kt
@@ -0,0 +1,5 @@
+package net.mullvad.mullvadvpn.feature.settings.impl.server
+
+import org.jsoup.Jsoup
+
+suspend fun download() = Jsoup.connect("https://mullvad.net/en/help/faq").get()
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Parser.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Parser.kt
new file mode 100644
index 0000000000..50e74c274c
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Parser.kt
@@ -0,0 +1,186 @@
+package net.mullvad.mullvadvpn.feature.settings.impl.server
+
+import kotlin.text.indexOf
+import net.mullvad.mullvadvpn.feature.settings.impl.server.model.FaqBlock
+import net.mullvad.mullvadvpn.feature.settings.impl.server.model.FaqItem
+import net.mullvad.mullvadvpn.feature.settings.impl.server.model.RichText
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import org.jsoup.nodes.TextNode
+
+fun parseFaq(doc: Document): List<FaqBlock.Question> {
+ val article = doc.selectFirst("main article") ?: return emptyList()
+
+ val result = mutableListOf<FaqBlock.Question>()
+
+ var currentTitle: String? = null
+ val currentContent = mutableListOf<FaqBlock.Content>()
+
+ for (el in article.children()) {
+ when (el.tagName()) {
+
+ "h2" -> {
+ // flush previous question
+ if (currentTitle != null) {
+ result += FaqBlock.Question(currentTitle, currentContent.toList())
+ currentContent.clear()
+ }
+ currentTitle = el.text()
+ }
+
+ "p" -> {
+ currentContent += FaqBlock.Content.Paragraph(el.text())
+ }
+
+ "ul" -> {
+ el.select("li").forEach { li ->
+ currentContent += FaqBlock.Content.ListItem(li.text())
+ }
+ }
+ }
+ }
+
+ // flush last item
+ if (currentTitle != null) {
+ result += FaqBlock.Question(currentTitle, currentContent.toList())
+ }
+
+ return result
+}
+
+fun parseFaqFromText(doc: org.jsoup.nodes.Document): List<FaqItem> {
+ val rawText = doc.body().text()
+
+ // Split by question marks + whitespace
+ val parts = rawText.split(Regex("(?<=\\?)(\\s+|$)"))
+
+ val items = mutableListOf<FaqItem>()
+
+ var i = 0
+ while (i < parts.size) {
+ val question = parts[i].trim()
+ val answer = if (i + 1 < parts.size) parts[i + 1].trim() else ""
+ items += FaqItem(question, answer)
+ i += 2
+ }
+
+ return items
+}
+
+fun parseFaqClean(doc: org.jsoup.nodes.Document): List<FaqItem> {
+ val text = doc.body().text()
+
+ // Find where the FAQ really starts
+ val faqStartIndex = Regex("\\?").find(text)?.range?.first ?: 0
+
+ val trimmedText = text.substring(faqStartIndex)
+
+ // Split by question/answer boundaries
+ val parts = trimmedText.split(Regex("(?<=\\?).*?(?=\\S)"))
+
+ val items = mutableListOf<FaqItem>()
+ var i = 0
+ while (i < parts.size - 1) {
+ val question = parts[i].trim()
+ val answer = parts[i + 1].trim()
+ items += FaqItem(question, answer)
+ i += 2
+ }
+ return items
+}
+
+fun parseFaqByHash(doc: Document): List<FaqItem> {
+ val body = doc.body()
+
+ // Collect all text lines
+ val lines = body.text().lines()
+
+ val items = mutableListOf<FaqItem>()
+ var currentQuestion: String? = null
+ val currentAnswer = StringBuilder()
+
+ for (line in lines) {
+ val trimmed = line.trim()
+ if (trimmed.startsWith("#")) {
+ // Save previous question/answer
+ if (currentQuestion != null) {
+ items += FaqItem(currentQuestion, currentAnswer.toString().trim())
+ currentAnswer.clear()
+ }
+ // New question (remove leading #)
+ currentQuestion = trimmed.removePrefix("#").trim()
+ } else {
+ // Append to current answer
+ if (currentQuestion != null) {
+ if (currentAnswer.isNotEmpty()) currentAnswer.append("\n")
+ currentAnswer.append(trimmed)
+ }
+ }
+ }
+
+ // Add the last question
+ if (currentQuestion != null) {
+ items += FaqItem(currentQuestion, currentAnswer.toString().trim())
+ }
+
+ return items
+}
+
+fun parseMullvadFaq(doc: Document): List<FaqItem> {
+ val items = mutableListOf<FaqItem>()
+
+ // 1. Mullvad FAQ questions are contained within <h3> tags
+ val questionHeaders = doc.select("h3")
+
+ for (header in questionHeaders) {
+ val question = header.text().trim()
+ val answerBuilder = StringBuilder()
+
+ // 2. The answer consists of all siblings (p, ul, div)
+ // until we hit the next <h3> or the end of the section.
+ var sibling = header.nextElementSibling()
+
+ while (sibling != null && sibling.tagName() != "h3" && sibling.tagName() != "h2") {
+ // Append the text of the sibling (paragraph, list item, etc.)
+ val text = sibling.text().trim()
+ if (text.isNotEmpty()) {
+ if (answerBuilder.isNotEmpty()) answerBuilder.append("\n\n")
+ answerBuilder.append(text)
+ }
+ sibling = sibling.nextElementSibling()
+ }
+
+ val answer = answerBuilder.toString().trim()
+
+ if (question.isNotEmpty() && answer.isNotEmpty()) {
+ items.add(FaqItem(question, answer))
+ }
+ }
+
+ return items
+}
+
+fun parseParagraph(el: Element): FaqBlock.Content.Paragraph {
+ val parts = mutableListOf<RichText.Part>()
+
+ el.childNodes().forEach { node ->
+ when (node) {
+ is TextNode -> parts += RichText.Part.Text(node.text())
+ is Element -> {
+ if (node.tagName() == "a") {
+ parts += RichText.Part.Link(
+ node.text(),
+ node.absUrl("href")
+ )
+ }
+ }
+ }
+ }
+
+ return FaqBlock.Content.Paragraph(parts.joinToString("") {
+ when (it) {
+ is RichText.Part.Text -> it.text
+ is RichText.Part.Link -> it.text // simple fallback
+ }
+ })
+}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Server.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Server.kt
new file mode 100644
index 0000000000..472928af71
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/Server.kt
@@ -0,0 +1,67 @@
+package net.mullvad.mullvadvpn.feature.settings.impl.server
+
+import android.content.Context
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.remote.creation.compose.capture.CapturedDocument
+import androidx.compose.remote.creation.compose.capture.captureSingleRemoteDocument
+import androidx.compose.remote.creation.compose.layout.RemoteArrangement
+import androidx.compose.remote.creation.compose.layout.RemoteColumn
+import androidx.compose.remote.creation.compose.layout.RemoteText
+import androidx.compose.remote.creation.compose.state.RemoteDp
+import androidx.compose.remote.creation.compose.state.asRemoteTextUnit
+import androidx.compose.remote.creation.compose.state.rc
+import androidx.compose.remote.creation.compose.text.RemoteTextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import co.touchlab.kermit.Logger
+import net.mullvad.mullvadvpn.feature.settings.impl.server.model.FaqItem
+
+class Server(private val context: Context) {
+ suspend fun main(): CapturedDocument {
+ val document = download()
+ val data = parseFaqClean(document)
+ val captured = captureSingleRemoteDocument(context = context) { RemoteFaq(faqItems = data) }
+ return captured
+ }
+}
+
+@Composable
+fun RemoteFaq(faqItems: List<FaqItem>) {
+ RemoteColumn(verticalArrangement = RemoteArrangement.spacedBy(RemoteDp(10.dp))) {
+ RemoteText(
+ "These are FAQs",
+ color = MaterialTheme.colorScheme.onPrimary.rc,
+ fontSize = MaterialTheme.typography.titleLarge.fontSize.asRemoteTextUnit(),
+ )
+ Logger.d { "questions=$faqItems" }
+ faqItems.forEach { q ->
+ RemoteText(
+ text = "Section",
+ style = RemoteTextStyle.Default,
+ color = MaterialTheme.colorScheme.onPrimary.rc,
+ )
+
+ RemoteText(
+ text = q.question,
+ style = RemoteTextStyle.Default,
+ color = MaterialTheme.colorScheme.onPrimary.rc,
+ )
+
+ RemoteText(
+ text = q.answer,
+ style = RemoteTextStyle.Default,
+ color = MaterialTheme.colorScheme.onPrimary.rc,
+ )
+
+ /*q.content.forEach { c ->
+ when (c) {
+ is FaqBlock.Content.Paragraph ->
+ RemoteText(c.text, color = MaterialTheme.colorScheme.onPrimary.rc)
+
+ is FaqBlock.Content.ListItem ->
+ RemoteText("• ${c.text}", color = MaterialTheme.colorScheme.onPrimary.rc)
+ }
+ }*/
+ }
+ }
+}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqBlock.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqBlock.kt
new file mode 100644
index 0000000000..f71fa328bf
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqBlock.kt
@@ -0,0 +1,10 @@
+package net.mullvad.mullvadvpn.feature.settings.impl.server.model
+
+sealed class FaqBlock {
+ data class Question(val title: String, val content: List<Content>) : FaqBlock()
+
+ sealed class Content {
+ data class Paragraph(val text: String) : Content()
+ data class ListItem(val text: String) : Content()
+ }
+}
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqItem.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqItem.kt
new file mode 100644
index 0000000000..65aeac5529
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/FaqItem.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.feature.settings.impl.server.model
+
+data class FaqItem(val question: String, val answer: String)
diff --git a/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/RichText.kt b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/RichText.kt
new file mode 100644
index 0000000000..aa73ea99f2
--- /dev/null
+++ b/android/lib/feature/settings/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/settings/impl/server/model/RichText.kt
@@ -0,0 +1,10 @@
+package net.mullvad.mullvadvpn.feature.settings.impl.server.model
+
+data class RichText(
+ val parts: List<Part>
+) {
+ sealed class Part {
+ data class Text(val text: String) : Part()
+ data class Link(val text: String, val url: String) : Part()
+ }
+}