Merge pull request #124 from amir1376/feature/proxy

Add proxy support
This commit is contained in:
AmirHossein Abdolmotallebi 2024-10-21 10:59:10 +03:30 committed by GitHub
commit 22b51fc2c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1023 additions and 120 deletions

View File

@ -24,7 +24,7 @@ import ir.amirab.downloader.utils.IDiskStat
import ir.amirab.util.startup.Startup
import com.abdownloadmanager.integration.Integration
import ir.amirab.downloader.DownloadManager
import ir.amirab.util.config.datastore.createMyConfigPreferences
import ir.amirab.util.config.datastore.createMapConfigDatastore
import kotlinx.coroutines.*
import kotlinx.serialization.json.Json
import okhttp3.Dispatcher
@ -39,8 +39,13 @@ import com.abdownloadmanager.utils.FileIconProvider
import com.abdownloadmanager.utils.FileIconProviderUsingCategoryIcons
import com.abdownloadmanager.utils.category.*
import com.abdownloadmanager.utils.compose.IMyIcons
import com.abdownloadmanager.utils.proxy.IProxyStorage
import com.abdownloadmanager.utils.proxy.ProxyData
import com.abdownloadmanager.utils.proxy.ProxyManager
import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider
import ir.amirab.downloader.monitor.IDownloadMonitor
import ir.amirab.downloader.utils.EmptyFileCreator
import ir.amirab.util.config.datastore.kotlinxSerializationDataStore
val downloaderModule = module {
single<IDownloadQueueDatabase> {
@ -84,6 +89,11 @@ val downloaderModule = module {
8,
)
}
single {
ProxyManager(
get()
)
}.bind<ProxyStrategyProvider>()
single<DownloaderClient> {
OkHttpDownloaderClient(
OkHttpClient
@ -92,9 +102,9 @@ val downloaderModule = module {
//bypass limit on concurrent connections!
maxRequests = Int.MAX_VALUE
maxRequestsPerHost = Int.MAX_VALUE
}).build()
}).build(),
get()
)
}
single {
val downloadSettings: DownloadSettings = get()
@ -215,9 +225,18 @@ val appModule = module {
single {
MyIcons
}.bind<IMyIcons>()
single {
ProxyDatastoreStorage(
kotlinxSerializationDataStore(
AppInfo.optionsDir.resolve("proxySettings.json"),
get(),
ProxyData::default,
)
)
}.bind<IProxyStorage>()
single {
AppSettingsStorage(
createMyConfigPreferences(
createMapConfigDatastore(
AppInfo.configDir.resolve("appSettings.json"),
get(),
)
@ -225,7 +244,7 @@ val appModule = module {
}
single {
PageStatesStorage(
createMyConfigPreferences(
createMapConfigDatastore(
AppInfo.configDir.resolve("pageStatesStorage.json"),
get(),
)

View File

@ -223,7 +223,7 @@ private fun WildcardLengthUi(
verticalAlignment = Alignment.CenterVertically
) {
Multiselect(
selections = WildcardSelect.entries,
selections = entries,
selectedItem = WildcardSelect.fromWildcardLength(wildcardLength),
onSelectionChange = {
onChangeWildcardLength(
@ -258,48 +258,6 @@ private fun WildcardLengthUi(
}
}
@Composable
private fun <T> Multiselect(
selections: List<T>,
selectedItem: T,
onSelectionChange: (T) -> Unit,
render: @Composable (T) -> Unit,
) {
val shape = RoundedCornerShape(6.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(shape)
.background(myColors.surface)
) {
for (item in selections) {
val isSelected = item == selectedItem
Box(
Modifier
.padding(vertical = 4.dp, horizontal = 4.dp)
.clip(shape)
.ifThen(isSelected) {
background(LocalContentColor.current / 10)
}
.clickable {
onSelectionChange(item)
}
.padding(vertical = 2.dp, horizontal = 4.dp)
) {
WithContentAlpha(
if (isSelected) {
1f
} else {
0.5f
}
) {
render(item)
}
}
}
}
}
@Composable
private fun LabeledContent(
label: @Composable () -> Unit,

View File

@ -11,6 +11,8 @@ import com.abdownloadmanager.desktop.utils.convertSpeedToHumanReadable
import com.abdownloadmanager.desktop.utils.mvi.ContainsEffects
import com.abdownloadmanager.desktop.utils.mvi.supportEffects
import androidx.compose.runtime.*
import com.abdownloadmanager.utils.proxy.ProxyManager
import com.abdownloadmanager.utils.proxy.ProxyMode
import com.arkivanov.decompose.ComponentContext
import ir.amirab.util.osfileutil.FileUtils
import ir.amirab.util.flow.createMutableStateFlowFromStateFlow
@ -133,6 +135,28 @@ fun defaultDownloadFolderConfig(appSettings: AppSettingsStorage): FolderConfigur
)
}
fun proxyConfig(proxyManager: ProxyManager, scope: CoroutineScope): ProxyConfigurable {
return ProxyConfigurable(
title = "Use Proxy",
description = "Use proxy for downloading files",
backedBy = proxyManager.proxyData,
validate = {
true
},
describe = {
val str = when (it.proxyMode) {
ProxyMode.Direct -> "No proxy"
ProxyMode.UseSystem -> "System proxy"
ProxyMode.Manual -> it.proxyWithRules.proxy.run {
"$type $host:$port"
}
}
"$str will be used"
}
)
}
/*
fun uiScaleConfig(appSettings: AppSettings): EnumConfigurable<Float?> {
return EnumConfigurable(
@ -270,6 +294,7 @@ class SettingsComponent(
ContainsEffects<SettingPageEffects> by supportEffects() {
val appSettings by inject<AppSettingsStorage>()
val appRepository by inject<AppRepository>()
val proxyManager by inject<ProxyManager>()
val themeManager by inject<ThemeManager>()
val allConfigs = object : SettingSectionGetter {
override operator fun get(key: SettingSections): List<Configurable<*>> {
@ -290,6 +315,7 @@ class SettingsComponent(
DownloadEngine -> listOf(
defaultDownloadFolderConfig(appSettings),
proxyConfig(proxyManager, scope),
useAverageSpeedConfig(appRepository),
speedLimitConfig(appRepository),
threadCountConfig(appRepository),

View File

@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color
import com.abdownloadmanager.desktop.pages.settings.ThemeInfo
import com.abdownloadmanager.desktop.pages.settings.configurable.BooleanConfigurable.RenderMode
import com.abdownloadmanager.desktop.ui.theme.MyColors
import com.abdownloadmanager.utils.proxy.ProxyData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -298,3 +299,21 @@ class DayOfWeekConfigurable(
enabled = enabled,
visible = visible,
)
class ProxyConfigurable(
title: String,
description: String,
backedBy: MutableStateFlow<ProxyData>,
describe: (ProxyData) -> String,
validate: (ProxyData) -> Boolean,
enabled: StateFlow<Boolean> = DefaultEnabledValue,
visible: StateFlow<Boolean> = DefaultVisibleValue,
) : Configurable<ProxyData>(
title = title,
description = description,
backedBy = backedBy,
describe = describe,
validate = validate,
enabled = enabled,
visible = visible,
)

View File

@ -0,0 +1,427 @@
package com.abdownloadmanager.desktop.pages.settings.configurable.widgets
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.abdownloadmanager.desktop.pages.settings.configurable.ProxyConfigurable
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.theme.myTextSizes
import com.abdownloadmanager.desktop.ui.widget.*
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.utils.compose.LocalContentColor
import com.abdownloadmanager.utils.compose.widget.Icon
import com.abdownloadmanager.utils.compose.widget.MyIcon
import com.abdownloadmanager.utils.proxy.ProxyMode
import com.abdownloadmanager.utils.proxy.ProxyRules
import com.abdownloadmanager.utils.proxy.ProxyWithRules
import ir.amirab.downloader.connection.proxy.Proxy
import ir.amirab.downloader.connection.proxy.ProxyType
import ir.amirab.util.desktop.DesktopUtils
@Composable
fun RenderProxyConfig(cfg: ProxyConfigurable, modifier: Modifier) {
val value by cfg.stateFlow.collectAsState()
val setValue = cfg::set
val enabled = isConfigEnabled()
ConfigTemplate(
modifier = modifier,
title = {
TitleAndDescription(cfg, true)
},
value = {
RenderSpinner(
enabled = enabled,
possibleValues = ProxyMode.usableValues(),
value = value.proxyMode,
onSelect = {
setValue(
value.copy(
proxyMode = it
)
)
},
modifier = Modifier.widthIn(min = 120.dp),
render = {
val text = when (it) {
ProxyMode.Direct -> "No Proxy"
ProxyMode.UseSystem -> "Use System Proxy"
ProxyMode.Manual -> "Manual Proxy"
}
Text(text)
},
)
},
nestedContent = {
AnimatedContent(value.proxyMode.takeIf { enabled }) {
when (it) {
ProxyMode.Direct -> {}
ProxyMode.UseSystem -> {
ActionButton(
"Open System Proxy Settings",
onClick = {
DesktopUtils.openSystemProxySettings()
},
)
}
ProxyMode.Manual -> {
RenderManualProxyConfig(
proxyWithRules = value.proxyWithRules,
setProxyWithRules = {
setValue(
value.copy(
proxyWithRules = it
)
)
}
)
}
null -> {}
}
}
}
)
}
@Stable
private class ProxyEditState(
private val proxyWithRules: ProxyWithRules,
private val setProxyWithRules: (ProxyWithRules) -> Unit,
) {
var proxyType = mutableStateOf(proxyWithRules.proxy.type)
var proxyHost = mutableStateOf(proxyWithRules.proxy.host)
var proxyPort = mutableStateOf(proxyWithRules.proxy.port)
var useAuth = mutableStateOf(proxyWithRules.proxy.username != null)
var proxyUsername = mutableStateOf(proxyWithRules.proxy.username.orEmpty())
var proxyPassword = mutableStateOf(proxyWithRules.proxy.password.orEmpty())
var excludeURLPatterns = mutableStateOf(proxyWithRules.rules.excludeURLPatterns.joinToString(" "))
val canSave: Boolean by derivedStateOf {
val hostValid = proxyHost.value.isNotBlank()
hostValid
}
fun save() {
val useAuth = useAuth.value
if (!canSave) {
return
}
setProxyWithRules(
proxyWithRules.copy(
proxy = Proxy(
type = proxyType.value,
host = proxyHost.value.trim(),
port = proxyPort.value,
username = proxyUsername.value.takeIf { it.isNotEmpty() && useAuth },
password = proxyPassword.value.takeIf { it.isNotEmpty() && useAuth },
),
rules = ProxyRules(
excludeURLPatterns = excludeURLPatterns.value
.split(" ")
.map { it.trim() }
.filterNot { it.isEmpty() },
)
)
)
}
}
@Composable
fun RenderManualProxyConfig(
proxyWithRules: ProxyWithRules,
setProxyWithRules: (ProxyWithRules) -> Unit,
) {
var showManualProxyConfig by remember {
mutableStateOf(false)
}
ActionButton(
"Change proxy",
onClick = {
showManualProxyConfig = true
},
)
if (showManualProxyConfig) {
val dismiss = {
showManualProxyConfig = false
}
val state = remember(setProxyWithRules) {
ProxyEditState(
proxyWithRules = proxyWithRules,
setProxyWithRules = {
setProxyWithRules(it)
dismiss()
}
)
}
ProxyEditDialog(state, onDismiss = dismiss)
}
}
@Composable
private fun ProxyEditDialog(
state: ProxyEditState,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = (onDismiss),
content = {
val (type, setType) = state.proxyType
val (host, setHost) = state.proxyHost
val (port, setPort) = state.proxyPort
val (useAuth, setUseAuth) = state.useAuth
val (username, setUsername) = state.proxyUsername
val (password, setPassword) = state.proxyPassword
val (excludeURLPatterns, setExcludeURLPatterns) = state.excludeURLPatterns
SettingsDialog(
headerTitle = "Edit Proxy",
onDismiss = onDismiss,
content = {
Column(
Modifier
.verticalScroll(rememberScrollState())
) {
val spacer = @Composable {
Spacer(Modifier.height(8.dp))
}
DialogConfigItem(
modifier = Modifier,
title = {
Text("Type")
},
value = {
Multiselect(
selections = ProxyType.entries.toList(),
selectedItem = type,
onSelectionChange = setType,
modifier = Modifier,
render = {
Text(
it.name,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp),
)
},
selectedColor = LocalContentColor.current / 15,
unselectedAlpha = 0.8f,
)
}
)
spacer()
DialogConfigItem(
modifier = Modifier,
title = {
Text("Address & Port")
},
value = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
MyTextField(
text = host,
onTextChange = setHost,
placeholder = "127.0.0.1",
modifier = Modifier.weight(1f),
)
Text(":", Modifier.padding(horizontal = 8.dp))
IntTextField(
value = port,
onValueChange = setPort,
placeholder = "Port",
range = 1..65535,
modifier = Modifier.width(96.dp),
keyboardOptions = KeyboardOptions(),
textPadding = PaddingValues(8.dp),
shape = RoundedCornerShape(12.dp),
)
}
}
)
spacer()
DialogConfigItem(
modifier = Modifier,
title = {
Row(
modifier = Modifier.onClick {
setUseAuth(!useAuth)
}
) {
CheckBox(
value = useAuth,
onValueChange = setUseAuth,
size = 16.dp
)
Spacer(Modifier.width(8.dp))
Text("Use Authentication")
}
},
value = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
MyTextField(
text = username,
onTextChange = setUsername,
placeholder = "Username",
modifier = Modifier.weight(1f),
enabled = useAuth,
)
Spacer(Modifier.width(8.dp))
MyTextField(
text = password,
onTextChange = setPassword,
placeholder = "Password",
modifier = Modifier.weight(1f),
enabled = useAuth,
)
}
}
)
spacer()
DialogConfigItem(
modifier = Modifier,
title = {
Row {
Text("Don't Use proxy for")
Spacer(Modifier.width(8.dp))
Help(
"A list of urls that may not be proxied\nYou can use wildcard with *\nfor example 192.168.1.* example.com (space separated)"
)
}
},
value = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
MyTextField(
text = excludeURLPatterns,
onTextChange = setExcludeURLPatterns,
placeholder = "example.com 192.168.1.*",
modifier = Modifier,
)
}
}
)
}
},
actions = {
ActionButton(
"Save",
enabled = state.canSave,
onClick = {
state.save()
})
Spacer(Modifier.width(8.dp))
ActionButton("Cancel", onClick = {
onDismiss()
})
}
)
}
)
}
@Composable
private fun SettingsDialog(
headerTitle: String,
onDismiss: () -> Unit,
content: @Composable () -> Unit,
actions: (@Composable RowScope.() -> Unit)? = null,
) {
val shape = RoundedCornerShape(6.dp)
Column(
modifier = Modifier
.clip(shape)
.border(2.dp, myColors.onBackground / 10, shape)
.background(
Brush.linearGradient(
listOf(
myColors.surface,
myColors.background,
)
)
)
.padding(16.dp)
.width(450.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
headerTitle,
fontSize = myTextSizes.lg,
fontWeight = FontWeight.Bold,
)
MyIcon(
MyIcons.windowClose,
"Close",
Modifier
.clip(CircleShape)
.clickable { onDismiss() }
.padding(12.dp)
.size(12.dp),
)
}
Spacer(Modifier.height(8.dp))
content()
actions?.let {
Spacer(Modifier.height(8.dp))
Row(
Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
actions()
}
}
}
}
@Composable
private fun DialogConfigItem(
modifier: Modifier,
title: @Composable ColumnScope.() -> Unit,
value: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier,
) {
Column(
Modifier
.height(IntrinsicSize.Max),
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
title()
}
Spacer(Modifier.height(8.dp))
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End,
) {
value()
}
}
}
}

View File

@ -48,6 +48,7 @@ fun RenderConfigurable(
}
is DayOfWeekConfigurable -> RenderDayOfWeekConfigurable(cfg,modifier)
is ProxyConfigurable -> RenderProxyConfig(cfg, modifier)
}
}
}

View File

@ -6,6 +6,7 @@ import com.abdownloadmanager.desktop.utils.DownloadSystem
import ir.amirab.downloader.DownloadSettings
import com.abdownloadmanager.integration.Integration
import com.abdownloadmanager.integration.IntegrationResult
import com.abdownloadmanager.utils.proxy.ProxyManager
import ir.amirab.downloader.DownloadManager
import ir.amirab.downloader.monitor.IDownloadMonitor
import kotlinx.coroutines.CoroutineScope
@ -19,6 +20,7 @@ import org.koin.core.component.inject
class AppRepository : KoinComponent {
private val scope: CoroutineScope by inject()
private val appSettings: AppSettingsStorage by inject()
private val proxyManager: ProxyManager by inject()
val theme = appSettings.theme
// val uiScale = appSettings.uiScale
@ -38,6 +40,7 @@ class AppRepository : KoinComponent {
val integrationEnabled = appSettings.browserIntegrationEnabled
val integrationPort = appSettings.browserIntegrationPort
init {
//maybe its better to move this to another place
appSettings.autoStartOnBoot

View File

@ -96,7 +96,7 @@ data class AppSettingsModel(
class AppSettingsStorage(
settings: DataStore<MapConfig>,
) : ConfigBaseSettings<AppSettingsModel>(settings, AppSettingsModel.ConfigLens) {
) : ConfigBaseSettingsByMapConfig<AppSettingsModel>(settings, AppSettingsModel.ConfigLens) {
var theme = from(AppSettingsModel.theme)
var mergeTopBarWithTitleBar = from(AppSettingsModel.mergeTopBarWithTitleBar)
val threadCount = from(AppSettingsModel.threadCount)

View File

@ -84,7 +84,7 @@ data class PageStatesModel(
class PageStatesStorage(
settings: DataStore<MapConfig>,
) : ConfigBaseSettings<PageStatesModel>(settings, PageStatesModel.ConfigLens) {
) : ConfigBaseSettingsByMapConfig<PageStatesModel>(settings, PageStatesModel.ConfigLens) {
val lastUsedSaveLocations = from(PageStatesModel.global.lastSavedLocations)
val downloadPage = from(PageStatesModel.downloadPage)
val homePageStorage = from(PageStatesModel.home)

View File

@ -0,0 +1,14 @@
package com.abdownloadmanager.desktop.storage
import androidx.datastore.core.DataStore
import com.abdownloadmanager.desktop.utils.ConfigBaseSettingsByJson
import com.abdownloadmanager.utils.proxy.IProxyStorage
import com.abdownloadmanager.utils.proxy.ProxyData
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class ProxyDatastoreStorage(
dataStore: DataStore<ProxyData>,
) : IProxyStorage, ConfigBaseSettingsByJson<ProxyData>(dataStore) {
override val proxyDataFlow = data
}

View File

@ -0,0 +1,66 @@
package com.abdownloadmanager.desktop.ui.widget
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.util.ifThen
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.utils.compose.LocalContentColor
import com.abdownloadmanager.utils.compose.WithContentAlpha
@Composable
fun <T> Multiselect(
selections: List<T>,
selectedItem: T,
onSelectionChange: (T) -> Unit,
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(6.dp),
backgroundColour: Color = myColors.surface,
selectedColor: Color = LocalContentColor.current / 10,
unselectedAlpha: Float = 0.5f,
render: @Composable (T) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(shape)
.background(backgroundColour)
) {
for (item in selections) {
val isSelected = item == selectedItem
Box(
Modifier
.padding(vertical = 4.dp, horizontal = 4.dp)
.clip(shape)
.ifThen(isSelected) {
background(selectedColor)
}
.clickable {
onSelectionChange(item)
}
.padding(vertical = 2.dp, horizontal = 4.dp)
) {
WithContentAlpha(
if (isSelected) {
1f
} else {
unselectedAlpha
}
) {
render(item)
}
}
}
}
}

View File

@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
@ -33,6 +34,8 @@ fun IntTextField(
keyboardOptions: KeyboardOptions,
prettify: (Int) -> String = { it.toString() },
placeholder: String = "",
textPadding: PaddingValues = PaddingValues(4.dp),
shape: Shape = RectangleShape,
) {
NumberTextField(
value = value,
@ -53,6 +56,8 @@ fun IntTextField(
keyboardOptions = keyboardOptions,
interactionSource = interactionSource,
placeholder = placeholder,
textPadding = textPadding,
shape = shape,
)
}
@ -103,7 +108,7 @@ fun DoubleTextField(
},
enabled: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
placeholder: String = ""
placeholder: String = "",
) {
NumberTextField(
value = value,
@ -177,10 +182,11 @@ fun <T : Comparable<T>> NumberTextField(
keyboardOptions: KeyboardOptions,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
placeholder: String = "",
textPadding: PaddingValues = PaddingValues(4.dp),
shape: Shape = RectangleShape,
) {
val value by rememberUpdatedState(value)
val shape = RectangleShape
val isFocused by interactionSource.collectIsFocusedAsState()
var haveWrongValue by remember(value) {
mutableStateOf(false)
@ -203,7 +209,7 @@ fun <T : Comparable<T>> NumberTextField(
myText = prettify(value)
}
}
fun set(v:T,prettify: Boolean):Boolean{
fun set(v: T, prettify: Boolean): Boolean {
val isInRange = v in range
val valueInRange = if (isInRange) v else v.coerceIn(range)
lastEmittedValueByMe = if (prettify || !isInRange) {
@ -224,7 +230,7 @@ fun <T : Comparable<T>> NumberTextField(
}
}
MyTextField(
textPadding = PaddingValues(4.dp),
textPadding = textPadding,
shape = shape,
modifier = modifier.onKeyEvent {
when (it.key) {
@ -258,7 +264,7 @@ fun <T : Comparable<T>> NumberTextField(
myText = it
} else {
val wasInRange = set(v, false)
if (wasInRange){
if (wasInRange) {
myText = it
}
}

View File

@ -35,6 +35,7 @@ fun AppInfo.isInDebugMode(): Boolean {
}
val AppInfo.configDir: File get() = File(AppProperties.getConfigDirectory())
val AppInfo.optionsDir: File get() = AppInfo.configDir.resolve("options")
val AppInfo.downloadDbDir:File get() = AppInfo.configDir.resolve("download_db")
fun AppInfo.extensions(){

View File

@ -10,34 +10,69 @@ import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
abstract class ConfigBaseSettings<T>(
dataStore: DataStore<MapConfig>,
lens: Lens<MapConfig, T>
) : KoinComponent {
private val scope: CoroutineScope by inject()
private val lastFileState = dataStore.data.let {
runBlocking { it.stateIn(scope) }
abstract class BaseStorage<T> : KoinComponent {
val scope: CoroutineScope by inject()
protected abstract val inMemoryState: MutableStateFlow<T>
protected abstract suspend fun saveData(data: T)
val data get() = inMemoryState
fun <K> from(lens: Lens<T, K>): MutableStateFlow<K> {
return inMemoryState.mapTwoWayStateFlow(lens)
}
private val inMemoryState = MutableStateFlow(
lens.get(lastFileState.value)
)
init {
/**
* call this on upper implementations where [inMemoryState] and [saveData] are implemented
*/
protected fun startPersistData() {
inMemoryState
//first
.drop(1)
.debounce(500)
.onEach { s ->
dataStore.updateData {
val newData = lens.set(MapConfig(), s)
newData
}
saveData(s)
}.launchIn(scope)
}
}
fun <K> from(lens: Lens<T, K>): MutableStateFlow<K> {
return inMemoryState
.mapTwoWayStateFlow(lens)
abstract class ConfigBaseSettingsByMapConfig<T>(
private val dataStore: DataStore<MapConfig>,
private val lens: Lens<MapConfig, T>,
) : BaseStorage<T>(), KoinComponent {
private val lastFileState = dataStore.data.let {
runBlocking { it.stateIn(scope) }
}
override val inMemoryState = MutableStateFlow(
lens.get(lastFileState.value)
)
override suspend fun saveData(data: T) {
dataStore.updateData {
val newData = lens.set(MapConfig(), data)
newData
}
}
init {
startPersistData()
}
}
abstract class ConfigBaseSettingsByJson<T>(
private val dataStore: DataStore<T>,
) : BaseStorage<T>(), KoinComponent {
private val lastFileState = dataStore.data.let {
runBlocking { it.stateIn(scope) }
}
override val inMemoryState = MutableStateFlow(lastFileState.value)
override suspend fun saveData(data: T) {
dataStore.updateData { data }
}
init {
startPersistData()
}
}

View File

@ -2,3 +2,8 @@ plugins{
id(MyPlugins.kotlin)
id(MyPlugins.composeDesktop)
}
dependencies {
implementation(project(":shared:app-utils"))
implementation(project(":shared:utils"))
}

View File

@ -0,0 +1,23 @@
package ir.amirab.util.desktop
import ir.amirab.util.desktop.linux.LinuxUtils
import ir.amirab.util.desktop.mac.MacOSUtils
import ir.amirab.util.desktop.windows.WindowsUtils
import ir.amirab.util.platform.Platform
interface DesktopUtils {
fun openSystemProxySettings()
companion object : DesktopUtils by getDesktopUtilOfCurrentOS()
}
private fun getDesktopUtilOfCurrentOS(): DesktopUtils {
val platform = Platform.getCurrentPlatform() as Platform.Desktop
return when (platform) {
Platform.Desktop.Windows -> WindowsUtils()
Platform.Desktop.MacOS -> MacOSUtils()
Platform.Desktop.Linux -> LinuxUtils()
}
}

View File

@ -0,0 +1,31 @@
package ir.amirab.util.desktop.linux
import ir.amirab.util.desktop.DesktopUtils
import ir.amirab.util.execAndWait
class LinuxUtils : DesktopUtils {
override fun openSystemProxySettings() {
val desktopEnv = System.getenv("XDG_CURRENT_DESKTOP")
when {
desktopEnv?.contains("GNOME") ?: false -> {
execAndWait(
arrayOf(
"gnome-control-center network"
)
)
}
desktopEnv?.contains("KDE") ?: false -> {
execAndWait(
arrayOf(
"systemsettings5 proxy"
)
)
}
else -> {
println("Can't open System Proxy Settings: Unsupported desktop environment: $desktopEnv")
}
}
}
}

View File

@ -0,0 +1,12 @@
package ir.amirab.util.desktop.mac
import ir.amirab.util.desktop.DesktopUtils
import ir.amirab.util.execAndWait
class MacOSUtils : DesktopUtils {
override fun openSystemProxySettings() {
execAndWait(
arrayOf("open /System/Library/PreferencePanes/Network.prefPane")
)
}
}

View File

@ -0,0 +1,20 @@
package ir.amirab.util.desktop.windows
import ir.amirab.util.desktop.DesktopUtils
import ir.amirab.util.execAndWait
class WindowsUtils : DesktopUtils {
override fun openSystemProxySettings() {
val result = execAndWait(
arrayOf(
"cmd", "/c", "start",
"ms-settings:network-proxy",
)
)
if (!result) {
arrayOf(
"rundll32.exe shell32.dll,Control_RunDLL inetcpl.cpl,,4"
)
}
}
}

View File

@ -1,12 +1,19 @@
package ir.amirab.downloader.connection
import ir.amirab.downloader.connection.proxy.ProxyStrategy
import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider
import ir.amirab.downloader.connection.proxy.ProxyType
import ir.amirab.downloader.connection.response.ResponseInfo
import ir.amirab.downloader.downloaditem.IDownloadCredentials
import ir.amirab.downloader.utils.await
import okhttp3.*
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
class OkHttpDownloaderClient(
private val okHttpClient: OkHttpClient,
private val proxyStrategyProvider: ProxyStrategyProvider,
) : DownloaderClient() {
private fun newCall(
downloadCredentials: IDownloadCredentials,
@ -15,38 +22,84 @@ class OkHttpDownloaderClient(
extraBuilder: Request.Builder.() -> Unit,
): Call {
val rangeHeader = createRangeHeader(start, end)
return okHttpClient.newCall(
Request.Builder()
.url(downloadCredentials.link)
.apply {
defaultHeadersInFirst().forEach { (k, v) ->
header(k, v)
}
downloadCredentials.headers
?.filter {
//OkHttp handles this header and if we override it,
//makes redirected links to have this "Host" instead of their own!, and cause error
!it.key.equals("Host", true)
}
?.forEach { (k, v) ->
return okHttpClient
.applyProxy(downloadCredentials)
.newCall(
Request.Builder()
.url(downloadCredentials.link)
.apply {
defaultHeadersInFirst().forEach { (k, v) ->
header(k, v)
}
defaultHeadersInLast().forEach { (k, v) ->
header(k, v)
downloadCredentials.headers
?.filter {
//OkHttp handles this header and if we override it,
//makes redirected links to have this "Host" instead of their own!, and cause error
!it.key.equals("Host", true)
}
?.forEach { (k, v) ->
header(k, v)
}
defaultHeadersInLast().forEach { (k, v) ->
header(k, v)
}
val username = downloadCredentials.username
val password = downloadCredentials.password
if (username?.isNotBlank() == true && password?.isNotBlank() == true) {
header("Authorization", Credentials.basic(username, password))
}
downloadCredentials.userAgent?.let { userAgent ->
header("User-Agent", userAgent)
}
}
val username = downloadCredentials.username
val password = downloadCredentials.password
if (username?.isNotBlank() == true && password?.isNotBlank() == true) {
header("Authorization", Credentials.basic(username, password))
}
downloadCredentials.userAgent?.let { userAgent ->
header("User-Agent", userAgent)
}
}
.apply(extraBuilder)
.header(rangeHeader.first, rangeHeader.second)
.build()
)
.apply(extraBuilder)
.header(rangeHeader.first, rangeHeader.second)
.build()
)
}
private fun OkHttpClient.applyProxy(
downloadCredentials: IDownloadCredentials,
): OkHttpClient {
return when (
val strategy = proxyStrategyProvider.getProxyStrategyFor(downloadCredentials.link)
) {
ProxyStrategy.Direct -> return this
ProxyStrategy.UseSystem -> {
newBuilder()
.proxySelector(ProxySelector.getDefault())
.build()
}
is ProxyStrategy.ManualProxy -> {
val proxy = strategy.proxy
return newBuilder()
.proxy(
Proxy(
when (proxy.type) {
ProxyType.HTTP -> Proxy.Type.HTTP
ProxyType.SOCKS -> Proxy.Type.SOCKS
},
InetSocketAddress(proxy.host, proxy.port)
)
).let {
if (proxy.username != null && proxy.type == ProxyType.HTTP) {
it.proxyAuthenticator { _, r ->
val credentials = Credentials.basic(
proxy.username,
proxy.password.orEmpty()
)
r.request
.newBuilder()
.header("Proxy-Authorization", credentials)
.build()
}
} else {
it
}
}.build()
}
}
}

View File

@ -0,0 +1,22 @@
package ir.amirab.downloader.connection.proxy
import kotlinx.serialization.Serializable
@Serializable
data class Proxy(
val type: ProxyType,
val host: String,
val port: Int,
val username: String?,
val password: String?,
) {
companion object {
fun default() = Proxy(
type = ProxyType.HTTP,
host = "127.0.0.1",
port = 2080,
username = null,
password = null,
)
}
}

View File

@ -0,0 +1,7 @@
package ir.amirab.downloader.connection.proxy
sealed interface ProxyStrategy {
data object Direct : ProxyStrategy
data object UseSystem : ProxyStrategy
data class ManualProxy(val proxy: Proxy) : ProxyStrategy
}

View File

@ -0,0 +1,5 @@
package ir.amirab.downloader.connection.proxy
interface ProxyStrategyProvider {
fun getProxyStrategyFor(url: String): ProxyStrategy
}

View File

@ -0,0 +1,11 @@
package ir.amirab.downloader.connection.proxy
import kotlinx.serialization.SerialName
enum class ProxyType {
@SerialName("http")
HTTP,
@SerialName("socks")
SOCKS;
}

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import ir.amirab.util.compose.IconSource
import ir.amirab.util.compose.fromUri
import ir.amirab.util.wildcardMatch
import kotlinx.serialization.Serializable
/**
@ -47,25 +48,14 @@ data class Category(
return true
}
return acceptedUrlPatterns.any {
test(
patten = it,
wildcardMatch(
pattern = it,
input = url
)
}
}
}
private fun test(
patten: String,
input: String,
): Boolean {
return patten
.split("*")
.joinToString(".*") { Regex.escape(it) }
.toRegex()
.containsMatchIn(input)
}
fun Category.iconSource(): IconSource? {
return IconSource.fromUri(icon)
}

View File

@ -0,0 +1,7 @@
package com.abdownloadmanager.utils.proxy
import kotlinx.coroutines.flow.MutableStateFlow
interface IProxyStorage {
val proxyDataFlow: MutableStateFlow<ProxyData>
}

View File

@ -0,0 +1,55 @@
package com.abdownloadmanager.utils.proxy
import ir.amirab.downloader.connection.proxy.Proxy
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ProxyRules(
val excludeURLPatterns: List<String>,
)
@Serializable
data class ProxyWithRules(
val proxy: Proxy,
val rules: ProxyRules,
)
enum class ProxyMode {
@SerialName("direct")
Direct,
@SerialName("system")
UseSystem,
@SerialName("manual")
Manual;
companion object {
fun usableValues(): List<ProxyMode> {
// UseSystem not works as expected
// so we filter it for now.
return listOf(
Direct,
Manual,
)
}
}
}
// for persisting in storage
@Serializable
data class ProxyData(
val proxyMode: ProxyMode,
val proxyWithRules: ProxyWithRules,
) {
companion object {
fun default() = ProxyData(
proxyMode = ProxyMode.Direct,
proxyWithRules = ProxyWithRules(
proxy = Proxy.default(),
rules = ProxyRules(emptyList())
)
)
}
}

View File

@ -0,0 +1,72 @@
package com.abdownloadmanager.utils.proxy
import ir.amirab.downloader.connection.proxy.Proxy
import ir.amirab.downloader.connection.proxy.ProxyStrategy
import ir.amirab.downloader.connection.proxy.ProxyStrategyProvider
import ir.amirab.downloader.connection.proxy.ProxyType
import ir.amirab.util.wildcardMatch
import java.net.Authenticator
import java.net.PasswordAuthentication
class ProxyManager(
val storage: IProxyStorage,
) : ProxyStrategyProvider {
val proxyData = storage.proxyDataFlow
init {
val mySocksProxyAuthenticator = MySocksProxyAuthenticator { proxyData.value.proxyWithRules.proxy }
Authenticator.setDefault(mySocksProxyAuthenticator)
}
private fun getProxyModeForThisURL(url: String): ProxyStrategy {
val usingProxy = proxyData.value
return when (usingProxy.proxyMode) {
ProxyMode.Direct -> ProxyStrategy.Direct
ProxyMode.UseSystem -> ProxyStrategy.UseSystem
ProxyMode.Manual -> {
val proxyWithRules = usingProxy.proxyWithRules
if (shouldUseProxyFor(url, proxyWithRules.rules)) {
ProxyStrategy.ManualProxy(proxyWithRules.proxy)
} else {
ProxyStrategy.Direct
}
}
}
}
private fun shouldUseProxyFor(
url: String,
rules: ProxyRules,
): Boolean {
val isInExcludeList = rules.excludeURLPatterns.any {
wildcardMatch(it, url)
}
return !isInExcludeList
}
override fun getProxyStrategyFor(url: String): ProxyStrategy {
return getProxyModeForThisURL(url)
}
}
/**
* this is used for socks proxy authentication
*/
private class MySocksProxyAuthenticator(
val currentProxy: () -> Proxy,
) : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
val proxy = currentProxy()
if (proxy.type == ProxyType.SOCKS && requestingPrompt == "SOCKS authentication") {
if (proxy.host == requestingHost && proxy.port == requestingPort) {
if (proxy.username != null) {
return PasswordAuthentication(
proxy.username,
proxy.password.orEmpty().toCharArray(),
)
}
}
}
return null
}
}

View File

@ -1,4 +1,4 @@
package com.abdownloadmanager.desktop.storage.base
package ir.amirab.util.config.datastore
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.DataStore

View File

@ -46,7 +46,7 @@ class MyConfigSerializer(
}
}
fun createMyConfigPreferences(
fun createMapConfigDatastore(
file: File,
json: Json,
): DataStore<MapConfig> {

View File

@ -1,4 +1,4 @@
package ir.amirab.util.osfileutil
package ir.amirab.util
import java.util.concurrent.TimeUnit
@ -8,7 +8,7 @@ import java.util.concurrent.TimeUnit
* @param waitFor maximum time allowed process finish ( in milliseconds )
* @return `true` when process exits with `0` exit code, `false` if the process fails with non-zero exit code or execution time exceeds the [waitFor]
*/
internal fun execAndWait(
fun execAndWait(
command: Array<String>,
waitFor: Long = 2_000,
): Boolean {

View File

@ -0,0 +1,12 @@
package ir.amirab.util
fun wildcardMatch(
pattern: String,
input: String,
): Boolean {
return pattern
.split("*")
.joinToString(".*") { Regex.escape(it) }
.toRegex()
.containsMatchIn(input)
}

View File

@ -1,5 +1,6 @@
package ir.amirab.util.osfileutil
import ir.amirab.util.execAndWait
import java.io.File
internal class LinuxFileUtils : FileUtilsBase() {

View File

@ -1,5 +1,6 @@
package ir.amirab.util.osfileutil
import ir.amirab.util.execAndWait
import java.io.File
internal class MacOsFileUtils : FileUtilsBase() {

View File

@ -1,5 +1,6 @@
package ir.amirab.util.osfileutil
import ir.amirab.util.execAndWait
import java.io.File
internal class WindowsFileUtils : FileUtilsBase() {