diff --git a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/data/LabController.kt b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/data/LabController.kt index 177ace8..d31e13a 100644 --- a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/data/LabController.kt +++ b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/data/LabController.kt @@ -82,14 +82,15 @@ class LabController(private val context: Context) { } } - fun start(packs: List, options: List) { + /** Tests [options] on the probe hosts of [packs] plus [domains] typed in by hand. */ + fun start(packs: List, domains: List, options: List) { if (state.value.running) return cancelled = false val strategies = IspLookup.markRecommended( options.map { LabStrategy(it.entry.name, it.entry.args, it.source, it.origin) }, state.value.isp?.known, ) - val probes = packs.flatMap { it.probes }.distinct() + val probes = (packs.flatMap { it.probes } + domains).distinct() state.update { LabState(running = true, isp = it.isp) } // Keeps the process (and the test) alive while the user is in another app. io.github.halilkhrmn.dpimech.lab.LabService.start(context) diff --git a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/DomainInput.kt b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/DomainInput.kt new file mode 100644 index 0000000..2a34572 --- /dev/null +++ b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/DomainInput.kt @@ -0,0 +1,75 @@ +package io.github.halilkhrmn.dpimech.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.github.halilkhrmn.dpimech.R +import io.github.halilkhrmn.dpimech.core.Hostlist + +/** Extra domains as removable pills, with a field that adds what is typed or pasted. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DomainPills( + domains: List, + input: String, + onInput: (String) -> Unit, + onAdd: () -> Unit, + onRemove: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + value = input, onValueChange = onInput, + label = { Text(stringResource(R.string.profile_extra_domains)) }, + supportingText = { Text(stringResource(R.string.profile_extra_domains_hint)) }, + singleLine = true, + // No autocorrect: it adds spaces after dots and "fixes" domain names. + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done, autoCorrectEnabled = false), + keyboardActions = KeyboardActions(onDone = { onAdd() }), + trailingIcon = { + IconButton(onClick = onAdd, enabled = Hostlist.parseInput(input).isNotEmpty()) { + Icon(Icons.Default.Add, stringResource(R.string.profile_domain_add)) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + if (domains.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + domains.forEach { d -> + InputChip( + selected = true, + onClick = { onRemove(d) }, + label = { Text(d) }, + trailingIcon = { + Icon( + Icons.Default.Close, + stringResource(R.string.profile_domain_remove, d), + Modifier.size(InputChipDefaults.AvatarSize), + ) + }, + ) + } + } + } + } +} + diff --git a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/LabScreen.kt b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/LabScreen.kt index 7a36c6e..bcfa56c 100644 --- a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/LabScreen.kt +++ b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/LabScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import io.github.halilkhrmn.dpimech.R import io.github.halilkhrmn.dpimech.core.DnsCheck import io.github.halilkhrmn.dpimech.core.DomainPack +import io.github.halilkhrmn.dpimech.core.Hostlist import io.github.halilkhrmn.dpimech.core.LabResult import io.github.halilkhrmn.dpimech.data.LabState import io.github.halilkhrmn.dpimech.data.QuickState @@ -57,16 +58,24 @@ fun LabScreen( initialPacks: List, targetProfile: String?, onDetectIsp: () -> Unit, - onStart: (List, List) -> Unit, + onStart: (packs: List, domains: List, strategies: List) -> Unit, onCancel: () -> Unit, - onUse: (result: LabResult, packs: List, turnOn: Boolean) -> Unit, + onUse: (result: LabResult, packs: List, domains: List, turnOn: Boolean) -> Unit, onBack: (() -> Unit)?, bottomPadding: PaddingValues = PaddingValues(), quick: QuickState = QuickState(), onQuickCheck: ((String) -> Unit)? = null, + /** Sites typed in by hand (the profile's extra domains), tested with the chosen packs. */ + initialDomains: List = emptyList(), ) { LaunchedEffect(Unit) { onDetectIsp() } - var packs by rememberSaveable { mutableStateOf(initialPacks.ifEmpty { listOf("discord") }) } + // Packs are optional: sites typed in by hand can be tested alone. + var packs by rememberSaveable { + mutableStateOf(initialPacks.ifEmpty { if (initialDomains.isEmpty()) listOf("discord") else emptyList() }) + } + var domains by rememberSaveable { mutableStateOf(initialDomains) } + var domainInput by rememberSaveable { mutableStateOf("") } + val allDomains = (domains + Hostlist.parseInput(domainInput)).distinct() var community by rememberSaveable { mutableStateOf(false) } val chosen = options.filter { community || it.source == LabResult.STANDARD_SET } @@ -110,6 +119,22 @@ fun LabScreen( ) } } + if (!state.running) { + DomainPills( + domains = domains, + input = domainInput, + onInput = { text -> + val (done, left) = Hostlist.splitTyped(text) + domains = (domains + done).distinct() + domainInput = left + }, + onAdd = { + domains = allDomains + domainInput = "" + }, + onRemove = { d -> domains = domains - d }, + ) + } } item { Row(verticalAlignment = Alignment.CenterVertically) { @@ -130,8 +155,12 @@ fun LabScreen( Text(stringResource(R.string.lab_progress, state.done, state.total), style = MaterialTheme.typography.bodySmall) } else { Button( - enabled = packs.isNotEmpty() && chosen.isNotEmpty(), - onClick = { onStart(DomainPack.ALL.filter { it.id in packs }, chosen) }, + enabled = (packs.isNotEmpty() || allDomains.isNotEmpty()) && chosen.isNotEmpty(), + onClick = { + domains = allDomains + domainInput = "" + onStart(DomainPack.ALL.filter { it.id in packs }, allDomains, chosen) + }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.lab_start)) } } @@ -177,14 +206,14 @@ fun LabScreen( style = MaterialTheme.typography.titleMedium, ) if (best != null) { - Button(onClick = { onUse(best, packs, true) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + Button(onClick = { onUse(best, packs, domains, true) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { Text(stringResource(R.string.lab_use_and_turn_on)) } } } } items(state.results, key = { r -> r.strategy!!.let { it.name + it.args } }) { r -> - ResultCard(r, targetProfile, enabled = !state.running, onUse = { onUse(r, packs, false) }) + ResultCard(r, targetProfile, enabled = !state.running, onUse = { onUse(r, packs, domains, false) }) } } } diff --git a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/MainActivity.kt b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/MainActivity.kt index e797806..f4857e5 100644 --- a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/MainActivity.kt +++ b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/MainActivity.kt @@ -123,9 +123,10 @@ class MainActivity : AppCompatActivity() { lab = lab, country = network?.country ?: lab.isp?.country ?: simCountry(), onDetectIsp = app.lab::detectIsp, - onTest = { packs, community -> + onTest = { packs, domains, community -> app.lab.start( DomainPack.ALL.filter { it.id in packs }, + domains, strategies.filter { community || it.source == LabResult.STANDARD_SET }, ) }, @@ -225,11 +226,12 @@ class MainActivity : AppCompatActivity() { state = lab, options = strategies, initialPacks = saved.selected?.packs.orEmpty(), + initialDomains = saved.selected?.extraDomains.orEmpty(), targetProfile = saved.selected?.name, onDetectIsp = app.lab::detectIsp, onStart = app.lab::start, onCancel = app.lab::cancel, - onUse = { result, packs, start -> useLabResult(result, packs, start, network?.networkKey ?: lab.isp?.networkKey, onEdit) }, + onUse = { result, packs, domains, start -> useLabResult(result, packs, domains, start, network?.networkKey ?: lab.isp?.networkKey, onEdit) }, onBack = null, bottomPadding = padding, quick = quick, @@ -273,12 +275,14 @@ class MainActivity : AppCompatActivity() { } } - private fun useLabResult(result: LabResult, packs: List, start: Boolean, networkKey: String?, onEdit: (String) -> Unit) { + private fun useLabResult(result: LabResult, packs: List, domains: List, start: Boolean, networkKey: String?, onEdit: (String) -> Unit) { val s = result.strategy!! val entry = StrategyEntry(s.name, s.args) val current = app.profiles.saved.value.selected if (current != null) { + // Sites typed in the Lab join the profile, so it covers what was tested. val updated = current.withStrategy(entry, networkKey) + .copy(extraDomains = (current.extraDomains + domains).distinct()) app.profiles.save(updated) // A start on a running service replaces its engine, so no stop first. if (start || EngineState.flow.value is EngineState.Running) turnOn(updated) @@ -289,8 +293,9 @@ class MainActivity : AppCompatActivity() { val chosen = DomainPack.ALL.filter { it.id in packs } val p = Profile( id = UUID.randomUUID().toString(), - name = chosen.joinToString(" + ") { it.displayName(packLanguage()) }, + name = profileName(chosen, domains), packs = chosen.map { it.id }, + extraDomains = domains, strategy = entry, perNetwork = networkKey?.let { mapOf(it to entry) }.orEmpty(), apps = chosen.flatMap { it.packages }.filter { it in installed }, @@ -306,13 +311,15 @@ class MainActivity : AppCompatActivity() { val id = UUID.randomUUID().toString() val base = if (choice.wholePhone) { CountryPreset(country.orEmpty(), choice.packs).profile(id, getString(R.string.wizard_profile_whole_phone), entry) + .copy(extraDomains = choice.domains) } else { val installed = InstalledApps.packageNames(packageManager) val chosen = DomainPack.ALL.filter { it.id in choice.packs } Profile( id = id, - name = chosen.joinToString(" + ") { it.displayName(packLanguage()) }, + name = profileName(chosen, choice.domains), packs = choice.packs, + extraDomains = choice.domains, strategy = entry, apps = chosen.flatMap { it.packages }.filter { it in installed }, ) @@ -320,10 +327,13 @@ class MainActivity : AppCompatActivity() { return if (choice.best != null) base.withStrategy(entry, networkKey) else base } - /** The SIM's country when nothing better is known (no permission needed). */ - /** The app's language, for the site pack names in new profiles. */ - private fun packLanguage(): String = resources.configuration.locales[0].language + /** "Discord + YouTube", or the typed sites when no pack was chosen. */ + private fun profileName(packs: List, domains: List): String { + val language = resources.configuration.locales[0].language + return (packs.map { it.displayName(language) }.ifEmpty { domains.take(2) }).joinToString(" + ") + } + /** The SIM's country when nothing better is known (no permission needed). */ private fun simCountry(): String? = runCatching { getSystemService(android.telephony.TelephonyManager::class.java)?.simCountryIso?.uppercase()?.ifEmpty { null } }.getOrNull() diff --git a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/ProfileEditor.kt b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/ProfileEditor.kt index 4d8fd24..96413a4 100644 --- a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/ProfileEditor.kt +++ b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/ProfileEditor.kt @@ -14,21 +14,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.InputChip -import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -53,8 +47,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.github.halilkhrmn.dpimech.R import androidx.compose.ui.platform.LocalConfiguration @@ -150,13 +142,9 @@ fun ProfileEditor( domains = domains, input = domainInput, onInput = { text -> - // A separator (space, comma, new line) turns what was typed into pills. - if (text.any { it in " ,;\n\t" }) { - domains = (domains + Hostlist.parseInput(text)).distinct() - domainInput = "" - } else { - domainInput = text - } + val (done, left) = Hostlist.splitTyped(text) + domains = (domains + done).distinct() + domainInput = left }, onAdd = { domains = (domains + Hostlist.parseInput(domainInput)).distinct() @@ -241,52 +229,6 @@ fun ProfileEditor( } } -/** Extra domains as removable pills, with a field that adds what is typed or pasted. */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun DomainPills( - domains: List, - input: String, - onInput: (String) -> Unit, - onAdd: () -> Unit, - onRemove: (String) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - OutlinedTextField( - value = input, onValueChange = onInput, - label = { Text(stringResource(R.string.profile_extra_domains)) }, - supportingText = { Text(stringResource(R.string.profile_extra_domains_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { onAdd() }), - trailingIcon = { - IconButton(onClick = onAdd, enabled = Hostlist.parseInput(input).isNotEmpty()) { - Icon(Icons.Default.Add, stringResource(R.string.profile_domain_add)) - } - }, - modifier = Modifier.fillMaxWidth(), - ) - if (domains.isNotEmpty()) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - domains.forEach { d -> - InputChip( - selected = true, - onClick = { onRemove(d) }, - label = { Text(d) }, - trailingIcon = { - Icon( - Icons.Default.Close, - stringResource(R.string.profile_domain_remove, d), - Modifier.size(InputChipDefaults.AvatarSize), - ) - }, - ) - } - } - } - } -} - @Composable private fun Section(title: String) { Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) diff --git a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/WizardScreen.kt b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/WizardScreen.kt index 3a1b1e5..b4a4457 100644 --- a/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/WizardScreen.kt +++ b/app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/WizardScreen.kt @@ -44,12 +44,20 @@ import androidx.compose.ui.unit.dp import io.github.halilkhrmn.dpimech.R import io.github.halilkhrmn.dpimech.core.CountryPreset import io.github.halilkhrmn.dpimech.core.DomainPack +import io.github.halilkhrmn.dpimech.core.Hostlist import io.github.halilkhrmn.dpimech.core.LabResult import io.github.halilkhrmn.dpimech.data.LabState import java.util.Locale /** What the wizard produces; the activity turns it into a profile. */ -data class WizardChoice(val wholePhone: Boolean, val packs: List, val best: LabResult?, val turnOn: Boolean) +data class WizardChoice( + val wholePhone: Boolean, + val packs: List, + val best: LabResult?, + val turnOn: Boolean, + /** Sites typed in by hand. */ + val domains: List = emptyList(), +) /** * First-start wizard: welcome → where (whole phone with the country's commonly blocked sites, @@ -61,7 +69,7 @@ fun WizardScreen( lab: LabState, country: String?, onDetectIsp: () -> Unit, - onTest: (packs: List, community: Boolean) -> Unit, + onTest: (packs: List, domains: List, community: Boolean) -> Unit, onCancelTest: () -> Unit, onDone: (WizardChoice) -> Unit, onSkip: () -> Unit, @@ -74,6 +82,9 @@ fun WizardScreen( val locale = LocalConfiguration.current.locales[0] val preset = CountryPreset.forCountry(country ?: locale.country) var packs by rememberSaveable(preset.country) { mutableStateOf(preset.packs) } + var domains by rememberSaveable { mutableStateOf(emptyList()) } + var domainInput by rememberSaveable { mutableStateOf("") } + val allDomains = (domains + Hostlist.parseInput(domainInput)).distinct() // Named only when the country has its own list; otherwise the text would promise too much. val countryName = preset.country.takeIf { it.isNotEmpty() } ?.let { Locale("", it).getDisplayCountry(locale) } @@ -120,18 +131,37 @@ fun WizardScreen( FilterChip(selected = on, onClick = { packs = if (on) packs - p.id else packs + p.id }, label = { Text(p.label()) }) } } + DomainPills( + domains = domains, + input = domainInput, + onInput = { text -> + val (done, left) = Hostlist.splitTyped(text) + domains = (domains + done).distinct() + domainInput = left + }, + onAdd = { + domains = allDomains + domainInput = "" + }, + onRemove = { d -> domains = domains - d }, + ) Button( - enabled = packs.isNotEmpty(), - onClick = { step = 2; onTest(packs, false) }, + enabled = packs.isNotEmpty() || allDomains.isNotEmpty(), + onClick = { + domains = allDomains + domainInput = "" + step = 2 + onTest(packs, allDomains, false) + }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.wizard_test)) } TextButton(onClick = { step = 0 }) { Text(stringResource(R.string.back)) } } else -> TestStep( lab = lab, - onRetryCommunity = { onTest(packs, true) }, + onRetryCommunity = { onTest(packs, domains, true) }, onCancel = { onCancelTest(); step = 1 }, - onDone = { best, turnOn -> onDone(WizardChoice(wholePhone, packs, best, turnOn)) }, + onDone = { best, turnOn -> onDone(WizardChoice(wholePhone, packs, best, turnOn, domains)) }, ) } } diff --git a/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/ScreenshotTest.kt b/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/ScreenshotTest.kt index 31e35b1..3b93ae1 100644 --- a/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/ScreenshotTest.kt @@ -50,7 +50,7 @@ class ScreenshotTest { HomeScreen(saved, EngineState.Running("1", "Engelli siteler", "TLS record split"), {}, {}, {}, {}, {}, PaddingValues(), stats = stats) } - @Test @Config(qualifiers = "+tr") fun wizardTurkish() = shot("wizard_tr") { WizardScreen(LabState(), "TR", {}, { _, _ -> }, {}, {}, {}) } + @Test @Config(qualifiers = "+tr") fun wizardTurkish() = shot("wizard_tr") { WizardScreen(LabState(), "TR", {}, { _, _, _ -> }, {}, {}, {}) } @Test @Config(qualifiers = "+night") fun homeDark() = shot("home_dark") { HomeScreen(saved, EngineState.Running("1", "Blocked sites", "TLS record split"), {}, {}, {}, {}, {}, PaddingValues(), stats = stats) @@ -102,7 +102,7 @@ class ScreenshotTest { @Test fun labQuickCheck() = shot("lab_quick") { LabScreen( - LabState(), emptyList(), listOf("discord"), "Blocked sites", {}, { _, _ -> }, {}, { _, _, _ -> }, null, + LabState(), emptyList(), listOf("discord"), "Blocked sites", {}, { _, _, _ -> }, {}, { _, _, _, _ -> }, null, quick = io.github.halilkhrmn.dpimech.data.QuickState( result = io.github.halilkhrmn.dpimech.core.QuickCheck.Result("discord.com", "TLS record split", null, 142), ), @@ -116,7 +116,7 @@ class ScreenshotTest { @Test fun about() = shot("about") { AboutScreen("0.1.0", {}, {}, PaddingValues()) } - @Test fun wizardWelcome() = shot("wizard_welcome") { WizardScreen(LabState(), "TR", {}, { _, _ -> }, {}, {}, {}) } + @Test fun wizardWelcome() = shot("wizard_welcome") { WizardScreen(LabState(), "TR", {}, { _, _, _ -> }, {}, {}, {}) } @Test fun logs() = shot("logs") { LogsScreen( @@ -151,6 +151,6 @@ class ScreenshotTest { LabResult(LabStrategy("Fake + TTL", "-f1 -t6 -n {sni}", std), 0, 8, 0, error = "ByeDPI stopped at start (exit 1)"), ), ) - shot("lab") { LabScreen(state, emptyList(), listOf("discord"), "Blocked sites", {}, { _, _ -> }, {}, { _, _, _ -> }, null) } + shot("lab") { LabScreen(state, emptyList(), listOf("discord"), "Blocked sites", {}, { _, _, _ -> }, {}, { _, _, _, _ -> }, null) } } } diff --git a/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/StoreScreenshotTest.kt b/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/StoreScreenshotTest.kt index b798229..e732c33 100644 --- a/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/StoreScreenshotTest.kt +++ b/app/src/test/kotlin/io/github/halilkhrmn/dpimech/ui/StoreScreenshotTest.kt @@ -98,7 +98,7 @@ class StoreScreenshotTest(private val locale: String) { } @Test fun wizard() = shot(2) { - WizardScreen(LabState(), country, {}, { _, _ -> }, {}, {}, {}, initialStep = 1) + WizardScreen(LabState(), country, {}, { _, _, _ -> }, {}, {}, {}, initialStep = 1) } @Test fun lab() = shot(3) { @@ -112,7 +112,7 @@ class StoreScreenshotTest(private val locale: String) { LabResult(LabStrategy("Disorder SNI", "-d1 -s1+s", std), 6, 8, 180, listOf("roblox.com")), ), ) - WithBar(1) { p -> LabScreen(state, standardOptions(), listOf("discord", "roblox"), str(R.string.wizard_profile_whole_phone), {}, { _, _ -> }, {}, { _, _, _ -> }, null, p) } + WithBar(1) { p -> LabScreen(state, standardOptions(), listOf("discord", "roblox"), str(R.string.wizard_profile_whole_phone), {}, { _, _, _ -> }, {}, { _, _, _, _ -> }, null, p) } } @Test fun settings() = shot(4) { diff --git a/core/src/main/kotlin/io/github/halilkhrmn/dpimech/core/Profile.kt b/core/src/main/kotlin/io/github/halilkhrmn/dpimech/core/Profile.kt index db29084..0e2fe73 100644 --- a/core/src/main/kotlin/io/github/halilkhrmn/dpimech/core/Profile.kt +++ b/core/src/main/kotlin/io/github/halilkhrmn/dpimech/core/Profile.kt @@ -89,10 +89,38 @@ object Hostlist { * entries are dropped. */ fun parseInput(text: String): List = - text.split('\n', ',', ' ', '\t', ';') - .map { it.substringAfter("://").substringBefore('/').substringBefore('?').substringBefore(':') } - .mapNotNull(::normalize) - .distinct() + text.split(*SEPARATORS).mapNotNull(::fromTyped).distinct() + + /** + * What a domain field does with text as it is typed: entries that are finished and followed + * by a separator become pills, the rest stays in the field. Some keyboards put a space after + * every "." (Samsung's address keyboard), so "example. " is not finished: it stays in the + * field as "example." and the user goes on typing. + */ + fun splitTyped(text: String): Pair, String> { + if (text.none { it in SEPARATOR_CHARS }) return emptyList() to text + val parts = text.split(*SEPARATORS) + val done = mutableListOf() + val pending = StringBuilder() + for (part in parts.dropLast(1)) { + val host = fromTyped(part) + when { + part.trimEnd().endsWith('.') -> pending.append(part.trim()) + host != null -> done += host + part.isNotBlank() -> pending.append(part.trim()).append(' ') + } + } + return done.distinct() to pending.append(parts.last()).toString() + } + + /** A typed or pasted entry (a domain or an address like `https://host:443/path`) as a hostname. */ + private fun fromTyped(entry: String): String? = + normalize(entry.trim().substringAfter("://").substringBefore('/').substringBefore('?').substringBefore(':')) + // A typed name needs a dot: "example" alone is a typo, not a site. + ?.takeIf { '.' in it } + + private const val SEPARATOR_CHARS = "\n, \t;" + private val SEPARATORS = SEPARATOR_CHARS.map { it.toString() }.toTypedArray() /** File body for ciadpi `--hosts `: one hostname per line. */ fun body(domains: List): String = diff --git a/core/src/test/kotlin/io/github/halilkhrmn/dpimech/core/HostlistInputTest.kt b/core/src/test/kotlin/io/github/halilkhrmn/dpimech/core/HostlistInputTest.kt index 495d582..b48d372 100644 --- a/core/src/test/kotlin/io/github/halilkhrmn/dpimech/core/HostlistInputTest.kt +++ b/core/src/test/kotlin/io/github/halilkhrmn/dpimech/core/HostlistInputTest.kt @@ -17,4 +17,17 @@ class HostlistInputTest { assertEquals(listOf("ok.net"), Hostlist.parseInput("bad_name ok.net .. -x.com")) assertEquals(emptyList(), Hostlist.parseInput(" ")) } + + @Test + fun typingOnlyMakesPillsOfFinishedNames() { + // Keyboards that add a space after "." must not cut "example." off. + assertEquals(emptyList() to "example.", Hostlist.splitTyped("example. ")) + assertEquals(emptyList() to "example.com", Hostlist.splitTyped("example.com")) + assertEquals(listOf("example.com") to "", Hostlist.splitTyped("example.com ")) + assertEquals(listOf("a.com", "www.b.org") to "c", Hostlist.splitTyped("a.com, https://www.b.org/x?y=1 c")) + // A word without a dot is not a site. + assertEquals(emptyList() to "example x", Hostlist.splitTyped("example x")) + assertEquals(emptyList(), Hostlist.parseInput("localhost")) + } } + diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d00f769..4a21133 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -51,6 +51,19 @@ ## Work log +### 2026-10-02 — Typed sites: no early pills, testable in the Lab +- Done: the domain field made a pill as soon as a dot was typed, because some keyboards (Samsung's + address keyboard) put a space after "."; now only finished names (a dot inside, not at the end) + become pills, a name without a dot is not a site, and autocorrect is off (`Hostlist.splitTyped`). + The pill field (`DomainInput.kt`) is also in the Strategy Lab and the wizard: packs are optional, + sites typed by hand are tested alone or with packs, and join the profile the result is used for + (new profiles are named after them when no pack was chosen). +- F-Droid review (fdroiddata!50840): builds must name the full commit hash, not the tag; the draft + in `docs/fdroid/` now does, and has the 0.2.0 build (commit e904087, the `v0.2.0` tag). +- Verified: `HostlistInputTest` (space after a dot, URLs, words without a dot); app tests, lint + unchanged; Lab and wizard renders checked. +- Next: phone test of typing domains on the Samsung keyboard. + ### 2026-10-02 — v0.2.0 prepared - Done: versionCode 2, versionName 0.2.0; `changelogs/2.txt` in all five store languages (start on boot / always-on VPN, quick check, profile backup, next profile from the notification, weekly diff --git a/docs/fdroid/io.github.halilkhrmn.dpimech.yml b/docs/fdroid/io.github.halilkhrmn.dpimech.yml index 0de2394..f87df9a 100644 --- a/docs/fdroid/io.github.halilkhrmn.dpimech.yml +++ b/docs/fdroid/io.github.halilkhrmn.dpimech.yml @@ -22,7 +22,19 @@ Binaries: Builds: - versionName: 0.1.0 versionCode: 1 - commit: v0.1.0 + commit: aeb82d0285e995618e40f37f9e77c6f139dfc5ec + subdir: app + submodules: true + gradle: + - yes + output: build/outputs/apk/release/app-universal-release-unsigned.apk + rm: + - native/hev-socks5-tunnel/third-part/wintun + ndk: 29.0.14206865 + + - versionName: 0.2.0 + versionCode: 2 + commit: e9040874e57bcace7b9ea4ffb163ad649629a172 subdir: app submodules: true gradle: @@ -36,5 +48,5 @@ AllowedAPKSigningKeys: dd5e5e13f10e026a494c95c7e23a0b8fca475af71c710d690069a3249 AutoUpdateMode: Version UpdateCheckMode: Tags ^v[0-9.]+$ -CurrentVersion: 0.1.0 -CurrentVersionCode: 1 +CurrentVersion: 0.2.0 +CurrentVersionCode: 2 diff --git a/fastlane/metadata/android/ar/images/phoneScreenshots/2.png b/fastlane/metadata/android/ar/images/phoneScreenshots/2.png index 6a7e946..b907b7f 100644 Binary files a/fastlane/metadata/android/ar/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ar/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/ar/images/phoneScreenshots/3.png b/fastlane/metadata/android/ar/images/phoneScreenshots/3.png index b62ca04..380f2af 100644 Binary files a/fastlane/metadata/android/ar/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ar/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index 94b8dd1..84623fe 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index b11e137..2a3e3ee 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/2.png b/fastlane/metadata/android/fa/images/phoneScreenshots/2.png index d6ad369..1b3dfa5 100644 Binary files a/fastlane/metadata/android/fa/images/phoneScreenshots/2.png and b/fastlane/metadata/android/fa/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/3.png b/fastlane/metadata/android/fa/images/phoneScreenshots/3.png index 1f7f75c..ac94e8e 100644 Binary files a/fastlane/metadata/android/fa/images/phoneScreenshots/3.png and b/fastlane/metadata/android/fa/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/2.png b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png index 33b7d46..3a3525b 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/2.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/ru/images/phoneScreenshots/3.png b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png index 61eec99..a831af3 100644 Binary files a/fastlane/metadata/android/ru/images/phoneScreenshots/3.png and b/fastlane/metadata/android/ru/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/2.png b/fastlane/metadata/android/tr/images/phoneScreenshots/2.png index 2e24d03..64deb53 100644 Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/2.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/tr/images/phoneScreenshots/3.png b/fastlane/metadata/android/tr/images/phoneScreenshots/3.png index 18fdaac..437026f 100644 Binary files a/fastlane/metadata/android/tr/images/phoneScreenshots/3.png and b/fastlane/metadata/android/tr/images/phoneScreenshots/3.png differ