Skip to content

Commit 7331ebb

Browse files
committed
implement QR code scanning and clipboard import for proxy settings
1 parent 95fe219 commit 7331ebb

11 files changed

Lines changed: 337 additions & 25 deletions

File tree

presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import org.monogram.domain.models.toDomainProxyType
2020
import org.monogram.domain.models.toProxyModel
2121
import org.monogram.domain.repository.AppPreferencesProvider
2222
import org.monogram.domain.repository.DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES
23+
import org.monogram.domain.repository.LinkAction
24+
import org.monogram.domain.repository.LinkHandlerRepository
2325
import org.monogram.domain.repository.ProxyDiagnosticsRepository
2426
import org.monogram.domain.repository.ProxyNetworkMode
2527
import org.monogram.domain.repository.ProxyNetworkRule
@@ -28,6 +30,7 @@ import org.monogram.domain.repository.ProxyRepository
2830
import org.monogram.domain.repository.ProxySmartSwitchMode
2931
import org.monogram.domain.repository.ProxySortMode
3032
import org.monogram.domain.repository.ProxyUnavailableFallback
33+
import org.monogram.domain.repository.StringProvider
3134
import org.monogram.domain.repository.defaultProxyNetworkMode
3235
import org.monogram.presentation.core.util.coRunCatching
3336
import org.monogram.presentation.core.util.componentScope
@@ -62,6 +65,7 @@ interface ProxyComponent {
6265
fun onToggleFavoriteProxy(proxyId: Int)
6366
fun exportProxiesJson(): String
6467
fun importProxiesJson(json: String)
68+
fun importProxiesFromText(rawText: String)
6569
fun onProxyNetworkModeChanged(networkType: ProxyNetworkType, mode: ProxyNetworkMode)
6670
fun onSpecificProxyForNetworkSelected(networkType: ProxyNetworkType, proxyId: Int)
6771
fun onClearUnavailableProxies()
@@ -110,6 +114,9 @@ class DefaultProxyComponent(
110114

111115
private val appPreferences: AppPreferencesProvider = container.preferences.appPreferences
112116
private val proxyRepository: ProxyRepository = container.repositories.proxyRepository
117+
private val linkHandlerRepository: LinkHandlerRepository =
118+
container.repositories.linkHandlerRepository
119+
private val stringProvider: StringProvider = container.utils.stringProvider()
113120
private val proxyDiagnosticsRepository: ProxyDiagnosticsRepository =
114121
container.repositories.proxyDiagnosticsRepository
115122

@@ -122,6 +129,10 @@ class DefaultProxyComponent(
122129
private var pingRequestToken = 0L
123130
private val pingRequestByProxyId = mutableMapOf<Int, Long>()
124131

132+
private fun tr(resName: String, vararg args: Any): String {
133+
return stringProvider.getString(resName, *args)
134+
}
135+
125136
private fun showToastThrottled(message: String, throttleMs: Long = 1500L) {
126137
val now = System.currentTimeMillis()
127138
val isDuplicateTooSoon = lastToastMessage == message && (now - lastToastAtMs) < throttleMs
@@ -146,7 +157,7 @@ class DefaultProxyComponent(
146157
coRunCatching { refreshProxies(shouldPing = true) }
147158
.onFailure {
148159
_state.update { state -> state.copy(isLoading = false) }
149-
showToastThrottled("Failed to load proxies")
160+
showToastThrottled(tr("proxy_load_failed"))
150161
}
151162
}
152163

@@ -242,7 +253,7 @@ class DefaultProxyComponent(
242253
val restoredProxies = coRunCatching { restoreUserProxiesIfNeeded() }
243254
.getOrElse { emptyList() }
244255
val allProxies = coRunCatching { proxyRepository.getProxies().map { it.toProxyModel() } }
245-
.onFailure { showToastThrottled("Failed to load proxies") }
256+
.onFailure { showToastThrottled(tr("proxy_load_failed")) }
246257
.getOrElse { emptyList() }
247258
.ifEmpty { restoredProxies }
248259
_state.update {
@@ -585,7 +596,7 @@ class DefaultProxyComponent(
585596
override fun importProxiesJson(json: String) {
586597
scope.launch {
587598
val existing = coRunCatching { proxyRepository.getProxies().map { it.toProxyModel() } }
588-
.onFailure { showToastThrottled("Failed to load existing proxies") }
599+
.onFailure { showToastThrottled(tr("proxy_existing_load_failed")) }
589600
.getOrElse { emptyList() }
590601
val fingerprintToId = existing.associate { proxy ->
591602
proxyFingerprint(proxy.server, proxy.port, proxy.type) to proxy.id
@@ -609,7 +620,7 @@ class DefaultProxyComponent(
609620
}.getOrNull()
610621

611622
if (parsedEntries == null) {
612-
_state.update { it.copy(toastMessage = "Import failed: invalid file") }
623+
_state.update { it.copy(toastMessage = tr("proxy_import_invalid_file")) }
613624
return@launch
614625
}
615626

@@ -659,11 +670,156 @@ class DefaultProxyComponent(
659670
favoriteProxyIdToSet?.let { appPreferences.setFavoriteProxyId(it) }
660671
refreshProxies(shouldPing = true)
661672
_state.update {
662-
it.copy(toastMessage = "Imported: $added, skipped: $skipped, invalid: $invalid")
673+
it.copy(toastMessage = tr("proxy_import_summary_format", added, skipped, invalid))
663674
}
664675
}
665676
}
666677

678+
override fun importProxiesFromText(rawText: String) {
679+
scope.launch {
680+
val normalized = rawText.trim()
681+
if (normalized.isBlank()) {
682+
showToastThrottled(tr("proxy_clipboard_empty"))
683+
return@launch
684+
}
685+
686+
val candidates = extractProxyLinkCandidates(rawText)
687+
if (candidates.isEmpty()) {
688+
showToastThrottled(tr("proxy_no_links_found"))
689+
return@launch
690+
}
691+
692+
val existing = coRunCatching { proxyRepository.getProxies().map { it.toProxyModel() } }
693+
.onFailure { showToastThrottled(tr("proxy_existing_load_failed")) }
694+
.getOrElse { emptyList() }
695+
val knownFingerprints = existing
696+
.mapTo(mutableSetOf()) { proxyFingerprint(it.server, it.port, it.type) }
697+
698+
var added = 0
699+
var skipped = 0
700+
var invalid = 0
701+
702+
candidates.forEach { candidate ->
703+
val action = runCatching { linkHandlerRepository.handleLink(candidate) }.getOrNull()
704+
val proxy = action as? LinkAction.AddProxy
705+
if (proxy == null) {
706+
invalid++
707+
return@forEach
708+
}
709+
710+
val fingerprint = proxyFingerprint(proxy.server, proxy.port, proxy.type)
711+
if (!knownFingerprints.add(fingerprint)) {
712+
skipped++
713+
return@forEach
714+
}
715+
716+
val addedProxy = proxyRepository.addProxy(
717+
input = ProxyInput(
718+
server = proxy.server,
719+
port = proxy.port,
720+
type = proxy.type.toDomainProxyType()
721+
),
722+
enable = false
723+
)?.toProxyModel()
724+
725+
if (addedProxy != null) {
726+
addProxyToBackup(addedProxy)
727+
added++
728+
} else {
729+
knownFingerprints.remove(fingerprint)
730+
invalid++
731+
}
732+
}
733+
734+
if (added > 0) {
735+
refreshProxies(shouldPing = true)
736+
}
737+
738+
showToastThrottled(tr("proxy_import_summary_format", added, skipped, invalid))
739+
}
740+
}
741+
742+
private fun extractProxyLinkCandidates(rawText: String): List<String> {
743+
val linkRegex = Regex(
744+
pattern = """(?i)(tg:(?://)?[^\s]+|https?://(?:t\.me|www\.t\.me|telegram\.me|www\.telegram\.me)/[^\s]+)"""
745+
)
746+
val fromRegex = linkRegex.findAll(rawText).map { it.value }
747+
val fromLines = rawText.lineSequence()
748+
return (fromRegex + fromLines)
749+
.map { normalizeForParsing(it) }
750+
.map { normalizeTelegramScheme(it) }
751+
.filter { it.isNotBlank() && looksLikeProxyLink(it) }
752+
.distinct()
753+
.toList()
754+
}
755+
756+
private fun normalizeTelegramScheme(link: String): String {
757+
if (link.startsWith("tg://", ignoreCase = true)) return link
758+
if (link.startsWith("tg:", ignoreCase = true)) {
759+
return "tg://${link.substringAfter(':')}"
760+
}
761+
return link
762+
}
763+
764+
private fun normalizeForParsing(link: String): String {
765+
var sanitized = link.trim()
766+
.removeSurrounding("<", ">")
767+
.removeSurrounding("\"")
768+
.removeSurrounding("'")
769+
770+
while (sanitized.isNotEmpty() && sanitized.last() in setOf(
771+
')',
772+
']',
773+
'}',
774+
'.',
775+
',',
776+
';',
777+
'!',
778+
'?'
779+
)
780+
) {
781+
sanitized = sanitized.dropLast(1)
782+
}
783+
return sanitized
784+
}
785+
786+
private fun looksLikeProxyLink(link: String): Boolean {
787+
val linkLower = link.lowercase()
788+
return linkLower.startsWith("tg://proxy?") ||
789+
linkLower.startsWith("tg:proxy?") ||
790+
linkLower.startsWith("tg://proxy/") ||
791+
linkLower.startsWith("tg://socks?") ||
792+
linkLower.startsWith("tg:socks?") ||
793+
linkLower.startsWith("tg://socks/") ||
794+
linkLower.startsWith("tg://http?") ||
795+
linkLower.startsWith("tg:http?") ||
796+
linkLower.startsWith("tg://http/") ||
797+
linkLower.startsWith("https://t.me/proxy?") ||
798+
linkLower.startsWith("http://t.me/proxy?") ||
799+
linkLower.startsWith("https://www.t.me/proxy?") ||
800+
linkLower.startsWith("http://www.t.me/proxy?") ||
801+
linkLower.startsWith("https://telegram.me/proxy?") ||
802+
linkLower.startsWith("http://telegram.me/proxy?") ||
803+
linkLower.startsWith("https://www.telegram.me/proxy?") ||
804+
linkLower.startsWith("http://www.telegram.me/proxy?") ||
805+
linkLower.startsWith("https://t.me/socks?") ||
806+
linkLower.startsWith("http://t.me/socks?") ||
807+
linkLower.startsWith("https://www.t.me/socks?") ||
808+
linkLower.startsWith("http://www.t.me/socks?") ||
809+
linkLower.startsWith("https://telegram.me/socks?") ||
810+
linkLower.startsWith("http://telegram.me/socks?") ||
811+
linkLower.startsWith("https://www.telegram.me/socks?") ||
812+
linkLower.startsWith("http://www.telegram.me/socks?") ||
813+
linkLower.startsWith("https://t.me/http?") ||
814+
linkLower.startsWith("http://t.me/http?") ||
815+
linkLower.startsWith("https://www.t.me/http?") ||
816+
linkLower.startsWith("http://www.t.me/http?") ||
817+
linkLower.startsWith("https://telegram.me/http?") ||
818+
linkLower.startsWith("http://telegram.me/http?") ||
819+
linkLower.startsWith("https://www.telegram.me/http?") ||
820+
linkLower.startsWith("http://www.telegram.me/http?")
821+
}
822+
667823
override fun onEnableProxy(proxyId: Int) {
668824
scope.launch {
669825
if (proxyRepository.enableProxy(proxyId)) {
@@ -874,7 +1030,7 @@ class DefaultProxyComponent(
8741030
upsertProxyLocally(proxy)
8751031
onPingProxy(proxy.id)
8761032
} else {
877-
showToastThrottled("Failed to add proxy")
1033+
showToastThrottled(tr("proxy_add_failed"))
8781034
}
8791035
}
8801036
}
@@ -901,7 +1057,7 @@ class DefaultProxyComponent(
9011057
upsertProxyLocally(proxy, replaceId = proxyId, closeEditor = true)
9021058
onPingProxy(proxy.id)
9031059
} else {
904-
showToastThrottled("Failed to save proxy")
1060+
showToastThrottled(tr("proxy_save_failed"))
9051061
}
9061062
}
9071063
}

0 commit comments

Comments
 (0)