diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
index 480320e33e..51128646e7 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
@@ -419,9 +419,9 @@ fun ComposeView(
withLongRunningApi(slow = 60_000) {
if (wait != null) delay(wait)
if (pendingLinkUrl.value != url) return@withLongRunningApi
- if (chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.get()
- && !chatModel.controller.appPrefs.networkUseSocksProxy.get()) {
- showLinkPreviewsConfirmAlert { enable ->
+ if (chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.get()) {
+ val socksEnabled = chatModel.controller.appPrefs.networkUseSocksProxy.get()
+ showLinkPreviewsConfirmAlert(socksEnabled) { enable ->
if (enable != null) {
chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false)
chatModel.controller.appPrefs.privacyLinkPreviews.set(enable)
@@ -1703,10 +1703,15 @@ fun ComposeView(
}
}
-private fun showLinkPreviewsConfirmAlert(onChoice: (Boolean?) -> Unit) {
+private fun showLinkPreviewsConfirmAlert(socksEnabled: Boolean, onChoice: (Boolean?) -> Unit) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.link_previews_alert_title),
- text = AnnotatedString(generalGetString(MR.strings.link_previews_alert_desc)),
+ text = AnnotatedString(
+ if (socksEnabled)
+ generalGetString(MR.strings.link_previews_alert_desc) + "\n\n" + generalGetString(MR.strings.link_previews_alert_desc_socks)
+ else
+ generalGetString(MR.strings.link_previews_alert_desc)
+ ),
onDismissRequest = { onChoice(null) },
buttons = {
Column {
@@ -1714,13 +1719,13 @@ private fun showLinkPreviewsConfirmAlert(onChoice: (Boolean?) -> Unit) {
AlertManager.shared.hideAlert()
onChoice(false)
}) {
- Text(stringResource(MR.strings.link_previews_alert_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
+ Text(stringResource(MR.strings.link_previews_alert_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
onChoice(true)
}) {
- Text(stringResource(MR.strings.link_previews_alert_enable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
+ Text(stringResource(MR.strings.link_previews_alert_enable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = if (socksEnabled) MaterialTheme.colors.primary else Color.Red)
}
// SectionItemView({
// AlertManager.shared.hideAlert()
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt
index 9c529e547a..d4b2915297 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.LinkPreview
+import chat.simplex.common.model.NetworkProxyAuth
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.chatViewScrollState
@@ -24,67 +25,123 @@ import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID
import chat.simplex.common.views.chat.item.imageViewFullWidth
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
+import java.net.Authenticator
+import java.net.InetSocketAddress
+import java.net.PasswordAuthentication
+import java.net.Proxy
import java.net.URL
+import java.util.UUID
private const val OG_SELECT_QUERY = "meta[property^=og:]"
private const val ICON_SELECT_QUERY = "link[rel^=icon],link[rel^=apple-touch-icon],link[rel^=shortcut icon]"
private val IMAGE_SUFFIXES = listOf(".jpg", ".png", ".ico", ".webp", ".gif")
+// Authenticator.setDefault is process-global. The mutex serializes preview fetches
+// so concurrent calls cannot clobber each other's authenticator, and so the
+// snapshot/restore in getLinkPreview is race-free.
+private val previewMutex = Mutex()
+
suspend fun getLinkPreview(url: String): LinkPreview? {
return withContext(Dispatchers.IO) {
- try {
- val title: String?
- val u = kotlin.runCatching { URL(url) }.getOrNull() ?: return@withContext null
- var imageUri = when {
- IMAGE_SUFFIXES.any { u.path.lowercase().endsWith(it) } -> {
- title = u.path.substringAfterLast("/")
- url
- }
- else -> {
- val connection = Jsoup.connect(url)
- .ignoreContentType(true)
- .timeout(10000)
- .followRedirects(true)
-
- val response = if (url.lowercase().startsWith("https://x.com/")) {
- // Apple sends request with special user-agent which handled differently by X.com.
- // Different response that includes video poster from post
- connection
- .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0")
- .execute()
- } else {
- connection
- .execute()
- }
- val doc = response.parse()
- val ogTags = doc.select(OG_SELECT_QUERY)
- title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title()
- ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
- ?: doc.select(ICON_SELECT_QUERY).firstOrNull { it.attr("rel").contains("icon") }?.attr("href")
- }
- }
- if (imageUri != null) {
- imageUri = normalizeImageUri(u, imageUri)
+ previewMutex.withLock {
+ val previousAuthenticator = Authenticator.getDefault()
+ try {
try {
- val stream = URL(imageUri).openStream()
- val image = resizeImageToStrSize(stream.use(::loadImageBitmap), maxDataSize = 14000)
- // TODO add once supported in iOS
- // val description = ogTags.firstOrNull {
- // it.attr("property") == "og:description"
- // }?.attr("content") ?: ""
- if (title != null) {
- return@withContext LinkPreview(url, title, description = "", image)
+ val title: String?
+ val u = kotlin.runCatching { URL(url) }.getOrNull() ?: return@withLock null
+ val useSocksProxy = appPrefs.networkUseSocksProxy.get()
+ val proxy: Proxy?
+ if (useSocksProxy) {
+ val networkProxy = appPrefs.networkProxy.get()
+ proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(networkProxy.host, networkProxy.port))
+ val (authUser, authPass) = when (networkProxy.auth) {
+ NetworkProxyAuth.USERNAME ->
+ if (networkProxy.username.isNotEmpty() && networkProxy.password.isNotEmpty())
+ networkProxy.username to networkProxy.password
+ else
+ null to null
+ // Per-call random credentials drive Tor-style stream isolation: each
+ // preview gets its own circuit, and previews don't share a circuit
+ // with other unauthenticated traffic on the proxy.
+ NetworkProxyAuth.ISOLATE ->
+ UUID.randomUUID().toString() to UUID.randomUUID().toString()
+ }
+ if (authUser != null && authPass != null) {
+ Authenticator.setDefault(object : Authenticator() {
+ override fun getPasswordAuthentication(): PasswordAuthentication? =
+ // Only respond when the SOCKS proxy itself challenges. A destination
+ // server returning 401 also triggers RequestorType.SERVER; without
+ // this gate, the JDK's auto-retry would post our SOCKS credentials
+ // in an Authorization header to the destination.
+ if (requestingHost == networkProxy.host && requestingPort == networkProxy.port)
+ PasswordAuthentication(authUser, authPass.toCharArray())
+ else null
+ })
+ } else {
+ Authenticator.setDefault(null)
+ }
+ } else {
+ proxy = null
+ Authenticator.setDefault(null)
+ }
+ var imageUri = when {
+ IMAGE_SUFFIXES.any { u.path.lowercase().endsWith(it) } -> {
+ title = u.path.substringAfterLast("/")
+ url
+ }
+ else -> {
+ val connection = Jsoup.connect(url)
+ .ignoreContentType(true)
+ .timeout(10000)
+ .followRedirects(true)
+ .proxy(proxy)
+
+ val response = if (url.lowercase().startsWith("https://x.com/")) {
+ // Apple sends request with special user-agent which handled differently by X.com.
+ // Different response that includes video poster from post
+ connection
+ .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0")
+ .execute()
+ } else {
+ connection
+ .execute()
+ }
+ val doc = response.parse()
+ val ogTags = doc.select(OG_SELECT_QUERY)
+ title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title()
+ ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
+ ?: doc.select(ICON_SELECT_QUERY).firstOrNull { it.attr("rel").contains("icon") }?.attr("href")
+ }
+ }
+ if (imageUri != null) {
+ imageUri = normalizeImageUri(u, imageUri)
+ try {
+ val conn = URL(imageUri).openConnection(proxy ?: Proxy.NO_PROXY)
+ val stream = conn.getInputStream()
+ val image = resizeImageToStrSize(stream.use(::loadImageBitmap), maxDataSize = 14000)
+ // TODO add once supported in iOS
+ // val description = ogTags.firstOrNull {
+ // it.attr("property") == "og:description"
+ // }?.attr("content") ?: ""
+ if (title != null) {
+ return@withLock LinkPreview(url, title, description = "", image)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
}
} catch (e: Exception) {
e.printStackTrace()
}
+ return@withLock null
+ } finally {
+ Authenticator.setDefault(previousAuthenticator)
}
- } catch (e: Exception) {
- e.printStackTrace()
}
- return@withContext null
}
}
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index b6494accde..8d654b5af8 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -1067,7 +1067,7 @@
for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]>
Update transport isolation mode?
Use .onion hosts to No if SOCKS proxy does not support them.]]>
- Please note: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]>
+ Please note: message and file relays are connected via SOCKS proxy. Calls use direct connection.]]>
Private routing
Always
Unknown servers
@@ -3061,6 +3061,7 @@
Enable link previews?
Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.
+ Link preview will be requested via SOCKS proxy. DNS lookup may still happen locally via your DNS resolver.
Enable
Disable
\ No newline at end of file