Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,15 @@ class LabController(private val context: Context) {
}
}

fun start(packs: List<DomainPack>, options: List<StrategyOption>) {
/** Tests [options] on the probe hosts of [packs] plus [domains] typed in by hand. */
fun start(packs: List<DomainPack>, domains: List<String>, options: List<StrategyOption>) {
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)
Expand Down
75 changes: 75 additions & 0 deletions app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/DomainInput.kt
Original file line number Diff line number Diff line change
@@ -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<String>,
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),
)
},
)
}
}
}
}
}

43 changes: 36 additions & 7 deletions app/src/main/kotlin/io/github/halilkhrmn/dpimech/ui/LabScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,16 +58,24 @@ fun LabScreen(
initialPacks: List<String>,
targetProfile: String?,
onDetectIsp: () -> Unit,
onStart: (List<DomainPack>, List<StrategyOption>) -> Unit,
onStart: (packs: List<DomainPack>, domains: List<String>, strategies: List<StrategyOption>) -> Unit,
onCancel: () -> Unit,
onUse: (result: LabResult, packs: List<String>, turnOn: Boolean) -> Unit,
onUse: (result: LabResult, packs: List<String>, domains: List<String>, 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<String> = 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 }

Expand Down Expand Up @@ -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) {
Expand All @@ -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)) }
}
Expand Down Expand Up @@ -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) })
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
)
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -273,12 +275,14 @@ class MainActivity : AppCompatActivity() {
}
}

private fun useLabResult(result: LabResult, packs: List<String>, start: Boolean, networkKey: String?, onEdit: (String) -> Unit) {
private fun useLabResult(result: LabResult, packs: List<String>, domains: List<String>, 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)
Expand All @@ -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 },
Expand All @@ -306,24 +311,29 @@ 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 },
)
}
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<DomainPack>, domains: List<String>): 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<String>,
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)
Expand Down
Loading
Loading