Skip to content

Commit 73768f5

Browse files
authored
Merge branch 'main' into feature/assets-options
2 parents d2ba980 + ccc7dfb commit 73768f5

File tree

10 files changed

+294
-249
lines changed

10 files changed

+294
-249
lines changed
-22 Bytes
Binary file not shown.
-17 Bytes
Binary file not shown.

core/data/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ kotlin {
3333

3434
jvmMain {
3535
dependencies {
36-
implementation(libs.ktor.client.cio)
36+
implementation(libs.ktor.client.okhttp)
3737
}
3838
}
3939
}

core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ actual val corePlatformModule = module {
3232

3333
single<Downloader> {
3434
AndroidDownloader(
35-
context = get(),
36-
files = get()
35+
files = get(),
3736
)
3837
}
3938

core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import java.net.InetSocketAddress
66
import java.net.Proxy
77
import okhttp3.Credentials
88
import zed.rainxch.core.domain.model.ProxyConfig
9+
import java.net.Authenticator
910
import java.net.PasswordAuthentication
1011
import java.net.ProxySelector
1112

1213
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient {
13-
java.net.Authenticator.setDefault(null)
14+
Authenticator.setDefault(null)
1415

1516
return HttpClient(OkHttp) {
1617
engine {
@@ -54,7 +55,7 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient {
5455
)
5556

5657
if (proxyConfig.username != null) {
57-
java.net.Authenticator.setDefault(object : java.net.Authenticator() {
58+
Authenticator.setDefault(object : Authenticator() {
5859
override fun getPasswordAuthentication(): PasswordAuthentication? {
5960
if (requestingHost == proxyConfig.host &&
6061
requestingPort == proxyConfig.port

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

Lines changed: 126 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,161 @@
11
package zed.rainxch.core.data.services
22

3-
import android.app.DownloadManager
4-
import android.content.Context
5-
import android.net.Uri
63
import co.touchlab.kermit.Logger
74
import kotlinx.coroutines.Dispatchers
8-
import kotlinx.coroutines.currentCoroutineContext
95
import kotlinx.coroutines.flow.Flow
106
import kotlinx.coroutines.flow.flow
11-
import kotlinx.coroutines.isActive
7+
import kotlinx.coroutines.flow.flowOn
128
import kotlinx.coroutines.withContext
9+
import okhttp3.Call
10+
import okhttp3.Credentials
11+
import okhttp3.OkHttpClient
12+
import okhttp3.Request
13+
import zed.rainxch.core.data.network.ProxyManager
14+
import zed.rainxch.core.domain.model.DownloadProgress
15+
import zed.rainxch.core.domain.model.ProxyConfig
16+
import zed.rainxch.core.domain.network.Downloader
1317
import java.io.File
18+
import java.net.Authenticator
19+
import java.net.InetSocketAddress
20+
import java.net.PasswordAuthentication
21+
import java.net.Proxy
1422
import java.util.UUID
15-
import kotlinx.coroutines.delay
16-
import kotlinx.coroutines.flow.flowOn
17-
import zed.rainxch.core.domain.model.DownloadProgress
1823
import java.util.concurrent.ConcurrentHashMap
19-
import androidx.core.net.toUri
20-
import zed.rainxch.core.domain.network.Downloader
2124

2225
class AndroidDownloader(
23-
private val context: Context,
24-
private val files: FileLocationsProvider
26+
private val files: FileLocationsProvider,
27+
private val proxyManager: ProxyManager = ProxyManager
2528
) : Downloader {
2629

27-
private val downloadManager by lazy {
28-
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
29-
}
30+
private val activeDownloads = ConcurrentHashMap<String, Call>()
31+
private val activeFileNames = ConcurrentHashMap<String, String>()
32+
33+
private fun buildClient(): OkHttpClient {
34+
Authenticator.setDefault(null)
35+
36+
return OkHttpClient.Builder().apply {
37+
when (val config = proxyManager.currentProxyConfig.value) {
38+
is ProxyConfig.None -> proxy(Proxy.NO_PROXY)
39+
is ProxyConfig.System -> {}
40+
is ProxyConfig.Http -> {
41+
proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port)))
42+
if (config.username != null && config.password != null) {
43+
proxyAuthenticator { _, response ->
44+
response.request.newBuilder()
45+
.header(
46+
"Proxy-Authorization",
47+
Credentials.basic(config.username!!, config.password!!)
48+
)
49+
.build()
50+
}
51+
}
52+
}
3053

31-
private val activeDownloads = ConcurrentHashMap<String, Long>()
54+
is ProxyConfig.Socks -> {
55+
proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(config.host, config.port)))
56+
if (config.username != null && config.password != null) {
57+
Authenticator.setDefault(object : Authenticator() {
58+
override fun getPasswordAuthentication() =
59+
PasswordAuthentication(
60+
config.username,
61+
config.password!!.toCharArray()
62+
)
63+
})
64+
}
65+
}
66+
}
67+
}.build()
68+
}
3269

3370
override fun download(url: String, suggestedFileName: String?): Flow<DownloadProgress> = flow {
71+
val client = buildClient()
72+
3473
val dirPath = files.appDownloadsDir()
3574
val dir = File(dirPath)
3675
if (!dir.exists()) dir.mkdirs()
3776

38-
val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
39-
?: url.substringAfterLast('/').ifBlank { "asset-${UUID.randomUUID()}.apk" })
40-
41-
val tentativeDestination = File(dir, safeName)
77+
val rawName = suggestedFileName?.takeIf { it.isNotBlank() }
78+
?: url.substringAfterLast('/').substringBefore('?').substringBefore('#')
79+
.ifBlank { "asset-${UUID.randomUUID()}.apk" }
80+
val safeName = rawName.substringAfterLast('/').substringAfterLast('\\')
81+
require(safeName.isNotBlank() && safeName != "." && safeName != "..") {
82+
"Invalid file name: $rawName"
83+
}
4284

43-
if (tentativeDestination.exists()) {
44-
Logger.d { "Deleting existing file before download: ${tentativeDestination.absolutePath}" }
45-
tentativeDestination.delete()
85+
check(!activeFileNames.containsKey(safeName)) {
86+
"A download for '$safeName' is already in progress"
4687
}
4788

48-
Logger.d { "Starting download: $url" }
89+
val downloadId = UUID.randomUUID().toString()
4990

50-
val request = DownloadManager.Request(url.toUri()).apply {
51-
setTitle(safeName)
52-
setDescription("Downloading asset")
91+
val destination = File(dir, safeName)
92+
if (destination.exists()) {
93+
Logger.d { "Deleting existing file before download: ${destination.absolutePath}" }
94+
destination.delete()
95+
}
5396

54-
setDestinationInExternalFilesDir(context, null, "ghs_downloads/$safeName")
97+
Logger.d { "Starting download: $url (id=$downloadId)" }
5598

56-
setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
57-
setAllowedOverMetered(true)
58-
setAllowedOverRoaming(false)
59-
}
99+
val request = Request.Builder().url(url).build()
100+
val call = client.newCall(request)
60101

61-
val downloadId = downloadManager.enqueue(request)
62-
activeDownloads[safeName] = downloadId
102+
activeDownloads[downloadId] = call
103+
activeFileNames[safeName] = downloadId
63104

64105
try {
65-
var isDone = false
66-
while (!isDone && currentCoroutineContext().isActive) {
67-
val cursor =
68-
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
69-
if (cursor.moveToFirst()) {
70-
val status =
71-
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
72-
val downloaded =
73-
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
74-
val total =
75-
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
76-
val percent = if (total > 0) ((downloaded * 100L) / total).toInt() else null
77-
78-
when (status) {
79-
DownloadManager.STATUS_SUCCESSFUL -> {
80-
val localUriStr =
81-
cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI))
82-
val finalPath = Uri.parse(localUriStr).path
83-
?: throw IllegalStateException("Invalid local URI: $localUriStr")
84-
85-
val file = File(finalPath)
86-
87-
var attempts = 0
88-
while ((!file.exists() || file.length() == 0L) && attempts < 6) {
89-
delay(500L)
90-
attempts++
91-
emit(DownloadProgress(downloaded, total, 100))
92-
}
93-
if (!file.exists() || file.length() == 0L) {
94-
throw IllegalStateException("File not ready after timeout: $finalPath")
95-
}
96-
97-
Logger.d { "Download complete: $finalPath" }
98-
emit(DownloadProgress(downloaded, total, 100))
99-
isDone = true
100-
}
106+
call.execute().use { response ->
107+
if (!response.isSuccessful) {
108+
throw kotlinx.io.IOException("Unexpected code ${response.code}")
109+
}
101110

102-
DownloadManager.STATUS_FAILED -> {
103-
val reason =
104-
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
105-
Logger.e { "Download failed with reason: $reason" }
106-
throw IllegalStateException("Download failed: $reason")
111+
val body = response.body
112+
val contentLength = body.contentLength()
113+
val total = if (contentLength > 0) contentLength else null
114+
115+
body.byteStream().use { input ->
116+
destination.outputStream().use { output ->
117+
val buffer = ByteArray(8192)
118+
var downloaded: Long = 0
119+
var bytesRead: Int
120+
while (input.read(buffer).also { bytesRead = it } != -1) {
121+
output.write(buffer, 0, bytesRead)
122+
downloaded += bytesRead
123+
val percent =
124+
if (total != null) ((downloaded * 100L) / total).toInt() else null
125+
emit(DownloadProgress(downloaded, total, percent))
107126
}
108-
109-
else -> emit(
110-
DownloadProgress(
111-
downloaded,
112-
if (total >= 0) total else null,
113-
percent
114-
)
115-
)
116127
}
117-
} else {
118-
throw IllegalStateException("Download ID not found: $downloadId")
119128
}
120-
cursor.close()
121129

122-
if (!isDone) delay(500L)
130+
if (destination.exists() && destination.length() > 0) {
131+
Logger.d { "Download complete: ${destination.absolutePath}" }
132+
val finalDownloaded = destination.length()
133+
val finalPercent =
134+
if (total != null) ((finalDownloaded * 100L) / total).toInt() else 100
135+
emit(DownloadProgress(finalDownloaded, total, finalPercent))
136+
} else {
137+
throw IllegalStateException("File not ready after download: ${destination.absolutePath}")
138+
}
123139
}
140+
} catch (e: Exception) {
141+
destination.delete()
142+
Logger.e(e) { "Download failed" }
143+
throw e
124144
} finally {
125-
activeDownloads.remove(safeName)
145+
activeDownloads.remove(downloadId)
146+
activeFileNames.remove(safeName)
126147
}
127148
}.flowOn(Dispatchers.IO)
128149

129150
override suspend fun saveToFile(url: String, suggestedFileName: String?): String =
130151
withContext(Dispatchers.IO) {
131-
val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
132-
?: url.substringAfterLast('/').ifBlank { "asset-${UUID.randomUUID()}.apk" })
152+
val rawName = suggestedFileName?.takeIf { it.isNotBlank() }
153+
?: url.substringAfterLast('/').substringBefore('?').substringBefore('#')
154+
.ifBlank { "asset-${UUID.randomUUID()}.apk" }
155+
val safeName = rawName.substringAfterLast('/').substringAfterLast('\\')
156+
require(safeName.isNotBlank() && safeName != "." && safeName != "..") {
157+
"Invalid file name: $rawName"
158+
}
133159

134160
val file = File(files.appDownloadsDir(), safeName)
135161

@@ -157,14 +183,19 @@ class AndroidDownloader(
157183

158184
override suspend fun cancelDownload(fileName: String): Boolean =
159185
withContext(Dispatchers.IO) {
160-
161186
var cancelled = false
162187
var deleted = false
163188

164-
activeDownloads[fileName]?.let { downloadId: Long ->
165-
val removedCount = downloadManager.remove(downloadId)
166-
cancelled = removedCount > 0
167-
activeDownloads.remove(fileName)
189+
val downloadId = activeFileNames[fileName]
190+
if (downloadId != null) {
191+
activeDownloads[downloadId]?.let { call: Call ->
192+
if (!call.isCanceled()) {
193+
call.cancel()
194+
cancelled = true
195+
}
196+
activeDownloads.remove(downloadId)
197+
}
198+
activeFileNames.remove(fileName)
168199
}
169200

170201
val file = File(files.appDownloadsDir(), fileName)

core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/RateLimitInterceptor.kt

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
package zed.rainxch.core.data.network.interceptor
22

3+
import co.touchlab.kermit.Logger
34
import io.ktor.client.HttpClient
45
import io.ktor.client.plugins.HttpClientPlugin
56
import io.ktor.client.statement.HttpReceivePipeline
67
import io.ktor.client.statement.HttpResponse
78
import io.ktor.client.statement.HttpResponsePipeline
89
import io.ktor.http.Headers
910
import io.ktor.util.AttributeKey
11+
import zed.rainxch.core.domain.logging.GitHubStoreLogger
1012
import zed.rainxch.core.domain.model.RateLimitException
1113
import zed.rainxch.core.domain.model.RateLimitInfo
1214
import zed.rainxch.core.domain.repository.RateLimitRepository
1315

1416
class RateLimitInterceptor(
15-
private val rateLimitRepository: RateLimitRepository
17+
private val rateLimitRepository: RateLimitRepository,
1618
) {
17-
19+
1820
class Config {
1921
var rateLimitRepository: RateLimitRepository? = null
2022
}
@@ -28,7 +30,7 @@ class RateLimitInterceptor(
2830
return RateLimitInterceptor(
2931
rateLimitRepository = requireNotNull(config.rateLimitRepository) {
3032
"RateLimitRepository must be provided"
31-
}
33+
},
3234
)
3335
}
3436

@@ -43,25 +45,30 @@ class RateLimitInterceptor(
4345
throw RateLimitException(rateLimitInfo)
4446
}
4547
}
46-
48+
4749
proceedWith(subject)
4850
}
4951
}
50-
52+
5153
private fun parseRateLimitFromHeaders(headers: Headers): RateLimitInfo? {
5254
return try {
53-
val limit = headers["X-RateLimit-Limit"]?.toIntOrNull() ?: return null
54-
val remaining = headers["X-RateLimit-Remaining"]?.toIntOrNull() ?: return null
55-
val reset = headers["X-RateLimit-Reset"]?.toLongOrNull() ?: return null
55+
val limitHeader = headers["X-RateLimit-Limit"]
56+
?: return null.also { Logger.w { "Missing X-RateLimit-Limit" } }
57+
val limit = limitHeader.toIntOrNull()
58+
?: return null.also { Logger.w { "Malformed X-RateLimit-Limit: $limitHeader" } }
59+
val remainingHeader = headers["X-RateLimit-Remaining"]
60+
?: return null.also { Logger.w { "Missing X-RateLimit-Remaining" } }
61+
val remaining = remainingHeader.toIntOrNull()
62+
?: return null.also { Logger.w { "Malformed X-RateLimit-Remaining: $remainingHeader" } }
63+
val resetHeader = headers["X-RateLimit-Reset"]
64+
?: return null.also { Logger.w { "Missing X-RateLimit-Reset" } }
65+
val reset = resetHeader.toLongOrNull()
66+
?: return null.also { Logger.w { "Malformed X-RateLimit-Reset: $resetHeader" } }
5667
val resource = headers["X-RateLimit-Resource"] ?: "core"
5768

58-
RateLimitInfo(
59-
limit = limit,
60-
remaining = remaining,
61-
resetTimestamp = reset,
62-
resource = resource
63-
)
69+
RateLimitInfo(limit, remaining, reset, resource)
6470
} catch (e: Exception) {
71+
Logger.e(e) { "Failed to parse rate limit headers" }
6572
null
6673
}
6774
}

0 commit comments

Comments
 (0)