|
1 | 1 | package zed.rainxch.core.data.services |
2 | 2 |
|
3 | | -import android.app.DownloadManager |
4 | | -import android.content.Context |
5 | | -import android.net.Uri |
6 | 3 | import co.touchlab.kermit.Logger |
7 | 4 | import kotlinx.coroutines.Dispatchers |
8 | | -import kotlinx.coroutines.currentCoroutineContext |
9 | 5 | import kotlinx.coroutines.flow.Flow |
10 | 6 | import kotlinx.coroutines.flow.flow |
11 | | -import kotlinx.coroutines.isActive |
| 7 | +import kotlinx.coroutines.flow.flowOn |
12 | 8 | 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 |
13 | 17 | import java.io.File |
| 18 | +import java.net.Authenticator |
| 19 | +import java.net.InetSocketAddress |
| 20 | +import java.net.PasswordAuthentication |
| 21 | +import java.net.Proxy |
14 | 22 | import java.util.UUID |
15 | | -import kotlinx.coroutines.delay |
16 | | -import kotlinx.coroutines.flow.flowOn |
17 | | -import zed.rainxch.core.domain.model.DownloadProgress |
18 | 23 | import java.util.concurrent.ConcurrentHashMap |
19 | | -import androidx.core.net.toUri |
20 | | -import zed.rainxch.core.domain.network.Downloader |
21 | 24 |
|
22 | 25 | class AndroidDownloader( |
23 | | - private val context: Context, |
24 | | - private val files: FileLocationsProvider |
| 26 | + private val files: FileLocationsProvider, |
| 27 | + private val proxyManager: ProxyManager = ProxyManager |
25 | 28 | ) : Downloader { |
26 | 29 |
|
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 | + } |
30 | 53 |
|
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 | + } |
32 | 69 |
|
33 | 70 | override fun download(url: String, suggestedFileName: String?): Flow<DownloadProgress> = flow { |
| 71 | + val client = buildClient() |
| 72 | + |
34 | 73 | val dirPath = files.appDownloadsDir() |
35 | 74 | val dir = File(dirPath) |
36 | 75 | if (!dir.exists()) dir.mkdirs() |
37 | 76 |
|
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 | + } |
42 | 84 |
|
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" |
46 | 87 | } |
47 | 88 |
|
48 | | - Logger.d { "Starting download: $url" } |
| 89 | + val downloadId = UUID.randomUUID().toString() |
49 | 90 |
|
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 | + } |
53 | 96 |
|
54 | | - setDestinationInExternalFilesDir(context, null, "ghs_downloads/$safeName") |
| 97 | + Logger.d { "Starting download: $url (id=$downloadId)" } |
55 | 98 |
|
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) |
60 | 101 |
|
61 | | - val downloadId = downloadManager.enqueue(request) |
62 | | - activeDownloads[safeName] = downloadId |
| 102 | + activeDownloads[downloadId] = call |
| 103 | + activeFileNames[safeName] = downloadId |
63 | 104 |
|
64 | 105 | 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 | + } |
101 | 110 |
|
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)) |
107 | 126 | } |
108 | | - |
109 | | - else -> emit( |
110 | | - DownloadProgress( |
111 | | - downloaded, |
112 | | - if (total >= 0) total else null, |
113 | | - percent |
114 | | - ) |
115 | | - ) |
116 | 127 | } |
117 | | - } else { |
118 | | - throw IllegalStateException("Download ID not found: $downloadId") |
119 | 128 | } |
120 | | - cursor.close() |
121 | 129 |
|
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 | + } |
123 | 139 | } |
| 140 | + } catch (e: Exception) { |
| 141 | + destination.delete() |
| 142 | + Logger.e(e) { "Download failed" } |
| 143 | + throw e |
124 | 144 | } finally { |
125 | | - activeDownloads.remove(safeName) |
| 145 | + activeDownloads.remove(downloadId) |
| 146 | + activeFileNames.remove(safeName) |
126 | 147 | } |
127 | 148 | }.flowOn(Dispatchers.IO) |
128 | 149 |
|
129 | 150 | override suspend fun saveToFile(url: String, suggestedFileName: String?): String = |
130 | 151 | 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 | + } |
133 | 159 |
|
134 | 160 | val file = File(files.appDownloadsDir(), safeName) |
135 | 161 |
|
@@ -157,14 +183,19 @@ class AndroidDownloader( |
157 | 183 |
|
158 | 184 | override suspend fun cancelDownload(fileName: String): Boolean = |
159 | 185 | withContext(Dispatchers.IO) { |
160 | | - |
161 | 186 | var cancelled = false |
162 | 187 | var deleted = false |
163 | 188 |
|
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) |
168 | 199 | } |
169 | 200 |
|
170 | 201 | val file = File(files.appDownloadsDir(), fileName) |
|
0 commit comments