Skip to content

Commit 6cb912a

Browse files
committed
feat(telegram): add progress UI for large channel and forum syncs
Introduces visual progress reporting across dashboard and search flows for large channel syncs, replacing silent 5+ minute loading spinners. Repository: Adds getApproxAudioMessageCount using defensive reflection for TDLib build safety. Adds onProgress callbacks to flat channel and forum topic fetches, and optimizes forum loop reflection. ViewModels: Wires progress tracking into flat/forum sync loops across both dashboard and search view models via a new shared TelegramSyncProgress state. UI (Screen & Sheet): Implements LinearWavyProgressIndicator. Displays a determinate percentage for flat channels (threshold >= 5000) and an indeterminate running count for forum topics where totals are unavailable.
1 parent 7834955 commit 6cb912a

5 files changed

Lines changed: 202 additions & 35 deletions

File tree

app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
package com.theveloper.pixelplay.data.telegram
22

3+
/**
4+
* Progress of an in-flight getAudioMessages fetch. current = songs fetched so far,
5+
* approxTotal = -1 if unknown, otherwise TDLib's approximate count for the channel (see
6+
* TelegramRepository.getApproxAudioMessageCount). Shared by every call site that wants to
7+
* show fetch progress, rather than each declaring its own copy of the same shape.
8+
*/
9+
data class TelegramSyncProgress(val current: Int, val approxTotal: Int)
10+
311
import com.theveloper.pixelplay.data.database.TelegramDao
412
import com.theveloper.pixelplay.data.database.TelegramSongEntity
513
import com.theveloper.pixelplay.data.database.TelegramTopicEntity
@@ -267,7 +275,11 @@ class TelegramRepository @Inject constructor(
267275
return topics
268276
}
269277

270-
suspend fun getAudioMessagesByTopic(chatId: Long, threadId: Long): List<Song> {
278+
suspend fun getAudioMessagesByTopic(
279+
chatId: Long,
280+
threadId: Long,
281+
onProgress: (suspend (current: Int) -> Unit)? = null
282+
): List<Song> {
271283
Timber.d("Fetching audio for topic threadId=$threadId in chat=$chatId")
272284
try {
273285
clientManager.sendRequest<TdApi.Ok>(TdApi.OpenChat(chatId))
@@ -279,6 +291,24 @@ class TelegramRepository @Inject constructor(
279291
var nextFromMessageId = 0L
280292
val batchSize = 100
281293

294+
// Resolved once, outside the loop, since the result never changes between batches —
295+
// previously this reflection lookup (plus a Timber.d field dump) re-ran on every
296+
// single batch despite always resolving the same way for a given build.
297+
var topicFieldName: String? = null
298+
var topicFieldSetsMessageTopic = false
299+
try {
300+
TdApi.SearchChatMessages::class.java.getDeclaredField("topicId")
301+
topicFieldName = "topicId"
302+
topicFieldSetsMessageTopic = true
303+
} catch (_: NoSuchFieldException) {
304+
try {
305+
TdApi.SearchChatMessages::class.java.getDeclaredField("messageThreadId")
306+
topicFieldName = "messageThreadId"
307+
} catch (_: NoSuchFieldException) {
308+
Timber.e("SearchChatMessages: could not resolve a topic filter field — results will be unfiltered")
309+
}
310+
}
311+
282312
try {
283313
while (true) {
284314
val request = TdApi.SearchChatMessages().apply {
@@ -290,37 +320,15 @@ class TelegramRepository @Inject constructor(
290320
this.limit = batchSize
291321
this.filter = TdApi.SearchMessagesFilterAudio()
292322

293-
// Set the topic/thread filter via reflection to handle different TDLib builds.
294-
// In newer builds the field is 'topicId' (MessageTopic object).
295-
// In older builds it was 'messageThreadId' (Long).
296-
val scFields = this.javaClass.declaredFields
297-
Timber.d("SearchChatMessages fields: ${scFields.map { "${it.name}:${it.type.simpleName}" }}")
298-
299-
var topicSet = false
300-
301-
// Try 'topicId' field (newer TDLib — expects a MessageTopic object)
302-
try {
303-
val f = this.javaClass.getDeclaredField("topicId")
323+
if (topicFieldName != null) {
324+
val f = this.javaClass.getDeclaredField(topicFieldName)
304325
f.isAccessible = true
305-
// MessageTopicForum wraps the thread ID as Int
306-
f.set(this, TdApi.MessageTopicForum(threadId.toInt()))
307-
Timber.d("SearchChatMessages: set topicId = MessageTopicForum($threadId)")
308-
topicSet = true
309-
} catch (_: NoSuchFieldException) { }
310-
311-
// Fallback: try 'messageThreadId' field (older TDLib — Long)
312-
if (!topicSet) {
313-
try {
314-
val f = this.javaClass.getDeclaredField("messageThreadId")
315-
f.isAccessible = true
326+
if (topicFieldSetsMessageTopic) {
327+
// MessageTopicForum wraps the thread ID as Int
328+
f.set(this, TdApi.MessageTopicForum(threadId.toInt()))
329+
} else {
316330
f.set(this, threadId)
317-
Timber.d("SearchChatMessages: set messageThreadId = $threadId")
318-
topicSet = true
319-
} catch (_: NoSuchFieldException) { }
320-
}
321-
322-
if (!topicSet) {
323-
Timber.e("SearchChatMessages: could not set topic filter — results will be unfiltered")
331+
}
324332
}
325333
}
326334

@@ -332,6 +340,8 @@ class TelegramRepository @Inject constructor(
332340
mapMessageToSong(message)?.let { allSongs.add(it) }
333341
}
334342

343+
onProgress?.invoke(allSongs.size)
344+
335345
nextFromMessageId = response.nextFromMessageId
336346
if (nextFromMessageId == 0L) break
337347
}
@@ -373,7 +383,7 @@ class TelegramRepository @Inject constructor(
373383
suspend fun getApproxAudioMessageCount(chatId: Long): Int {
374384
return try {
375385
val result = clientManager.sendRequest<TdApi.Count>(
376-
TdApi.GetChatMessageCount(chatId, null, TdApi.SearchMessagesFilterAudio(), false)
386+
TdApi.GetChatMessageCount(chatId, TdApi.SearchMessagesFilterAudio(), false)
377387
)
378388
extractApproxCount(result)
379389
} catch (e: Exception) {

app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchSheet.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.compose.material3.FilledIconButton
3232
import androidx.compose.material3.Icon
3333
import androidx.compose.material3.IconButtonDefaults
3434
import androidx.compose.material3.LoadingIndicator
35+
import androidx.compose.material3.LinearWavyProgressIndicator
3536
import androidx.compose.material3.MaterialTheme
3637
import androidx.compose.material3.MediumExtendedFloatingActionButton
3738
import androidx.compose.material3.ModalBottomSheet
@@ -73,6 +74,7 @@ fun TelegramChannelSearchSheet(
7374
val foundChat by viewModel.foundChat.collectAsStateWithLifecycle()
7475
val songs by viewModel.songs.collectAsStateWithLifecycle()
7576
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
77+
val syncProgress by viewModel.syncProgress.collectAsStateWithLifecycle()
7678
val statusMessage by viewModel.statusMessage.collectAsStateWithLifecycle()
7779
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
7880
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -240,6 +242,47 @@ fun TelegramChannelSearchSheet(
240242
fontFamily = GoogleSansRounded,
241243
color = MaterialTheme.colorScheme.primary
242244
)
245+
// Two cases here, same as the dashboard sync UI:
246+
// - Known total >= 5000 (flat channel): determinate bar with
247+
// a percentage.
248+
// - Unknown total (forum topic fetch, approxTotal == -1): no
249+
// honest percentage available (see TelegramSyncProgress),
250+
// shown as an indeterminate bar with the running count
251+
// instead. Below 5000 with a known total, the indeterminate
252+
// LoadingIndicator above is enough since the fetch finishes
253+
// quickly anyway.
254+
val progress = syncProgress
255+
if (progress != null && (progress.approxTotal >= 5000 || progress.approxTotal == -1)) {
256+
Spacer(modifier = Modifier.height(16.dp))
257+
if (progress.approxTotal > 0) {
258+
val fraction = (progress.current.toFloat() / progress.approxTotal.toFloat())
259+
.coerceIn(0f, 1f)
260+
LinearWavyProgressIndicator(
261+
progress = { fraction },
262+
modifier = Modifier
263+
.fillMaxWidth()
264+
.padding(horizontal = 32.dp)
265+
)
266+
Spacer(modifier = Modifier.height(8.dp))
267+
Text(
268+
text = "${progress.current} / ${progress.approxTotal}",
269+
style = MaterialTheme.typography.labelMedium,
270+
color = MaterialTheme.colorScheme.onSurfaceVariant
271+
)
272+
} else {
273+
LinearWavyProgressIndicator(
274+
modifier = Modifier
275+
.fillMaxWidth()
276+
.padding(horizontal = 32.dp)
277+
)
278+
Spacer(modifier = Modifier.height(8.dp))
279+
Text(
280+
text = "${progress.current} songs so far",
281+
style = MaterialTheme.typography.labelMedium,
282+
color = MaterialTheme.colorScheme.onSurfaceVariant
283+
)
284+
}
285+
}
243286
}
244287
}
245288
statusMessage != null -> {

app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchViewModel.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
55
import com.theveloper.pixelplay.data.model.Song
66
import com.theveloper.pixelplay.data.repository.MusicRepository
77
import com.theveloper.pixelplay.data.telegram.TelegramRepository
8+
import com.theveloper.pixelplay.data.telegram.TelegramSyncProgress
89
import dagger.hilt.android.lifecycle.HiltViewModel
910
import kotlinx.coroutines.flow.MutableStateFlow
1011
import kotlinx.coroutines.flow.asStateFlow
@@ -40,6 +41,10 @@ class TelegramChannelSearchViewModel @Inject constructor(
4041
private val _isLoading = MutableStateFlow(false)
4142
val isLoading = _isLoading.asStateFlow()
4243

44+
// See TelegramSyncProgress. Null when no fetch is in progress.
45+
private val _syncProgress = MutableStateFlow<TelegramSyncProgress?>(null)
46+
val syncProgress = _syncProgress.asStateFlow()
47+
4348
// Status message for errors or "Not Found"
4449
private val _statusMessage = MutableStateFlow<String?>(null)
4550
val statusMessage = _statusMessage.asStateFlow()
@@ -97,7 +102,9 @@ class TelegramChannelSearchViewModel @Inject constructor(
97102
val isForum = telegramRepository.isForum(chatId)
98103
val chat = _foundChat.value ?: return@launch
99104

100-
val allSongs = telegramRepository.getAudioMessages(chatId)
105+
val allSongs = telegramRepository.getAudioMessages(chatId) { current, approxTotal ->
106+
_syncProgress.value = TelegramSyncProgress(current, approxTotal)
107+
}
101108
musicRepository.replaceTelegramSongsForChannel(chatId, allSongs)
102109

103110
var localPhotoPath: String? = null
@@ -124,7 +131,12 @@ class TelegramChannelSearchViewModel @Inject constructor(
124131
musicRepository.replaceTopicsForChannel(chatId, topics)
125132
var totalSongs = 0
126133
topics.forEach { topic ->
127-
val topicSongs = telegramRepository.getAudioMessagesByTopic(chatId, topic.threadId)
134+
_statusMessage.value = "Syncing topic \"${topic.name}\"..."
135+
val topicSongs = telegramRepository.getAudioMessagesByTopic(chatId, topic.threadId) { current ->
136+
// approxTotal = -1: no honest per-topic total available, see
137+
// the equivalent comment in TelegramDashboardViewModel.
138+
_syncProgress.value = TelegramSyncProgress(current, -1)
139+
}
128140
totalSongs += topicSongs.size
129141
musicRepository.replaceTelegramSongsForTopic(
130142
chatId = chatId,
@@ -158,6 +170,7 @@ class TelegramChannelSearchViewModel @Inject constructor(
158170
runCatching { musicRepository.requestTelegramUnifiedSync() }
159171
_songs.value = emptyList()
160172
_isLoading.value = false
173+
_syncProgress.value = null
161174
}
162175
}
163176
}
@@ -189,6 +202,7 @@ class TelegramChannelSearchViewModel @Inject constructor(
189202
_foundChat.value = null
190203
_songs.value = emptyList()
191204
_isLoading.value = false
205+
_syncProgress.value = null
192206
_statusMessage.value = null
193207
_resolvedUsername.value = null
194208
}

0 commit comments

Comments
 (0)