diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2026-03-27 12:23:59 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2026-03-29 23:10:34 +0200 |
| commit | 952354cb33a69df1a58b1a32858e5143882f72e2 (patch) | |
| tree | fbb215f393548c3ae69954b5a9be8a055ce92ebc /android/lib/feature/settings/impl | |
| parent | 726ce1a3fddf0eb613ef8f8d15fe2a30dbd76c10 (diff) | |
| download | mullvadvpn-hackday-remote-compose.tar.xz mullvadvpn-hackday-remote-compose.zip | |
An attempt was madehackday-remote-compose
Diffstat (limited to 'android/lib/feature/settings/impl')
12 files changed, 426 insertions, 3 deletions
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() + } +} |
