diff --git a/core/ui/src/main/java/org/monogram/core/ui/DownloadSettings.kt b/core/ui/src/main/java/org/monogram/core/ui/DownloadSettings.kt
index 3ccb2b28c..c86f8b9d5 100644
--- a/core/ui/src/main/java/org/monogram/core/ui/DownloadSettings.kt
+++ b/core/ui/src/main/java/org/monogram/core/ui/DownloadSettings.kt
@@ -145,9 +145,7 @@ data class AutoDownloadPreset(
}
data class DownloadState(
- val lanes: Int = 8,
- val speedUpUploads: Boolean = false,
- val speedUpDownloads: Boolean = false,
+ val lanes: Int = 2,
val premium: Boolean = false,
val wifi: AutoDownloadPreset = AutoDownloadPreset.WIFI,
val mobile: AutoDownloadPreset = AutoDownloadPreset.MOBILE,
@@ -156,12 +154,12 @@ data class DownloadState(
val autoplayVideos: Boolean = true,
val activeNetwork: AutoDownloadNetwork = AutoDownloadNetwork.Wifi,
) {
- val effectiveLanes: Int get() = lanes.coerceIn(1, if (premium && speedUpDownloads) 8 else 2)
- val parts: Int get() = if (!premium) 2 else if (speedUpDownloads) 8 else 4
+ val effectiveLanes: Int get() = lanes.coerceIn(1, if (premium) 8 else 2)
+ val parts: Int get() = 4
val concurrency: DownloadConcurrency
get() = DownloadConcurrency(lanes = effectiveLanes, parts = parts)
val filePartKib: Int get() = 512
- val downloadChunkKib: Int get() = if (premium && speedUpDownloads) 512 else 128
+ val downloadChunkKib: Int get() = if (premium) 512 else 256
fun presetFor(network: AutoDownloadNetwork): AutoDownloadPreset = when (network) {
AutoDownloadNetwork.Wifi -> wifi
@@ -172,8 +170,6 @@ data class DownloadState(
object DownloadSettings {
private const val PREFS = "monogram_download"
- private const val KEY_SPEED_UP_UPLOADS = "speed_up_uploads"
- private const val KEY_SPEED_UP_DOWNLOADS = "speed_up_downloads"
private const val KEY_WIFI = "autodownload_wifi"
private const val KEY_MOBILE = "autodownload_mobile"
private const val KEY_ROAMING = "autodownload_roaming"
@@ -202,8 +198,6 @@ object DownloadSettings {
this.apply = apply
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
mutable.value = DownloadState(
- speedUpUploads = prefs.getBoolean(KEY_SPEED_UP_UPLOADS, false),
- speedUpDownloads = prefs.getBoolean(KEY_SPEED_UP_DOWNLOADS, false),
wifi = AutoDownloadPreset.decode(prefs.getString(KEY_WIFI, null), AutoDownloadPreset.WIFI),
mobile = AutoDownloadPreset.decode(prefs.getString(KEY_MOBILE, null), AutoDownloadPreset.MOBILE),
roaming = AutoDownloadPreset.decode(prefs.getString(KEY_ROAMING, null), AutoDownloadPreset.ROAMING),
@@ -219,18 +213,6 @@ object DownloadSettings {
fun activePreset(): AutoDownloadPreset = mutable.value.presetFor(network.value)
- fun setSpeedUpUploads(enabled: Boolean) {
- mutable.update { it.copy(speedUpUploads = enabled) }
- persist()
- apply?.invoke(mutable.value)
- }
-
- fun setSpeedUpDownloads(enabled: Boolean) {
- mutable.update { it.copy(speedUpDownloads = enabled) }
- persist()
- apply?.invoke(mutable.value)
- }
-
fun setPremium(premium: Boolean) {
if (mutable.value.premium == premium) return
mutable.update { it.copy(premium = premium) }
@@ -308,8 +290,6 @@ object DownloadSettings {
val prefs = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE) ?: return
val value = mutable.value
prefs.edit {
- putBoolean(KEY_SPEED_UP_UPLOADS, value.speedUpUploads)
- putBoolean(KEY_SPEED_UP_DOWNLOADS, value.speedUpDownloads)
putString(KEY_WIFI, value.wifi.encode())
putString(KEY_MOBILE, value.mobile.encode())
putString(KEY_ROAMING, value.roaming.encode())
diff --git a/core/ui/src/test/java/org/monogram/core/ui/DownloadSettingsTest.kt b/core/ui/src/test/java/org/monogram/core/ui/DownloadSettingsTest.kt
index b4c31af1a..679270746 100644
--- a/core/ui/src/test/java/org/monogram/core/ui/DownloadSettingsTest.kt
+++ b/core/ui/src/test/java/org/monogram/core/ui/DownloadSettingsTest.kt
@@ -13,22 +13,18 @@ class DownloadSettingsTest {
}
@Test
- fun downloadBoostRespectsPremiumAndConfiguredLaneCap() {
- for (boost in listOf(false, true)) {
- val free = DownloadState(speedUpDownloads = boost, premium = false)
- assertEquals(DownloadConcurrency(2, 2), free.concurrency)
- assertEquals(128, free.downloadChunkKib)
- assertEquals(512, free.filePartKib)
- }
- val premium = DownloadState(premium = true)
- assertEquals(DownloadConcurrency(2, 4), premium.concurrency)
- assertEquals(128, premium.downloadChunkKib)
- val boosted = premium.copy(speedUpDownloads = true, lanes = 7)
- assertEquals(DownloadConcurrency(7, 8), boosted.concurrency)
- assertEquals(512, boosted.downloadChunkKib)
+ fun premiumDownloadKeepsLargeChunksWithConservativeConcurrency() {
+ val free = DownloadState(premium = false, lanes = 99)
+ assertEquals(DownloadConcurrency(2, 4), free.concurrency)
+ assertEquals(256, free.downloadChunkKib)
+ assertEquals(512, free.filePartKib)
+ val premium = DownloadState(premium = true, lanes = 7)
+ assertEquals(DownloadConcurrency(7, 4), premium.concurrency)
+ assertEquals(DownloadConcurrency(2, 4), DownloadState(premium = true).concurrency)
+ assertEquals(512, premium.downloadChunkKib)
assertEquals(512, premium.filePartKib)
- assertEquals(1, DownloadState(lanes = 1).effectiveLanes)
- assertEquals(8, boosted.copy(lanes = 99).effectiveLanes)
+ assertEquals(1, DownloadState(lanes = 1, premium = true).effectiveLanes)
+ assertEquals(8, DownloadState(lanes = 99, premium = true).effectiveLanes)
}
@Test
diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogDownloads.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogDownloads.kt
index d3351fb79..e8d0c61e7 100644
--- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogDownloads.kt
+++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogDownloads.kt
@@ -95,15 +95,8 @@ internal fun DialogDownloadsSheet(
)
val total = item.totalBytes
Text(
- text = if (total != null && total > 0L) {
- stringResource(
- R.string.dialog_media_download_progress,
- formatFileSize(bytes),
- formatFileSize(total),
- )
- } else {
- stringResource(R.string.dialog_media_loading)
- },
+ text = downloadProgressText(bytes, total)
+ ?: stringResource(R.string.dialog_media_loading),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/FileBubbles.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/FileBubbles.kt
index 115134738..e11aa3ce4 100644
--- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/FileBubbles.kt
+++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/FileBubbles.kt
@@ -191,7 +191,7 @@ internal fun DocumentBubble(
openFailed -> stringResource(R.string.dialog_media_open_failed)
failed && file == null -> stringResource(R.string.dialog_media_failed)
uploading && loading -> stringResource(R.string.dialog_media_uploading)
- loading && file == null -> formatDownloadProgress(downloadedBytes, size)
+ loading && file == null -> downloadProgressText(downloadedBytes, size)
else -> size?.takeIf { it > 0 }?.let(::formatFileSize)
}
sizeText?.let {
@@ -329,7 +329,7 @@ internal fun AudioBubble(
)
Text(
when {
- downloading -> formatDownloadProgress(downloadedBytes, fileSize)
+ downloading -> downloadProgressText(downloadedBytes, fileSize)
?: stringResource(R.string.dialog_media_loading)
failedHere -> stringResource(R.string.dialog_media_failed)
diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaDuration.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaDuration.kt
index 2854fc58d..816958f58 100644
--- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaDuration.kt
+++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaDuration.kt
@@ -1,5 +1,19 @@
package org.monogram.feature.dialog.ui
+import android.os.SystemClock
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.delay
+import org.monogram.feature.dialog.R
import org.monogram.network.http.MediaPriority
import org.monogram.core.models.isStickerFileName
import org.monogram.core.ui.AutoDownloadPreset
@@ -28,6 +42,39 @@ internal fun formatDownloadProgress(bytes: Long, total: Long?): String? {
}
}
+internal fun downloadBytesPerSecond(previous: Long, current: Long, elapsedMs: Long): Long =
+ if (elapsedMs <= 0L || current < previous) 0L
+ else ((current - previous).toDouble() * 1000.0 / elapsedMs).toLong()
+
+@Composable
+internal fun downloadProgressText(bytes: Long, total: Long?, active: Boolean = true): String? {
+ val latestBytes by rememberUpdatedState(bytes)
+ var speed by remember(active) { mutableLongStateOf(0L) }
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+ LaunchedEffect(active, lifecycle) {
+ if (!active) return@LaunchedEffect
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ speed = 0L
+ var previousBytes = latestBytes
+ var previousTime = SystemClock.elapsedRealtime()
+ while (true) {
+ delay(1000L)
+ val now = SystemClock.elapsedRealtime()
+ val received = latestBytes
+ speed = downloadBytesPerSecond(previousBytes, received, now - previousTime)
+ previousBytes = received
+ previousTime = now
+ }
+ }
+ }
+ val progress = formatDownloadProgress(bytes, total) ?: return null
+ return if (active) {
+ stringResource(R.string.dialog_media_progress_speed, progress, formatFileSize(speed))
+ } else {
+ progress
+ }
+}
+
internal fun formatMediaDuration(seconds: Int): String {
val total = seconds.coerceAtLeast(0)
val hours = total / 3600
diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt
index 94f48dd0b..1de749891 100644
--- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt
+++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt
@@ -202,6 +202,7 @@ fun MessageMedia(
return@LaunchedEffect
}
val displayPriority = MediaPriority.VISIBLE
+ var fullCancelled = false
suspend fun fetch(kind: MediaFetchKind): File? = withContext(Dispatchers.IO) {
var networkTries = 0
repeat(16) {
@@ -216,7 +217,10 @@ fun MessageMedia(
when (result) {
is Outcome.Ok -> return@withContext result.value
is Outcome.Err -> {
- if (result.message == "cancelled") return@withContext null
+ if (result.message == "cancelled") {
+ if (kind == MediaFetchKind.Full) fullCancelled = true
+ return@withContext null
+ }
if (kind == MediaFetchKind.Display && result.message == "no display size") {
return@withContext null
}
@@ -275,8 +279,11 @@ fun MessageMedia(
if (needFull && fullFile == null) {
fullFailed = false
fullFile = fetch(MediaFetchKind.Full)
- fullFailed = fullFile == null && (playing || requestFull)
- failed = fullFile == null && thumbFile == null && (playing || requestFull)
+ fullFailed = fullFile == null && !fullCancelled && (playing || requestFull)
+ failed = fullFile == null &&
+ thumbFile == null &&
+ !fullCancelled &&
+ (playing || requestFull)
}
}
previewFetchDone = true
@@ -378,6 +385,8 @@ fun MessageMedia(
onCancel = {
wantFull = false
playing = false
+ fullFailed = false
+ failed = false
mediaRepository?.cancel(fullKey)
},
modifier = modifier
@@ -414,6 +423,8 @@ fun MessageMedia(
},
onCancel = {
wantFull = false
+ fullFailed = false
+ failed = false
mediaRepository?.cancel(fullKey)
},
modifier = modifier
diff --git a/feature/dialog/src/main/res/values-es/strings.xml b/feature/dialog/src/main/res/values-es/strings.xml
index 61eb72b67..641da6b65 100644
--- a/feature/dialog/src/main/res/values-es/strings.xml
+++ b/feature/dialog/src/main/res/values-es/strings.xml
@@ -352,4 +352,5 @@
Lenguaje (opcional)
Ampliar editor
%1$s a las %2$s
+ %1$s · %2$s/s
diff --git a/feature/dialog/src/main/res/values-hy/strings.xml b/feature/dialog/src/main/res/values-hy/strings.xml
index a830fde39..88d163629 100644
--- a/feature/dialog/src/main/res/values-hy/strings.xml
+++ b/feature/dialog/src/main/res/values-hy/strings.xml
@@ -352,4 +352,5 @@
Լեզու (ըստ ցանկության)
Ընդլայնել խմբագիրը
%1$s, %2$s
+ %1$s · %2$s/վ
diff --git a/feature/dialog/src/main/res/values-ja/strings.xml b/feature/dialog/src/main/res/values-ja/strings.xml
index 26c7bf4f7..91ba2f50d 100644
--- a/feature/dialog/src/main/res/values-ja/strings.xml
+++ b/feature/dialog/src/main/res/values-ja/strings.xml
@@ -341,4 +341,5 @@
言語(任意)
エディターを展開
%1$s %2$s
+ %1$s · %2$s/秒
diff --git a/feature/dialog/src/main/res/values-pt-rBR/strings.xml b/feature/dialog/src/main/res/values-pt-rBR/strings.xml
index 8833770f5..3047338ae 100644
--- a/feature/dialog/src/main/res/values-pt-rBR/strings.xml
+++ b/feature/dialog/src/main/res/values-pt-rBR/strings.xml
@@ -352,4 +352,5 @@
Linguagem (opcional)
Expandir editor
%1$s às %2$s
+ %1$s · %2$s/s
diff --git a/feature/dialog/src/main/res/values-ru/strings.xml b/feature/dialog/src/main/res/values-ru/strings.xml
index 70dc6dd4b..61334a505 100644
--- a/feature/dialog/src/main/res/values-ru/strings.xml
+++ b/feature/dialog/src/main/res/values-ru/strings.xml
@@ -374,4 +374,5 @@
Язык (необязательно)
Развернуть редактор
%1$s в %2$s
+ %1$s · %2$s/с
diff --git a/feature/dialog/src/main/res/values-sk/strings.xml b/feature/dialog/src/main/res/values-sk/strings.xml
index 5df657b78..be7ed354b 100644
--- a/feature/dialog/src/main/res/values-sk/strings.xml
+++ b/feature/dialog/src/main/res/values-sk/strings.xml
@@ -374,4 +374,5 @@
Jazyk (voliteľné)
Rozbaliť editor
%1$s o %2$s
+ %1$s · %2$s/s
diff --git a/feature/dialog/src/main/res/values-tr/strings.xml b/feature/dialog/src/main/res/values-tr/strings.xml
index 76c884a60..5c9aa4f7f 100644
--- a/feature/dialog/src/main/res/values-tr/strings.xml
+++ b/feature/dialog/src/main/res/values-tr/strings.xml
@@ -352,4 +352,5 @@
Dil (isteğe bağlı)
Düzenleyiciyi genişlet
%1$s saat %2$s
+ %1$s · %2$s/sn
diff --git a/feature/dialog/src/main/res/values-uk/strings.xml b/feature/dialog/src/main/res/values-uk/strings.xml
index d944fbdc7..6ca35a301 100644
--- a/feature/dialog/src/main/res/values-uk/strings.xml
+++ b/feature/dialog/src/main/res/values-uk/strings.xml
@@ -374,4 +374,5 @@
Мова (необов’язково)
Розгорнути редактор
%1$s о %2$s
+ %1$s · %2$s/с
diff --git a/feature/dialog/src/main/res/values-zh/strings.xml b/feature/dialog/src/main/res/values-zh/strings.xml
index 33213b4a4..5e5353d83 100644
--- a/feature/dialog/src/main/res/values-zh/strings.xml
+++ b/feature/dialog/src/main/res/values-zh/strings.xml
@@ -341,4 +341,5 @@
语言(可选)
展开编辑器
%1$s %2$s
+ %1$s · %2$s/秒
diff --git a/feature/dialog/src/main/res/values/strings.xml b/feature/dialog/src/main/res/values/strings.xml
index f2530403e..645bfe85d 100644
--- a/feature/dialog/src/main/res/values/strings.xml
+++ b/feature/dialog/src/main/res/values/strings.xml
@@ -353,4 +353,5 @@
Language (optional)
Expand editor
%1$s at %2$s
+ %1$s · %2$s/s
diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/MediaDurationTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/MediaDurationTest.kt
index bf7a1364f..c9ac56f5d 100644
--- a/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/MediaDurationTest.kt
+++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/MediaDurationTest.kt
@@ -163,6 +163,14 @@ class MediaDurationTest {
assertEquals("2 KB", formatFileSize(2048))
}
+ @Test
+ fun downloadSpeedUsesElapsedTimeAndResetsAfterRestart() {
+ assertEquals(2048L, downloadBytesPerSecond(1024, 4096, 1500))
+ assertEquals(0L, downloadBytesPerSecond(4096, 4096, 1000))
+ assertEquals(0L, downloadBytesPerSecond(4096, 1024, 1000))
+ assertEquals(0L, downloadBytesPerSecond(1024, 4096, 0))
+ }
+
@Test
fun downloadProgressUsesKnownTotal() {
assertEquals(0f, downloadProgressFraction(0, 2000)!!, 0.001f)
diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt
index 76076ad59..c3154b8c3 100644
--- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt
+++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt
@@ -352,7 +352,6 @@ fun SettingsContent(
cacheMessage = state.cacheMessage,
loading = state.loading || state.loggingOut,
download = download,
- onSpeedUpDownloads = DownloadSettings::setSpeedUpDownloads,
onOpenAutoDownload = { network ->
component.openPage(
SettingsPage.AutoDownload(autoDownloadNetworkKey(network)),
diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt
index 2e1504561..289b6711a 100644
--- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt
+++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.CellTower
-import androidx.compose.material.icons.outlined.CloudDownload
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.Gif
@@ -169,7 +168,6 @@ internal fun LazyListScope.dataItems(
cacheMessage: String?,
loading: Boolean,
download: DownloadState,
- onSpeedUpDownloads: (Boolean) -> Unit,
onOpenAutoDownload: (AutoDownloadNetwork) -> Unit,
onClear: () -> Unit,
onClearChat: (Long) -> Unit,
@@ -304,24 +302,6 @@ internal fun LazyListScope.dataItems(
)
}
item { Spacer(Modifier.height(8.dp)) }
- item { SectionHeader(stringResource(R.string.settings_data_transfers)) }
- item {
- SettingsTile(
- icon = Icons.Outlined.CloudDownload,
- title = stringResource(R.string.settings_speed_up_downloads),
- subtitle = stringResource(R.string.settings_speed_up_downloads_sub),
- iconColor = MaterialTheme.colorScheme.primary,
- position = ItemPosition.STANDALONE,
- onClick = { onSpeedUpDownloads(!download.speedUpDownloads) },
- trailingContent = {
- Switch(
- checked = download.speedUpDownloads,
- onCheckedChange = onSpeedUpDownloads,
- )
- },
- )
- }
- item { Spacer(Modifier.height(8.dp)) }
item {
val photos = cacheByKind[FileCache.KIND_PHOTOS] ?: 0L
val videos = cacheByKind[FileCache.KIND_VIDEOS] ?: 0L
diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml
index 8fc4b794e..2ee327cea 100644
--- a/feature/settings/src/main/res/values-es/strings.xml
+++ b/feature/settings/src/main/res/values-es/strings.xml
@@ -126,9 +126,6 @@
Última conexión, llamadas y reenvío siguen los ajustes de privacidad de Telegram — abre tu perfil para más detalles
Datos y almacenamiento
Descarga automática y caché
- Transferencias
- Acelerar descargas
- Descargar fotos, videos y archivos más rápido con buena conexión
Descarga automática de medios
Con Wi-Fi
Con datos móviles
diff --git a/feature/settings/src/main/res/values-hy/strings.xml b/feature/settings/src/main/res/values-hy/strings.xml
index 58896c16a..b6b88408e 100644
--- a/feature/settings/src/main/res/values-hy/strings.xml
+++ b/feature/settings/src/main/res/values-hy/strings.xml
@@ -126,9 +126,6 @@
Վերջին այցը, զանգերը և վերահասցեավորումը հետևում են Telegram-ի գաղտնիության կարգավորումներին — մանրամասները պրոֆիլում
Տվյալներ և պահոց
Ինքնաբեռնում և քեշ
- Փոխանցումներ
- Արագացնել ներբեռնումը
- Լուսանկարները, տեսանյութերը և ֆայլերը լավ կապի դեպքում ավելի արագ են ներբեռնվում
Մեդիայի ինքնաբեռնում
Wi-Fi-ով
Բջջային ցանցով
diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml
index 0ded19ae7..83c7ebee8 100644
--- a/feature/settings/src/main/res/values-ja/strings.xml
+++ b/feature/settings/src/main/res/values-ja/strings.xml
@@ -126,9 +126,6 @@
最終オンライン、通話、転送は Telegram のプライバシー設定に従います — 詳細はプロフィールをご覧ください
データとストレージ
自動ダウンロードとキャッシュ
- 転送
- ダウンロードを高速化
- 良好な接続時に写真、動画、ファイルをより速くダウンロードします
メディアの自動ダウンロード
Wi-Fi接続時
モバイル通信時
diff --git a/feature/settings/src/main/res/values-pt-rBR/strings.xml b/feature/settings/src/main/res/values-pt-rBR/strings.xml
index f9848ec22..a16506493 100644
--- a/feature/settings/src/main/res/values-pt-rBR/strings.xml
+++ b/feature/settings/src/main/res/values-pt-rBR/strings.xml
@@ -126,9 +126,6 @@
Última vez, chamadas e encaminhamento seguem as configurações de privacidade do Telegram — abra seu perfil para detalhes
Dados e armazenamento
Baixar automaticamente e cache
- Transferências
- Acelerar downloads
- Baixar fotos, vídeos e arquivos mais rápido em conexões boas
Download automático de mídia
No Wi-Fi
Nos dados móveis
diff --git a/feature/settings/src/main/res/values-ru/strings.xml b/feature/settings/src/main/res/values-ru/strings.xml
index fe2575ec7..fdc2b89d9 100644
--- a/feature/settings/src/main/res/values-ru/strings.xml
+++ b/feature/settings/src/main/res/values-ru/strings.xml
@@ -126,9 +126,6 @@
Статус в сети, звонки и пересылка зависят от настроек конфиденциальности Telegram — подробности в профиле
Данные и память
Автозагрузка и кэш
- Передача
- Ускорить скачивание
- Скачивать фото, видео и файлы быстрее при хорошем соединении
Автозагрузка медиа
Через Wi-Fi
Через мобильную сеть
diff --git a/feature/settings/src/main/res/values-sk/strings.xml b/feature/settings/src/main/res/values-sk/strings.xml
index 5a5d58f61..20c758a15 100644
--- a/feature/settings/src/main/res/values-sk/strings.xml
+++ b/feature/settings/src/main/res/values-sk/strings.xml
@@ -126,9 +126,6 @@
Naposledy videný, hovory a preposielanie sa riadia nastaveniami súkromia Telegramu — podrobnosti nájdete v profile
Údaje a úložisko
Automatické sťahovanie a vyrovnávacia pamäť
- Prenosy
- Zrýchliť sťahovanie
- Sťahujte fotky, videá a súbory rýchlejšie pri dobrom pripojení
Automatické sťahovanie médií
Cez Wi-Fi
Cez mobilné dáta
diff --git a/feature/settings/src/main/res/values-tr/strings.xml b/feature/settings/src/main/res/values-tr/strings.xml
index fa3361c60..09df18eb0 100644
--- a/feature/settings/src/main/res/values-tr/strings.xml
+++ b/feature/settings/src/main/res/values-tr/strings.xml
@@ -126,9 +126,6 @@
Son görülme, aramalar ve iletme Telegram gizlilik ayarlarına bağlıdır — ayrıntılar için profilinizi açın
Veri ve depolama
Otomatik indirme ve önbellek
- Aktarımlar
- İndirmeleri hızlandır
- İyi bir bağlantıda fotoğrafları, videoları ve dosyaları daha hızlı indirin
Otomatik medya indirme
Wi-Fi ile
Hücresel veri ile
diff --git a/feature/settings/src/main/res/values-uk/strings.xml b/feature/settings/src/main/res/values-uk/strings.xml
index 09d543c18..326c058d0 100644
--- a/feature/settings/src/main/res/values-uk/strings.xml
+++ b/feature/settings/src/main/res/values-uk/strings.xml
@@ -126,9 +126,6 @@
Статус, дзвінки та пересилання залежать від налаштувань приватності Telegram — деталі у профілі
Дані та пам\'ять
Автозавантаження та кеш
- Передавання
- Прискорити завантаження
- Завантажувати фото, відео та файли швидше за стабільного з\'єднання
Автозавантаження медіа
Через Wi-Fi
Через мобільну мережу
diff --git a/feature/settings/src/main/res/values-zh/strings.xml b/feature/settings/src/main/res/values-zh/strings.xml
index b94db3d35..5b1277a84 100644
--- a/feature/settings/src/main/res/values-zh/strings.xml
+++ b/feature/settings/src/main/res/values-zh/strings.xml
@@ -126,9 +126,6 @@
最后上线时间、通话和转发遵循 Telegram 的隐私设置 — 打开资料查看详情
数据和存储
自动下载与缓存
- 传输
- 加速下载
- 在良好网络连接下更快下载照片、视频和文件
自动下载媒体
使用 Wi-Fi
使用移动网络
diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml
index 391b0e00d..10335c7b8 100644
--- a/feature/settings/src/main/res/values/strings.xml
+++ b/feature/settings/src/main/res/values/strings.xml
@@ -126,9 +126,6 @@
Last seen, calls, and forwarding follow Telegram\'s privacy settings — open your profile for details
Data and Storage
Auto-download and cache
- Transfers
- Speed up downloads
- Download photos, videos, and files faster on a good connection
Automatic media download
Using Wi-Fi
Using mobile data
diff --git a/native/mtproto-rs/src/auth/auth_key.rs b/native/mtproto-rs/src/auth/auth_key.rs
index 7b3bc8896..b7978f68f 100644
--- a/native/mtproto-rs/src/auth/auth_key.rs
+++ b/native/mtproto-rs/src/auth/auth_key.rs
@@ -3,8 +3,8 @@
use bnum::types::{I128, I256};
use tellers_mtproto::codec::{Boxed, BoxedDecode, Decoder, Encoder, Limits};
use tellers_mtproto::transport::{
- PQInnerDataDcConstructor, ReqDhParamsRequest, ReqPqMultiRequest, ResPq, ServerDhInnerData,
- ServerDhParams, SetClientDhParamsAnswer, SetClientDhParamsRequest,
+ PQInnerDataDcConstructor, PQInnerDataTempDcConstructor, ReqDhParamsRequest, ReqPqMultiRequest,
+ ResPq, ServerDhInnerData, ServerDhParams, SetClientDhParamsAnswer, SetClientDhParamsRequest,
};
use tellers_mtproto_crypto::{
aes_ige_decrypt, aes_ige_encrypt, derive_auth_key, factor_pq, fill_random, initial_server_salt,
@@ -128,6 +128,7 @@ struct AuthAdapter {
q: Option>,
g_b: Option>,
auth_key: Option>,
+ expires_in: Option,
}
impl AuthAdapter {
@@ -148,6 +149,7 @@ impl AuthAdapter {
q: None,
g_b: None,
auth_key: None,
+ expires_in: None,
})
}
}
@@ -210,17 +212,29 @@ impl AuthorizationAdapter for AuthAdapter {
let new_nonce = i256_from_bytes(new_nonce_bytes);
self.new_nonce = Some(new_nonce.clone());
- let inner = PQInnerDataDcConstructor {
- pq: res.pq.clone(),
- p: p_bytes,
- q: q_bytes,
- nonce: self.nonce.clone(),
- server_nonce: res.server_nonce.clone(),
- new_nonce,
- dc: inner_data_dc(self.dc_id),
- };
- let inner_bytes = encode_boxed(&inner)
- .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?;
+ let inner_bytes = if let Some(expires_in) = self.expires_in {
+ encode_boxed(&PQInnerDataTempDcConstructor {
+ pq: res.pq.clone(),
+ p: p_bytes,
+ q: q_bytes,
+ nonce: self.nonce.clone(),
+ server_nonce: res.server_nonce.clone(),
+ new_nonce,
+ dc: inner_data_dc(self.dc_id),
+ expires_in,
+ })
+ } else {
+ encode_boxed(&PQInnerDataDcConstructor {
+ pq: res.pq.clone(),
+ p: p_bytes,
+ q: q_bytes,
+ nonce: self.nonce.clone(),
+ server_nonce: res.server_nonce.clone(),
+ new_nonce,
+ dc: inner_data_dc(self.dc_id),
+ })
+ }
+ .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?;
let key = parse_rsa_public_key(&self.rsa_pem)
.map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?;
@@ -427,14 +441,34 @@ pub fn create_auth_key(
create_auth_key_with_pem(conn, framing, snapshot, rsa_pem())
}
+pub fn create_temp_auth_key(
+ conn: &mut dyn Connection,
+ framing: &mut PaddedIntermediate,
+ snapshot: &mut Snapshot,
+ expires_in: i32,
+) -> Result<(), MtprotoError> {
+ let mut adapter = AuthAdapter::with_pem(snapshot.dc_id, rsa_pem().to_string())?;
+ adapter.expires_in = Some(expires_in);
+ run_exchange(adapter, conn, framing, snapshot)
+}
+
pub fn create_auth_key_with_pem(
conn: &mut dyn Connection,
framing: &mut PaddedIntermediate,
snapshot: &mut Snapshot,
pem: &str,
) -> Result<(), MtprotoError> {
- let clock = SystemClock;
let adapter = AuthAdapter::with_pem(snapshot.dc_id, pem.to_string())?;
+ run_exchange(adapter, conn, framing, snapshot)
+}
+
+fn run_exchange(
+ adapter: AuthAdapter,
+ conn: &mut dyn Connection,
+ framing: &mut PaddedIntermediate,
+ snapshot: &mut Snapshot,
+) -> Result<(), MtprotoError> {
+ let clock = SystemClock;
let mut auth = Authorization::new(adapter);
let body = auth
diff --git a/native/mtproto-rs/src/auth/bind_temp.rs b/native/mtproto-rs/src/auth/bind_temp.rs
new file mode 100644
index 000000000..bad4a1170
--- /dev/null
+++ b/native/mtproto-rs/src/auth/bind_temp.rs
@@ -0,0 +1,470 @@
+//! Bind a temporary auth key so an extra home-DC session does not reuse the
+//! permanent key. https://core.telegram.org/api/pfs
+//! https://core.telegram.org/method/auth.bindTempAuthKey
+//! https://core.telegram.org/mtproto/description_v1
+
+use tellers_mtproto::codec::{Boxed, Encoder};
+use tellers_mtproto::transport::BindAuthKeyInnerConstructor;
+use tellers_mtproto_crypto::{aes_ige_decrypt, aes_ige_encrypt, auth_key_id, fill_random, sha1};
+use tellers_mtproto_session::{Clock, OsRandom, Snapshot};
+use tellers_mtproto_transport::{Connection, Framing, PaddedIntermediate};
+
+use crate::MtprotoError;
+use crate::auth::auth_key::create_temp_auth_key;
+
+const TEMP_EXPIRES_IN: i32 = 24 * 60 * 60;
+const BOOL_TRUE: u32 = 0x9972_75b5;
+const BOOL_FALSE: u32 = 0xbc79_9737;
+const RPC_RESULT: u32 = 0xf35c_6d01;
+const RPC_ERROR: u32 = 0x2144_ca19;
+const MSG_CONTAINER: u32 = 0x73f1_f8dc;
+
+pub(crate) fn unix_now() -> i32 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| i32::try_from(d.as_secs()).unwrap_or(i32::MAX))
+ .unwrap_or(0)
+}
+
+/// MTProto v1 `encrypted_message` for `auth.bindTempAuthKey`.
+/// `msg_key` covers the envelope before alignment padding.
+pub(crate) fn encrypt_bind_message(
+ perm_key: &[u8],
+ msg_id: i64,
+ inner: &BindAuthKeyInnerConstructor,
+ random_prefix: &[u8; 16],
+ padding: &[u8],
+) -> Result, MtprotoError> {
+ if perm_key.len() != 256 {
+ return Err(MtprotoError::Message("permanent key required".into()));
+ }
+ let body = encode_inner(inner)?;
+ if body.len() != 40 {
+ return Err(MtprotoError::Message("bind inner length".into()));
+ }
+ let mut plain = Vec::with_capacity(72 + padding.len());
+ plain.extend_from_slice(random_prefix);
+ plain.extend_from_slice(&msg_id.to_le_bytes());
+ plain.extend_from_slice(&0_i32.to_le_bytes());
+ plain.extend_from_slice(&40_i32.to_le_bytes());
+ plain.extend_from_slice(&body);
+ let msg_key = v1_msg_key(&plain);
+ if padding.len() > 15 || (plain.len() + padding.len()) % 16 != 0 {
+ return Err(MtprotoError::Message("bind padding".into()));
+ }
+ plain.extend_from_slice(padding);
+ let (aes_key, aes_iv) = v1_aes(&msg_key, perm_key);
+ let encrypted = aes_ige_encrypt(&plain, &aes_key, &aes_iv)
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ let mut out = Vec::with_capacity(24 + encrypted.len());
+ out.extend_from_slice(&auth_key_id(perm_key).to_le_bytes());
+ out.extend_from_slice(&msg_key);
+ out.extend_from_slice(&encrypted);
+ Ok(out)
+}
+
+fn v1_msg_key(unpadded: &[u8]) -> [u8; 16] {
+ sha1(unpadded)[4..20].try_into().expect("sha1 slice")
+}
+
+fn v1_aes(msg_key: &[u8; 16], auth_key: &[u8]) -> ([u8; 32], [u8; 32]) {
+ let sha_a = sha1_parts(&[msg_key, &auth_key[0..32]]);
+ let sha_b = sha1_parts(&[&auth_key[32..48], msg_key, &auth_key[48..64]]);
+ let sha_c = sha1_parts(&[&auth_key[64..96], msg_key]);
+ let sha_d = sha1_parts(&[msg_key, &auth_key[96..128]]);
+ let mut key = [0_u8; 32];
+ key[..8].copy_from_slice(&sha_a[..8]);
+ key[8..20].copy_from_slice(&sha_b[8..20]);
+ key[20..].copy_from_slice(&sha_c[4..16]);
+ let mut iv = [0_u8; 32];
+ iv[..12].copy_from_slice(&sha_a[8..20]);
+ iv[12..20].copy_from_slice(&sha_b[..8]);
+ iv[20..24].copy_from_slice(&sha_c[16..20]);
+ iv[24..].copy_from_slice(&sha_d[..8]);
+ (key, iv)
+}
+
+fn sha1_parts(parts: &[&[u8]]) -> [u8; 20] {
+ let mut buf = Vec::new();
+ for part in parts {
+ buf.extend_from_slice(part);
+ }
+ sha1(&buf)
+}
+
+fn encode_inner(inner: &BindAuthKeyInnerConstructor) -> Result, MtprotoError> {
+ let mut encoder = Encoder::new();
+ inner
+ .encode_boxed(&mut encoder)
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ Ok(encoder.into_bytes())
+}
+
+struct SystemClock;
+impl Clock for SystemClock {
+ fn unix_micros(&self) -> i64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX))
+ .unwrap_or(0)
+ }
+}
+
+pub(crate) struct BoundTemp {
+ pub snapshot: Snapshot,
+ pub expires_at: i32,
+}
+
+/// Handshake a temporary key and bind it. Leaves the permanent key untouched.
+pub(crate) fn negotiate_bound_temp(
+ perm_key: &[u8],
+ dc_id: i32,
+ time_offset_micros: i64,
+) -> Result {
+ if perm_key.len() != 256 {
+ return Err(MtprotoError::Message("permanent key required".into()));
+ }
+ let expires_at = unix_now().saturating_add(TEMP_EXPIRES_IN);
+ let mut snapshot =
+ Snapshot::new(dc_id, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?;
+ snapshot.time_offset_micros = time_offset_micros;
+ let mut conn = crate::tcp::connect_obfuscated_dc(crate::rpc::dc_endpoints(dc_id))
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ let mut framing = PaddedIntermediate::default();
+ create_temp_auth_key(&mut conn, &mut framing, &mut snapshot, TEMP_EXPIRES_IN)?;
+ let temp_key = snapshot
+ .auth_key
+ .clone()
+ .ok_or_else(|| MtprotoError::Message("temp key missing".into()))?;
+ if temp_key == perm_key {
+ return Err(MtprotoError::Message(
+ "temp key matched permanent key".into(),
+ ));
+ }
+ let clock = SystemClock;
+ let msg_id = snapshot
+ .next_message_id(&clock)
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ let mut nonce_bytes = [0_u8; 8];
+ fill_random(&mut nonce_bytes).map_err(|e| MtprotoError::Message(e.to_string()))?;
+ let mut prefix = [0_u8; 16];
+ fill_random(&mut prefix).map_err(|e| MtprotoError::Message(e.to_string()))?;
+ let inner = BindAuthKeyInnerConstructor {
+ nonce: i64::from_le_bytes(nonce_bytes),
+ temp_auth_key_id: auth_key_id(&temp_key),
+ perm_auth_key_id: auth_key_id(perm_key),
+ temp_session_id: snapshot.session_id,
+ expires_at,
+ };
+ let encrypted = encrypt_bind_message(perm_key, msg_id, &inner, &prefix, &[0_u8; 8])?;
+ let request = tellers_mtproto::latest::api::AuthBindTempAuthKeyRequest {
+ perm_auth_key_id: inner.perm_auth_key_id,
+ nonce: inner.nonce,
+ expires_at,
+ encrypted_message: encrypted,
+ };
+ let body = crate::rpc::encode_boxed_bytes(&request)?;
+ let seq = snapshot
+ .next_sequence(true)
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ send_v2(
+ &mut conn,
+ &mut framing,
+ &temp_key,
+ &mut snapshot,
+ msg_id,
+ seq,
+ &body,
+ )?;
+ read_bind_result(
+ &mut conn,
+ &mut framing,
+ &temp_key,
+ snapshot.session_id,
+ msg_id,
+ )?;
+ drop(conn);
+ Ok(BoundTemp {
+ snapshot,
+ expires_at,
+ })
+}
+
+fn send_v2(
+ conn: &mut dyn Connection,
+ framing: &mut PaddedIntermediate,
+ auth_key: &[u8],
+ snapshot: &mut Snapshot,
+ message_id: i64,
+ sequence: i32,
+ body: &[u8],
+) -> Result<(), MtprotoError> {
+ let pad_len = v2_pad_len(body.len());
+ let mut padding = vec![0_u8; pad_len];
+ fill_random(&mut padding).map_err(|e| MtprotoError::Message(e.to_string()))?;
+ let packet = tellers_mtproto_crypto::encrypt_message(
+ auth_key,
+ tellers_mtproto_crypto::MessageToEncrypt {
+ server_salt: snapshot.server_salt,
+ session_id: snapshot.session_id,
+ message_id,
+ sequence,
+ body,
+ padding: &padding,
+ },
+ tellers_mtproto_crypto::Direction::ClientToServer,
+ )
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ let framed = framing
+ .encode(&packet)
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ conn.send(&framed)
+ .map_err(|e| MtprotoError::Message(e.to_string()))
+}
+
+fn v2_pad_len(body_len: usize) -> usize {
+ let mut pad = 12;
+ while (32 + body_len + pad) % 16 != 0 {
+ pad += 1;
+ }
+ pad
+}
+
+fn read_bind_result(
+ conn: &mut dyn Connection,
+ framing: &mut PaddedIntermediate,
+ temp_key: &[u8],
+ session_id: i64,
+ msg_id: i64,
+) -> Result<(), MtprotoError> {
+ let mut buf = vec![0_u8; 64 * 1024];
+ let mut input = Vec::new();
+ for _ in 0..8 {
+ loop {
+ match framing.decode(&input) {
+ Ok(packet) => {
+ let consumed = packet.consumed;
+ let payload = crate::rpc::trim_padded_mtproto_packet(&packet.payload);
+ input.drain(..consumed.min(input.len()));
+ if let Some(control) = tellers_mtproto_transport::payload_control(payload) {
+ let _ = control;
+ break;
+ }
+ let message = tellers_mtproto_crypto::decrypt_message(
+ temp_key,
+ payload,
+ tellers_mtproto_crypto::Direction::ServerToClient,
+ session_id,
+ 1024 * 1024,
+ )
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ if bind_accepted(&message.body, msg_id)? {
+ return Ok(());
+ }
+ break;
+ }
+ Err(tellers_mtproto_transport::Error::Incomplete { .. }) => {
+ let n = conn
+ .receive(&mut buf)
+ .map_err(|e| MtprotoError::Message(e.to_string()))?;
+ if n == 0 {
+ return Err(MtprotoError::Message("temp bind connection closed".into()));
+ }
+ input.extend_from_slice(&buf[..n]);
+ }
+ Err(e) => return Err(MtprotoError::Message(e.to_string())),
+ }
+ }
+ }
+ Err(MtprotoError::Message("temp bind had no result".into()))
+}
+
+fn bind_accepted(body: &[u8], msg_id: i64) -> Result {
+ if body.len() < 4 {
+ return Ok(false);
+ }
+ let ctor = u32::from_le_bytes(body[..4].try_into().expect("ctor"));
+ if ctor == MSG_CONTAINER {
+ return container_accepted(body, msg_id);
+ }
+ if ctor == RPC_RESULT {
+ return rpc_result_accepted(body, msg_id);
+ }
+ Ok(false)
+}
+
+fn container_accepted(body: &[u8], msg_id: i64) -> Result {
+ if body.len() < 8 {
+ return Ok(false);
+ }
+ let count = u32::from_le_bytes(body[4..8].try_into().expect("count")) as usize;
+ let mut offset = 8;
+ for _ in 0..count {
+ if offset + 16 > body.len() {
+ return Ok(false);
+ }
+ offset += 8 + 4;
+ let len = u32::from_le_bytes(body[offset..offset + 4].try_into().expect("len")) as usize;
+ offset += 4;
+ let end = offset.saturating_add(len);
+ if end > body.len() {
+ return Ok(false);
+ }
+ if bind_accepted(&body[offset..end], msg_id)? {
+ return Ok(true);
+ }
+ offset = end;
+ }
+ Ok(false)
+}
+
+fn rpc_result_accepted(body: &[u8], msg_id: i64) -> Result {
+ if body.len() < 16 {
+ return Ok(false);
+ }
+ let req = i64::from_le_bytes(body[4..12].try_into().expect("req"));
+ if req != msg_id {
+ return Ok(false);
+ }
+ let inner = u32::from_le_bytes(body[12..16].try_into().expect("inner"));
+ if inner == BOOL_TRUE {
+ return Ok(true);
+ }
+ if inner == BOOL_FALSE {
+ return Err(MtprotoError::Message("temp bind rejected".into()));
+ }
+ if inner == RPC_ERROR {
+ let code = if body.len() >= 20 {
+ i32::from_le_bytes(body[16..20].try_into().expect("code"))
+ } else {
+ 0
+ };
+ return Err(MtprotoError::Message(format!("temp bind {code}")));
+ }
+ Err(MtprotoError::Message("temp bind result".into()))
+}
+
+fn promote_main(client: &crate::client::Client, perm: &[u8], dc: i32, offset: i64) -> bool {
+ let now = unix_now();
+ {
+ let main = client.main.lock();
+ if main.temp_expires_at > now && main.snapshot.auth_key.as_deref() != Some(perm) {
+ return true;
+ }
+ }
+ let Ok(session) = negotiate_bound_temp(perm, dc, offset) else {
+ eprintln!("monogram.api main temp bind failed");
+ return false;
+ };
+ let mut main = client.main.lock();
+ main.snapshot = session.snapshot;
+ main.temp_expires_at = session.expires_at;
+ main.transport = None;
+ main.pending_push = Default::default();
+ main.last_difference = None;
+ true
+}
+
+fn bind_lane(
+ lane: &crate::client::lanes::Lane,
+ perm: &[u8],
+ dc: i32,
+ offset: i64,
+ now: i32,
+) -> bool {
+ let Some(mut io) = lane.io.try_lock() else {
+ return false;
+ };
+ if io.temp_expires_at > now
+ && io.snapshot.auth_key.as_deref() != Some(perm)
+ && io.snapshot.auth_key.as_ref().map(Vec::len) == Some(256)
+ {
+ return true;
+ }
+ match negotiate_bound_temp(perm, dc, offset) {
+ Ok(session) => {
+ io.snapshot = session.snapshot;
+ io.temp_expires_at = session.expires_at;
+ io.transport = None;
+ io.pending_push = Default::default();
+ io.last_difference = None;
+ true
+ }
+ Err(_) => {
+ eprintln!("monogram.api temp session bind failed");
+ false
+ }
+ }
+}
+
+/// After `help.getConfig`, bind telegram extra sockets.
+/// Read lanes stay within `tmp_sessions`. Upload lanes 1..4 are file sockets.
+/// Failure leaves the single main connection and upload lane 0 in place.
+pub(crate) fn prepare_extra_main_sessions(handle: u64) {
+ let Ok(client) = crate::client::get_client(handle) else {
+ return;
+ };
+ let (perm, dc, offset) = {
+ let data = client.data.lock();
+ if data.user_id.is_none() || data.session_dead {
+ crate::scheduler::set_bound_extra_sessions(0);
+ return;
+ }
+ let Some(key) = data.home_auth_key.clone() else {
+ crate::scheduler::set_bound_extra_sessions(0);
+ return;
+ };
+ (key, data.home_dc, data.home_time_offset)
+ };
+ let now = unix_now();
+ let _ = promote_main(&client, &perm, dc, offset);
+ for lane in &client.upload {
+ let _ = bind_lane(lane, &perm, dc, offset, now);
+ }
+ let allowance = crate::scheduler::main_session_allowance();
+ if allowance <= 1 {
+ crate::scheduler::set_bound_extra_sessions(0);
+ return;
+ }
+ let want = usize::try_from(allowance.saturating_sub(1)).unwrap_or(0);
+ let want = want.min(crate::scheduler::READ_LANES);
+ let mut bound = 0_usize;
+ for lane in client.rpc.iter().take(want) {
+ if bind_lane(lane, &perm, dc, offset, now) {
+ bound += 1;
+ }
+ }
+ crate::scheduler::set_bound_extra_sessions(bound);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bind_message_uses_v1_msg_key_without_padding() {
+ let perm = vec![3_u8; 256];
+ let inner = BindAuthKeyInnerConstructor {
+ nonce: 7,
+ temp_auth_key_id: 8,
+ perm_auth_key_id: auth_key_id(&perm),
+ temp_session_id: 9,
+ expires_at: 1_700_000_000,
+ };
+ let prefix = [1_u8; 16];
+ let packet = encrypt_bind_message(&perm, 100, &inner, &prefix, &[2_u8; 8]).unwrap();
+ assert_eq!(
+ i64::from_le_bytes(packet[..8].try_into().unwrap()),
+ auth_key_id(&perm)
+ );
+ let msg_key: [u8; 16] = packet[8..24].try_into().unwrap();
+ let (aes_key, aes_iv) = v1_aes(&msg_key, &perm);
+ let plain = aes_ige_decrypt(&packet[24..], &aes_key, &aes_iv).unwrap();
+ assert_eq!(&plain[..16], &prefix);
+ assert_eq!(i64::from_le_bytes(plain[16..24].try_into().unwrap()), 100);
+ assert_eq!(i32::from_le_bytes(plain[24..28].try_into().unwrap()), 0);
+ assert_eq!(i32::from_le_bytes(plain[28..32].try_into().unwrap()), 40);
+ assert_eq!(v1_msg_key(&plain[..plain.len() - 8]), msg_key);
+ assert_eq!(&plain[plain.len() - 8..], &[2_u8; 8]);
+ }
+}
diff --git a/native/mtproto-rs/src/auth/mod.rs b/native/mtproto-rs/src/auth/mod.rs
index 9567c0349..3bd5d4540 100644
--- a/native/mtproto-rs/src/auth/mod.rs
+++ b/native/mtproto-rs/src/auth/mod.rs
@@ -1,3 +1,4 @@
pub(crate) mod auth_key;
pub(crate) mod auth_rpc;
+pub(crate) mod bind_temp;
pub(crate) mod srp;
diff --git a/native/mtproto-rs/src/client/auth.rs b/native/mtproto-rs/src/client/auth.rs
index bd20a8e99..db04c5484 100644
--- a/native/mtproto-rs/src/client/auth.rs
+++ b/native/mtproto-rs/src/client/auth.rs
@@ -53,7 +53,9 @@ pub fn connect(handle: u64) -> Result<(), MtprotoError> {
}
recover_imported_user(state)?;
Ok(())
- })
+ })?;
+ crate::auth::bind_temp::prepare_extra_main_sessions(handle);
+ Ok(())
}
fn recover_imported_user(state: &mut ClientState) -> Result<(), MtprotoError> {
diff --git a/native/mtproto-rs/src/client/dispatch.rs b/native/mtproto-rs/src/client/dispatch.rs
index df885c9f1..e50fff050 100644
--- a/native/mtproto-rs/src/client/dispatch.rs
+++ b/native/mtproto-rs/src/client/dispatch.rs
@@ -590,8 +590,10 @@ pub(crate) fn save_items_with(
let _policy = crate::transfer_policy::bind(Arc::clone(&client.policy));
let home = client.main.lock().snapshot.clone();
crate::media::check_transfer_flood(home.dc_id, crate::media::TransferClass::Upload)?;
- let mut io = client.upload.lock();
- if sync_upload_snapshot(&mut io.snapshot, &home) {
+ let mut io = super::lanes::lock_upload_lane(client);
+ if io.temp_expires_at <= crate::auth::bind_temp::unix_now()
+ && sync_upload_snapshot(&mut io.snapshot, &home)
+ {
io.transport = None;
}
let mut transport = io.transport.take();
diff --git a/native/mtproto-rs/src/client/lanes.rs b/native/mtproto-rs/src/client/lanes.rs
index 07dc5e170..b11f236a2 100644
--- a/native/mtproto-rs/src/client/lanes.rs
+++ b/native/mtproto-rs/src/client/lanes.rs
@@ -29,6 +29,10 @@ pub(crate) struct SessionIo {
pub(crate) last_difference: Option,
pub(crate) snapshot: Snapshot,
pub(crate) transport: Option,
+ /// Unix time when this lane's temporary auth key expires. Zero means none.
+ pub(crate) temp_expires_at: i32,
+ /// Key this lane's temp key was bound from. Not the key on the wire.
+ pub(crate) perm_key: Option>,
}
/// A session plus the gate that decides which request class gets it next.
@@ -175,13 +179,28 @@ pub(crate) fn close_idle_file_transports(client: &Client, now: std::time::Instan
for lane in &client.media {
close(&lane.io);
}
- close(&client.upload);
+ for lane in &client.upload {
+ close(&lane.io);
+ }
if !pending {
client.media_open.store(1, Ordering::Release);
}
pending
}
+/// Prefer a free temporary-key upload socket. Lane 0 is the permanent-key fallback.
+pub(crate) fn lock_upload_lane(client: &Client) -> parking_lot::MutexGuard<'_, SessionIo> {
+ let now = crate::auth::bind_temp::unix_now();
+ for lane in client.upload.iter().skip(1) {
+ if let Some(io) = lane.io.try_lock() {
+ if io.temp_expires_at > now {
+ return io;
+ }
+ }
+ }
+ client.upload[0].io.lock()
+}
+
pub(crate) fn schedule_file_cleanup(client: &Arc) {
if client.file_cleanup_running.swap(true, Ordering::AcqRel) {
return;
@@ -248,9 +267,8 @@ pub(crate) fn flush_idle_main_acks(
false
}
-/// Second media-DC TCP if a lane is free. The held first lease makes that gate
-/// busy, so this never returns the same lane. Home-DC downloads must not call this.
-#[allow(dead_code)]
+/// Second file TCP if a lane is free. The held first lease makes that gate
+/// busy, so this never returns the same lane. Caller must bind a separate temp key.
pub(crate) fn try_lock_second_media_lane(client: &Client) -> Option> {
let class = scheduler::current_class();
if scheduler::family(class) != scheduler::LaneFamily::Media {
diff --git a/native/mtproto-rs/src/client/media_download.rs b/native/mtproto-rs/src/client/media_download.rs
index d377469be..5ab45977b 100644
--- a/native/mtproto-rs/src/client/media_download.rs
+++ b/native/mtproto-rs/src/client/media_download.rs
@@ -14,6 +14,46 @@ use crate::tcp;
use super::*;
+fn home_lease_ok(state: &ClientState, home_session_id: i64) -> bool {
+ state.snapshot.session_id == home_session_id || state.perm_session_id == home_session_id
+}
+
+fn download_temp_ready(io: &super::lanes::SessionIo, dc: i32, home_key: Option<&[u8]>) -> bool {
+ io.temp_expires_at > crate::auth::bind_temp::unix_now()
+ && io.snapshot.dc_id == dc
+ && io
+ .snapshot
+ .auth_key
+ .as_deref()
+ .is_some_and(|key| Some(key) != home_key)
+}
+
+pub(crate) struct MediaLanePreparation {
+ request_id: u64,
+ worker: std::thread::JoinHandle,
+}
+
+impl MediaLanePreparation {
+ pub(crate) fn start(prepare: impl FnOnce() -> bool + Send + 'static) -> Self {
+ let request_id = crate::request_control::create();
+ let worker = std::thread::spawn(move || {
+ struct Release(u64);
+ impl Drop for Release {
+ fn drop(&mut self) { crate::request_control::release(self.0); }
+ }
+ let _release = Release(request_id);
+ crate::request_control::with_shared_binding(request_id, prepare)
+ });
+ Self { request_id, worker }
+ }
+
+ pub(crate) fn finished(&self) -> bool { self.worker.is_finished() }
+}
+
+impl Drop for MediaLanePreparation {
+ fn drop(&mut self) { crate::request_control::cancel(self.request_id); }
+}
+
pub fn download_wallpaper(
handle: u64,
id: i64,
@@ -43,7 +83,7 @@ pub fn download_wallpaper(
) {
Err(error) if media::is_file_reference_error(&error) => {
let refreshed = with_client_mut(handle, |state| {
- if state.snapshot.session_id != home.session_id {
+ if !home_lease_ok(state, home.session_id) {
return Err(expired_session_lease());
}
let media = call_with_migrate(state, |state| {
@@ -394,7 +434,7 @@ pub fn download_message_media_range(
|| thumb_file_id_can_refresh(kind, &media, message_id, &err) =>
{
let refreshed = with_client_mut(handle, |state| {
- if state.snapshot.session_id != home.session_id {
+ if !home_lease_ok(state, home.session_id) {
return Err(expired_session_lease());
}
crate::rpc::with_rpc_timeout_secs(20, || {
@@ -685,21 +725,9 @@ pub(crate) fn download_media_range_on_lane(
loop {
media::check_transfer_flood(target_dc, media::TransferClass::Download)?;
let admission = client.policy.admit_download(target_dc, large)?;
- let first = if target_dc == home.dc_id {
- download_media_range_on_home_session(
- handle,
- client,
- home.session_id,
- api_id,
- media,
- dest,
- offset,
- )
- } else {
- download_media_range_on_lane_dc(
- handle, client, home, api_id, media, dest, offset, target_dc,
- )
- };
+ let first = download_media_range_on_lane_dc(
+ handle, client, home, api_id, media, dest, offset, target_dc,
+ );
if let Err(ref error) = first {
if let Some(dc) = api_invoke::migrate_dc(error) {
if dc != target_dc && migrations < 3 {
@@ -727,21 +755,9 @@ pub(crate) fn download_media_range_on_lane(
Ok(path) => Ok(path),
Err(_) => media::with_cdn_supported(false, || {
let _admission = client.policy.admit_download(target_dc, large)?;
- if target_dc == home.dc_id {
- download_media_range_on_home_session(
- handle,
- client,
- home.session_id,
- api_id,
- media,
- dest,
- offset,
- )
- } else {
- download_media_range_on_lane_dc(
- handle, client, home, api_id, media, dest, offset, target_dc,
- )
- }
+ download_media_range_on_lane_dc(
+ handle, client, home, api_id, media, dest, offset, target_dc,
+ )
}),
}
}
@@ -750,11 +766,8 @@ pub(crate) fn download_media_range_on_lane(
}
}
-/// Home-DC download over the single main session.
-///
-/// A second session on this DC would be another main session.
-/// https://core.telegram.org/api/datacenter#parallel-sessions
-#[allow(dead_code)]
+/// Home-DC download on the main socket. Used only when a temp key cannot be bound,
+/// so the permanent key is not opened on a second TCP.
pub(crate) fn download_media_range_on_home_session(
handle: u64,
client: &Client,
@@ -786,7 +799,7 @@ pub(crate) fn download_media_range_on_home_session(
|_init_first, requests, on_chunk| {
media::check_transfer_flood(dc, media::TransferClass::Download)?;
with_client_mut(handle, |state| {
- if state.snapshot.session_id != session_id {
+ if !home_lease_ok(state, session_id) {
return Err(expired_session_lease());
}
ensure_ready(state)?;
@@ -825,12 +838,106 @@ fn invoke_lane_batch(
slot: &mut Option,
api_id: i32,
requests: Vec,
+ on_chunk: &mut dyn FnMut(
+ usize,
+ Result,
+ ) -> Option,
) -> Result>, MtprotoError> {
with_client_transport(client, slot, || {
crate::api_invoke::invoke_api_batch_without_updates_streaming::<
_,
tellers_mtproto::latest::api::UploadFile,
- >(snapshot, api_id, requests, &mut |_, _| None)
+ >(snapshot, api_id, requests, on_chunk)
+ })
+}
+
+pub(crate) fn invoke_parallel_media_batches(
+ batches: [Vec<(usize, Req)>; 2],
+ invoke_a: impl FnOnce(
+ Vec,
+ &mut dyn FnMut(usize, Result) -> Option,
+ ) -> Result>, MtprotoError>
+ + Send,
+ invoke_b: impl FnOnce(
+ Vec,
+ &mut dyn FnMut(usize, Result) -> Option,
+ ) -> Result>, MtprotoError>
+ + Send,
+ on_chunk: &mut dyn FnMut(usize, Result) -> Option,
+) -> Result>, MtprotoError> {
+ use std::sync::mpsc::{Sender, sync_channel};
+
+ fn run_lane(
+ batch: Vec<(usize, Req)>,
+ completed: Sender<(
+ usize,
+ Result,
+ std::sync::mpsc::SyncSender