Skip to content

Commit 742a7a1

Browse files
committed
feat(android): true byte-level resume downloads, background service, dynamic greeting
- Replace DownloadManager with custom OkHttp FileDownloader using HTTP Range headers - Support true pause/resume at byte level using RandomAccessFile - Add DownloadService (foreground) for background downloads with progress notifications - Add pause/resume/cancel buttons to downloads UI - Make home screen greeting dynamic (Morning/Afternoon/Evening/Night) - Show logged-in user's profile name in greeting
1 parent 2f3284a commit 742a7a1

10 files changed

Lines changed: 720 additions & 313 deletions

File tree

android/app/src/main/AndroidManifest.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<!-- Background Audio Playback -->
1818
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1919
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
20+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
2021
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
2122

2223
<!-- TV-specific features -->
@@ -106,6 +107,12 @@
106107
<action android:name="androidx.media3.session.MediaSessionService" />
107108
</intent-filter>
108109
</service>
110+
111+
<!-- Background Download Service -->
112+
<service
113+
android:name=".service.DownloadService"
114+
android:exported="false"
115+
android:foregroundServiceType="dataSync" />
109116
</application>
110117

111118
</manifest>

android/app/src/main/java/com/telegramtv/di/NetworkModule.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import okhttp3.OkHttpClient
1717
import okhttp3.logging.HttpLoggingInterceptor
1818
import retrofit2.Retrofit
1919
import retrofit2.converter.gson.GsonConverterFactory
20+
import com.telegramtv.download.FileDownloader
2021
import java.util.concurrent.TimeUnit
2122
import javax.inject.Singleton
2223

@@ -27,6 +28,33 @@ import javax.inject.Singleton
2728
@InstallIn(SingletonComponent::class)
2829
object NetworkModule {
2930

31+
@Provides
32+
@Singleton
33+
fun provideFileDownloader(
34+
@dagger.hilt.android.qualifiers.ApplicationContext context: android.content.Context,
35+
authInterceptor: AuthInterceptor
36+
): FileDownloader {
37+
// Create a dedicated OkHttpClient for downloads:
38+
// - NO body logging (Level.BODY buffers entire response into memory, killing large downloads)
39+
// - Longer read timeout for large files
40+
// - Auth interceptor for automatic token handling
41+
val downloadLogging = HttpLoggingInterceptor().apply {
42+
level = HttpLoggingInterceptor.Level.HEADERS
43+
}
44+
val downloadClient = OkHttpClient.Builder()
45+
.addInterceptor(authInterceptor)
46+
.addInterceptor(downloadLogging)
47+
.connectTimeout(30, TimeUnit.SECONDS)
48+
.readTimeout(5, TimeUnit.MINUTES)
49+
.writeTimeout(60, TimeUnit.SECONDS)
50+
.build()
51+
52+
val scope = kotlinx.coroutines.CoroutineScope(
53+
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO
54+
)
55+
return FileDownloader(context, downloadClient, scope)
56+
}
57+
3058
@Provides
3159
@Singleton
3260
fun provideGson(): Gson {
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
package com.telegramtv.download
2+
3+
import android.content.Context
4+
import android.os.Environment
5+
import com.telegramtv.service.DownloadService
6+
import kotlinx.coroutines.*
7+
import kotlinx.coroutines.flow.MutableStateFlow
8+
import kotlinx.coroutines.flow.StateFlow
9+
import kotlinx.coroutines.flow.asStateFlow
10+
import okhttp3.OkHttpClient
11+
import okhttp3.Request
12+
import java.io.File
13+
import java.io.RandomAccessFile
14+
import java.util.concurrent.ConcurrentHashMap
15+
import java.util.concurrent.atomic.AtomicLong
16+
17+
/**
18+
* Status for each download task.
19+
*/
20+
enum class DownloadStatus {
21+
PENDING,
22+
RUNNING,
23+
PAUSED,
24+
COMPLETED,
25+
FAILED,
26+
CANCELLED
27+
}
28+
29+
/**
30+
* Represents one download task with its current state.
31+
*/
32+
data class DownloadTask(
33+
val id: Long,
34+
val fileId: Int,
35+
val fileName: String,
36+
val url: String,
37+
val mimeType: String? = null,
38+
val status: DownloadStatus = DownloadStatus.PENDING,
39+
val downloadedBytes: Long = 0L,
40+
val totalBytes: Long = -1L,
41+
val speed: Long = 0L, // bytes per second
42+
val error: String? = null,
43+
val localPath: String? = null
44+
)
45+
46+
/**
47+
* Custom file downloader that supports true pause/resume using HTTP Range headers.
48+
*
49+
* Uses OkHttp for HTTP requests and RandomAccessFile for writing at specific offsets.
50+
* Downloads are saved to the device's public Downloads directory.
51+
*/
52+
class FileDownloader(
53+
private val context: Context,
54+
private val okHttpClient: OkHttpClient,
55+
private val scope: CoroutineScope
56+
) {
57+
private val _tasks = MutableStateFlow<Map<Long, DownloadTask>>(emptyMap())
58+
val tasks: StateFlow<Map<Long, DownloadTask>> = _tasks.asStateFlow()
59+
60+
private val activeJobs = ConcurrentHashMap<Long, Job>()
61+
private val nextId = AtomicLong(1L)
62+
63+
// Speed tracking
64+
private val lastBytesMap = ConcurrentHashMap<Long, Long>()
65+
private val lastTimeMap = ConcurrentHashMap<Long, Long>()
66+
67+
/**
68+
* Enqueue a new download. Returns the download task ID.
69+
*/
70+
fun enqueue(fileId: Int, fileName: String, url: String, mimeType: String? = null): Long {
71+
val id = nextId.getAndIncrement()
72+
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
73+
val localPath = File(downloadsDir, fileName).absolutePath
74+
75+
val task = DownloadTask(
76+
id = id,
77+
fileId = fileId,
78+
fileName = fileName,
79+
url = url,
80+
mimeType = mimeType,
81+
status = DownloadStatus.PENDING,
82+
localPath = localPath
83+
)
84+
85+
updateTask(task)
86+
startDownload(task)
87+
88+
// Start foreground service to keep downloads alive in background
89+
try { DownloadService.start(context) } catch (_: Exception) {}
90+
91+
return id
92+
}
93+
94+
/**
95+
* Pause a running download.
96+
*/
97+
fun pause(id: Long) {
98+
val task = _tasks.value[id] ?: return
99+
if (task.status != DownloadStatus.RUNNING && task.status != DownloadStatus.PENDING) return
100+
101+
// Cancel the coroutine job - this stops the download loop
102+
activeJobs[id]?.cancel()
103+
activeJobs.remove(id)
104+
lastBytesMap.remove(id)
105+
lastTimeMap.remove(id)
106+
107+
// Update status - the partial file remains on disk
108+
updateTask(task.copy(status = DownloadStatus.PAUSED, speed = 0L))
109+
}
110+
111+
/**
112+
* Resume a paused download from where it left off.
113+
*/
114+
fun resume(id: Long) {
115+
val task = _tasks.value[id] ?: return
116+
if (task.status != DownloadStatus.PAUSED && task.status != DownloadStatus.FAILED) return
117+
118+
// Check how many bytes are already on disk
119+
val file = File(task.localPath ?: return)
120+
val existingBytes = if (file.exists()) file.length() else 0L
121+
122+
val updatedTask = task.copy(
123+
status = DownloadStatus.PENDING,
124+
downloadedBytes = existingBytes,
125+
error = null
126+
)
127+
updateTask(updatedTask)
128+
startDownload(updatedTask)
129+
130+
// Start foreground service to keep downloads alive in background
131+
try { DownloadService.start(context) } catch (_: Exception) {}
132+
}
133+
134+
/**
135+
* Cancel and remove a download, deleting any partial file.
136+
*/
137+
fun cancel(id: Long) {
138+
activeJobs[id]?.cancel()
139+
activeJobs.remove(id)
140+
lastBytesMap.remove(id)
141+
lastTimeMap.remove(id)
142+
143+
val task = _tasks.value[id]
144+
task?.localPath?.let { path ->
145+
val file = File(path)
146+
if (file.exists() && task.status != DownloadStatus.COMPLETED) {
147+
file.delete()
148+
}
149+
}
150+
151+
val currentTasks = _tasks.value.toMutableMap()
152+
currentTasks.remove(id)
153+
_tasks.value = currentTasks
154+
}
155+
156+
/**
157+
* Delete a completed download's file.
158+
*/
159+
fun deleteFile(id: Long) {
160+
val task = _tasks.value[id] ?: return
161+
task.localPath?.let { path ->
162+
File(path).delete()
163+
}
164+
val currentTasks = _tasks.value.toMutableMap()
165+
currentTasks.remove(id)
166+
_tasks.value = currentTasks
167+
}
168+
169+
/**
170+
* Start the actual download coroutine for a task.
171+
*/
172+
private fun startDownload(task: DownloadTask) {
173+
val job = scope.launch(Dispatchers.IO) {
174+
try {
175+
val file = File(task.localPath ?: return@launch)
176+
file.parentFile?.mkdirs()
177+
178+
// Determine how many bytes we already have (for resume)
179+
val existingBytes = if (file.exists()) file.length() else 0L
180+
181+
// Build request with Range header if resuming
182+
val requestBuilder = Request.Builder().url(task.url)
183+
if (existingBytes > 0) {
184+
requestBuilder.addHeader("Range", "bytes=$existingBytes-")
185+
}
186+
187+
val response = okHttpClient.newCall(requestBuilder.build()).execute()
188+
189+
if (!response.isSuccessful && response.code != 206) {
190+
updateTask(task.copy(
191+
status = DownloadStatus.FAILED,
192+
error = "HTTP ${response.code}: ${response.message}"
193+
))
194+
return@launch
195+
}
196+
197+
val body = response.body ?: run {
198+
updateTask(task.copy(
199+
status = DownloadStatus.FAILED,
200+
error = "Empty response body"
201+
))
202+
return@launch
203+
}
204+
205+
// Calculate total size
206+
val contentLength = body.contentLength()
207+
val totalBytes = if (response.code == 206) {
208+
// Partial content - total = existing + remaining
209+
existingBytes + contentLength
210+
} else {
211+
// Full response (server didn't support Range, or fresh download)
212+
contentLength
213+
}
214+
215+
val startOffset = if (response.code == 206) existingBytes else 0L
216+
217+
updateTask(task.copy(
218+
status = DownloadStatus.RUNNING,
219+
downloadedBytes = startOffset,
220+
totalBytes = totalBytes
221+
))
222+
223+
// Write using RandomAccessFile for seek support
224+
val raf = RandomAccessFile(file, "rw")
225+
raf.seek(startOffset)
226+
227+
val buffer = ByteArray(65536) // 64KB buffer for good throughput
228+
var bytesWritten = startOffset
229+
val inputStream = body.byteStream()
230+
231+
lastBytesMap[task.id] = bytesWritten
232+
lastTimeMap[task.id] = System.currentTimeMillis()
233+
var lastUpdateTime = System.currentTimeMillis()
234+
235+
inputStream.use { stream ->
236+
while (isActive) {
237+
val bytesRead = stream.read(buffer)
238+
if (bytesRead == -1) break
239+
240+
raf.write(buffer, 0, bytesRead)
241+
bytesWritten += bytesRead
242+
243+
// Throttle UI updates to every 500ms to avoid excessive StateFlow emissions
244+
val now = System.currentTimeMillis()
245+
if (now - lastUpdateTime >= 500) {
246+
val lastBytes = lastBytesMap[task.id] ?: bytesWritten
247+
val lastTime = lastTimeMap[task.id] ?: now
248+
val timeDelta = (now - lastTime).coerceAtLeast(1)
249+
val speed = ((bytesWritten - lastBytes) * 1000) / timeDelta
250+
251+
lastBytesMap[task.id] = bytesWritten
252+
lastTimeMap[task.id] = now
253+
lastUpdateTime = now
254+
255+
updateTask(_tasks.value[task.id]?.copy(
256+
status = DownloadStatus.RUNNING,
257+
downloadedBytes = bytesWritten,
258+
totalBytes = totalBytes,
259+
speed = speed
260+
) ?: return@launch)
261+
}
262+
}
263+
}
264+
265+
raf.close()
266+
response.close()
267+
268+
// Check if completed or cancelled
269+
if (isActive) {
270+
updateTask(_tasks.value[task.id]?.copy(
271+
status = DownloadStatus.COMPLETED,
272+
downloadedBytes = bytesWritten,
273+
totalBytes = if (totalBytes > 0) totalBytes else bytesWritten,
274+
speed = 0L
275+
) ?: return@launch)
276+
}
277+
278+
} catch (e: CancellationException) {
279+
// Paused by user - don't update to FAILED
280+
throw e
281+
} catch (e: Exception) {
282+
updateTask(_tasks.value[task.id]?.copy(
283+
status = DownloadStatus.FAILED,
284+
error = e.message ?: "Download failed",
285+
speed = 0L
286+
) ?: return@launch)
287+
} finally {
288+
activeJobs.remove(task.id)
289+
lastBytesMap.remove(task.id)
290+
lastTimeMap.remove(task.id)
291+
}
292+
}
293+
294+
activeJobs[task.id] = job
295+
}
296+
297+
private fun updateTask(task: DownloadTask) {
298+
val currentTasks = _tasks.value.toMutableMap()
299+
currentTasks[task.id] = task
300+
_tasks.value = currentTasks
301+
}
302+
}

0 commit comments

Comments
 (0)