Skip to content

Commit 9a4f7dc

Browse files
authored
Merge pull request #298 from OpenHub-Store/desktop-download-impr
2 parents 6bb3759 + 784bdc5 commit 9a4f7dc

3 files changed

Lines changed: 136 additions & 75 deletions

File tree

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import zed.rainxch.core.domain.network.Downloader
2121

2222
class AndroidDownloader(
2323
private val context: Context,
24-
private val files: zed.rainxch.core.data.services.FileLocationsProvider
24+
private val files: FileLocationsProvider
2525
) : Downloader {
2626

2727
private val downloadManager by lazy {

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt

Lines changed: 129 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -7,110 +7,169 @@ import io.ktor.client.statement.*
77
import io.ktor.http.isSuccess
88
import io.ktor.utils.io.*
99
import 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
1014
import 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
1218
import kotlinx.coroutines.isActive
19+
import kotlinx.coroutines.launch
1320
import kotlinx.coroutines.withContext
1421
import zed.rainxch.core.domain.model.DownloadProgress
1522
import zed.rainxch.core.domain.network.Downloader
1623
import java.io.File
1724
import java.io.FileOutputStream
25+
import java.nio.ByteBuffer
1826
import java.util.UUID
27+
import java.util.concurrent.atomic.AtomicLong
28+
import kotlin.coroutines.cancellation.CancellationException
1929

2030
class 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())

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopFileLocationsProvider.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,29 @@ class DesktopFileLocationsProvider(
6262
}
6363

6464
override fun userDownloadsDir(): String {
65+
val appSubdirName = "GitHub Store Downloads"
6566
val downloadsDir = when (platform) {
6667
Platform.WINDOWS -> {
6768
val userProfile = System.getenv("USERPROFILE")
6869
?: System.getProperty("user.home")
69-
File(userProfile, "Downloads")
70+
File(userProfile, "Downloads").resolve(appSubdirName)
7071
}
7172
Platform.MACOS -> {
7273
val home = System.getProperty("user.home")
73-
File(home, "Downloads")
74+
File(home, "Downloads").resolve(appSubdirName)
7475
}
7576
Platform.LINUX -> {
7677
val xdgDownloads = getXdgDownloadsDir()
77-
if (xdgDownloads != null) {
78+
val baseDir = if (xdgDownloads != null) {
7879
File(xdgDownloads)
7980
} else {
8081
val home = System.getProperty("user.home")
8182
File(home, "Downloads")
8283
}
84+
baseDir.resolve(appSubdirName)
8385
}
8486
else -> {
85-
File(System.getProperty("user.home"), "Downloads")
87+
File(System.getProperty("user.home"), "Downloads").resolve(appSubdirName)
8688
}
8789
}
8890

0 commit comments

Comments
 (0)