@@ -7,110 +7,169 @@ import io.ktor.client.statement.*
77import io.ktor.http.isSuccess
88import io.ktor.utils.io.*
99import kotlinx.coroutines.Dispatchers
10+ import kotlinx.coroutines.channels.Channel
11+ import kotlinx.coroutines.channels.awaitClose
12+ import kotlinx.coroutines.coroutineScope
13+ import kotlinx.coroutines.delay
1014import kotlinx.coroutines.flow.Flow
11- import kotlinx.coroutines.flow.channelFlow
15+ import kotlinx.coroutines.flow.buffer
16+ import kotlinx.coroutines.flow.callbackFlow
17+ import kotlinx.coroutines.flow.flowOn
1218import kotlinx.coroutines.isActive
19+ import kotlinx.coroutines.launch
1320import kotlinx.coroutines.withContext
1421import zed.rainxch.core.domain.model.DownloadProgress
1522import zed.rainxch.core.domain.network.Downloader
1623import java.io.File
1724import java.io.FileOutputStream
25+ import java.nio.ByteBuffer
1826import java.util.UUID
27+ import java.util.concurrent.atomic.AtomicLong
28+ import kotlin.coroutines.cancellation.CancellationException
1929
2030class DesktopDownloader (
2131 private val http : HttpClient ,
2232 private val files : FileLocationsProvider ,
2333) : Downloader {
2434
25- override fun download (url : String , suggestedFileName : String? ): Flow <DownloadProgress > = channelFlow {
26- withContext(Dispatchers .IO ) {
27- val dir = File (files.userDownloadsDir())
28- if (! dir.exists()) dir.mkdirs()
29-
30- val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
31- ? : url.substringAfterLast(' /' )
32- .ifBlank { " asset-${UUID .randomUUID()} " })
33- val outFile = File (dir, safeName)
34-
35- if (outFile.exists()) {
36- Logger .d { " Deleting existing file before download: ${outFile.absolutePath} " }
37- outFile.delete()
38- }
39-
40- Logger .d { " Downloading: $url to ${outFile.absolutePath} " }
35+ override fun download (url : String , suggestedFileName : String? ): Flow <DownloadProgress > =
36+ callbackFlow {
37+ coroutineScope {
38+ val dir = File (files.userDownloadsDir())
39+ if (! dir.exists()) dir.mkdirs()
40+
41+ val rawName = suggestedFileName?.takeIf { it.isNotBlank() }
42+ ? : url.substringAfterLast(' /' ).substringBefore(' ?' ).substringBefore(' #' )
43+ .ifBlank { " asset-${UUID .randomUUID()} " }
44+ val safeName = rawName.substringAfterLast(' /' ).substringAfterLast(' \\ ' )
45+ require(safeName.isNotBlank() && safeName != " ." && safeName != " .." ) {
46+ " Invalid file name: $rawName "
47+ }
48+ val outFile = File (dir, safeName)
4149
42- val response : HttpResponse = http.get(url)
43- if ( ! response.status.isSuccess()) {
44- throw IllegalStateException ( " Download failed: HTTP ${response.status.value} " )
45- }
50+ if (outFile.exists()) {
51+ Logger .d { " Deleting existing file before download: ${outFile.absolutePath} " }
52+ outFile.delete( )
53+ }
4654
47- val total = response.headers[" Content-Length" ]?.toLongOrNull()
48- val channel = response.bodyAsChannel()
55+ Logger .d { " Downloading: $url to ${outFile.absolutePath} " }
4956
50- try {
51- FileOutputStream (outFile).use { fos ->
52- val buffer = ByteArray (DEFAULT_BUFFER_SIZE )
53- var downloaded = 0L
57+ val response: HttpResponse = http.get(url)
58+ if (! response.status.isSuccess()) {
59+ close(IllegalStateException (" Download failed: HTTP ${response.status.value} " ))
60+ return @coroutineScope
61+ }
5462
55- while (isActive) {
56- val read = channel.readAvailable(buffer, 0 , buffer.size)
57- if (read == - 1 ) break
58- fos.write(buffer, 0 , read)
59- downloaded + = read
63+ val total = response.headers[" Content-Length" ]?.toLongOrNull()
64+ val channel = response.bodyAsChannel()
65+
66+ val downloaded = AtomicLong (0L )
67+
68+ trySend(DownloadProgress (0L , total, if (total != null && total > 0 ) 0 else null ))
69+
70+ val downloadJob = launch(Dispatchers .IO ) {
71+ try {
72+ FileOutputStream (outFile).use { fos ->
73+ val fc = fos.channel
74+
75+ while (isActive) {
76+ val buffer = ByteArray (DEFAULT_BUFFER_SIZE )
77+ val bytesRead = channel.readAvailable(buffer, 0 , buffer.size)
78+ if (bytesRead == - 1 ) break
79+
80+ if (bytesRead > 0 ) {
81+ val byteBuffer = ByteBuffer .wrap(buffer, 0 , bytesRead)
82+ while (byteBuffer.hasRemaining()) {
83+ fc.write(byteBuffer)
84+ }
85+ downloaded.addAndGet(bytesRead.toLong())
86+ }
87+ }
88+ }
89+ Logger .d { " File write complete: ${outFile.absolutePath} " }
90+ } catch (e: CancellationException ) {
91+ if (outFile.exists()) {
92+ outFile.delete()
93+ Logger .d { " Deleted partial file after cancellation: ${outFile.absolutePath} " }
94+ }
95+ throw e
96+ } catch (e: Exception ) {
97+ if (outFile.exists()) {
98+ outFile.delete()
99+ }
100+ throw e
101+ }
102+ }
60103
104+ val progressJob = launch {
105+ while (isActive && downloadJob.isActive) {
106+ val current = downloaded.get()
61107 val percent = if (total != null && total > 0 ) {
62- ((downloaded * 100L ) / total).toInt()
108+ ((current * 100L ) / total).toInt()
63109 } else null
64-
65- trySend( DownloadProgress (downloaded, total, percent) )
110+ trySend( DownloadProgress (current, total, percent))
111+ delay( 50L )
66112 }
67- fos.flush()
68113 }
69114
70- Logger .d { " Download complete: ${outFile.absolutePath} " }
71-
72- trySend(DownloadProgress (total ? : outFile.length(), total, 100 ))
73- } catch (e: CancellationException ) {
74- if (outFile.exists()) {
75- outFile.delete()
76- Logger .d { " Deleted partial file after cancellation: ${outFile.absolutePath} " }
115+ try {
116+ downloadJob.join()
117+ progressJob.cancel()
118+
119+ val finalDownloaded = total ? : outFile.length()
120+ trySend(DownloadProgress (finalDownloaded, total, 100 ))
121+ Logger .d { " Download complete: ${outFile.absolutePath} " }
122+
123+ close()
124+ } catch (e: CancellationException ) {
125+ downloadJob.cancel()
126+ progressJob.cancel()
127+ close(e)
128+ } catch (e: Exception ) {
129+ downloadJob.cancel()
130+ progressJob.cancel()
131+ close(e)
77132 }
78- throw e
79- } finally {
80- close()
81133 }
82- }
83- }
84134
85- override suspend fun saveToFile (url : String , suggestedFileName : String? ): String = withContext(Dispatchers .IO ) {
86- val dir = File (files.userDownloadsDir())
87- val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
88- ? : url.substringAfterLast(' /' )
89- .ifBlank { " asset-${UUID .randomUUID()} " })
135+ awaitClose { }
136+ }.flowOn(Dispatchers .Default ).buffer(Channel .CONFLATED )
90137
91- val outFile = File (dir, safeName)
138+ override suspend fun saveToFile (url : String , suggestedFileName : String? ): String =
139+ withContext(Dispatchers .IO ) {
140+ val dir = File (files.userDownloadsDir())
141+ val rawName = suggestedFileName?.takeIf { it.isNotBlank() }
142+ ? : url.substringAfterLast(' /' ).substringBefore(' ?' ).substringBefore(' #' )
143+ .ifBlank { " asset-${UUID .randomUUID()} " }
144+ val safeName = rawName.substringAfterLast(' /' ).substringAfterLast(' \\ ' )
145+ require(safeName.isNotBlank() && safeName != " ." && safeName != " .." ) {
146+ " Invalid file name: $rawName "
147+ }
92148
93- if (outFile.exists()) {
94- Logger .d { " Deleting existing file before download: ${outFile.absolutePath} " }
95- outFile.delete()
96- }
149+ val outFile = File (dir, safeName)
150+
151+ if (outFile.exists()) {
152+ Logger .d { " Deleting existing file before download: ${outFile.absolutePath} " }
153+ outFile.delete()
154+ }
97155
98- Logger .d { " saveToFile downloading file..." }
99- download(url, suggestedFileName).collect { }
156+ Logger .d { " saveToFile downloading file..." }
157+ download(url, suggestedFileName).collect { }
100158
101- outFile.absolutePath
102- }
159+ outFile.absolutePath
160+ }
103161
104- override suspend fun getDownloadedFilePath (fileName : String ): String? = withContext(Dispatchers .IO ) {
105- val dir = File (files.userDownloadsDir())
106- val file = File (dir, fileName)
162+ override suspend fun getDownloadedFilePath (fileName : String ): String? =
163+ withContext(Dispatchers .IO ) {
164+ val dir = File (files.userDownloadsDir())
165+ val file = File (dir, fileName)
107166
108- if (file.exists() && file.length() > 0 ) {
109- file.absolutePath
110- } else {
111- null
167+ if (file.exists() && file.length() > 0 ) {
168+ file.absolutePath
169+ } else {
170+ null
171+ }
112172 }
113- }
114173
115174 override suspend fun cancelDownload (fileName : String ): Boolean = withContext(Dispatchers .IO ) {
116175 val dir = File (files.userDownloadsDir())
0 commit comments