@@ -23,53 +23,77 @@ import us.shandian.giga.service.MissionState
2323import kotlin.coroutines.resume
2424import kotlin.coroutines.resumeWithException
2525
26- sealed interface DownloadStatus {
27- data object None : DownloadStatus
28- data class InProgress ( val running : Boolean ) : DownloadStatus
29- data class Completed ( val info : CompletedDownload ) : DownloadStatus
26+ enum class DownloadStage {
27+ Pending ,
28+ Running ,
29+ Finished
3030}
3131
32- data class CompletedDownload (
32+ data class DownloadHandle (
33+ val serviceId : Int ,
34+ val url : String ,
35+ val streamUid : Long ,
36+ val storageUri : Uri ? ,
37+ val timestamp : Long ,
38+ val kind : Char
39+ )
40+
41+ data class DownloadEntry (
42+ val handle : DownloadHandle ,
3343 val displayName : String? ,
3444 val qualityLabel : String? ,
3545 val mimeType : String? ,
3646 val fileUri : Uri ? ,
3747 val parentUri : Uri ? ,
38- val fileAvailable : Boolean
48+ val fileAvailable : Boolean ,
49+ val stage : DownloadStage
3950)
4051
4152object DownloadStatusRepository {
4253
43- fun observe (context : Context , serviceId : Int , url : String ): Flow <DownloadStatus > = callbackFlow {
54+ /* *
55+ * Keeps a one-off binding to [DownloadManagerService] alive for as long as the caller stays
56+ * subscribed. We prime the channel with the latest persisted snapshot and then forward every
57+ * mission event emitted by the service-bound handler. Once the consumer cancels the flow we
58+ * make sure to unregister the handler and unbind the service to avoid leaking the connection.
59+ */
60+ fun observe (context : Context , serviceId : Int , url : String ): Flow <List <DownloadEntry >> = callbackFlow {
4461 if (serviceId < 0 || url.isBlank()) {
45- trySend(DownloadStatus . None )
62+ trySend(emptyList() )
4663 close()
4764 return @callbackFlow
4865 }
4966
5067 val appContext = context.applicationContext
5168 val intent = Intent (appContext, DownloadManagerService ::class .java)
5269 appContext.startService(intent)
70+ // The download manager service only notifies listeners while a client is bound, so the flow
71+ // keeps a foreground-style binding alive for its entire lifetime. Holding on to
72+ // applicationContext avoids leaking short-lived UI contexts.
5373 var binder: DownloadManagerBinder ? = null
5474 var registeredCallback: Handler .Callback ? = null
5575
5676 val connection = object : ServiceConnection {
5777 override fun onServiceConnected (name : ComponentName ? , service : IBinder ? ) {
5878 val downloadBinder = service as ? DownloadManagerBinder
5979 if (downloadBinder == null ) {
60- trySend(DownloadStatus . None )
80+ trySend(emptyList() )
6181 appContext.unbindService(this )
6282 close()
6383 return
6484 }
6585 binder = downloadBinder
66- trySend(downloadBinder.getDownloadStatus(serviceId, url, false ).toDownloadStatus())
86+ // First delivery: snapshot persisted on disk so the UI paints immediately even
87+ // before the service emits new events.
88+ trySend(downloadBinder.getDownloadStatuses(serviceId, url, true ).toDownloadEntries())
6789
6890 val callback = Handler .Callback { message: Message ->
6991 val mission = message.obj
7092 if (mission.matches(serviceId, url)) {
71- val snapshot = downloadBinder.getDownloadStatus(serviceId, url, false )
72- trySend(snapshot.toDownloadStatus())
93+ // Each mission event carries opaque state, so we fetch a fresh snapshot to
94+ // guarantee consistent entries while the download progresses or finishes.
95+ val snapshots = downloadBinder.getDownloadStatuses(serviceId, url, false )
96+ trySend(snapshots.toDownloadEntries())
7397 }
7498 false
7599 }
@@ -80,48 +104,57 @@ object DownloadStatusRepository {
80104 override fun onServiceDisconnected (name : ComponentName ? ) {
81105 registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) }
82106 binder = null
83- trySend(DownloadStatus . None )
107+ trySend(emptyList() )
84108 }
85109 }
86110
87111 val bound = appContext.bindService(intent, connection, Context .BIND_AUTO_CREATE )
88112 if (! bound) {
89- trySend(DownloadStatus . None )
113+ trySend(emptyList() )
90114 close()
91115 return @callbackFlow
92116 }
93117
94118 awaitClose {
119+ // When the collector disappears we remove listeners and unbind immediately to avoid
120+ // holding the service forever; the service will rebind on the next subscription.
95121 registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) }
96122 runCatching { appContext.unbindService(connection) }
97123 }
98124 }
99125
100- suspend fun refresh (context : Context , serviceId : Int , url : String ): DownloadStatus {
101- if (serviceId < 0 || url.isBlank()) return DownloadStatus . None
126+ suspend fun refresh (context : Context , serviceId : Int , url : String ): List < DownloadEntry > {
127+ if (serviceId < 0 || url.isBlank()) return emptyList()
102128 return withBinder(context) { binder ->
103- binder.getDownloadStatus (serviceId, url, true ).toDownloadStatus ()
129+ binder.getDownloadStatuses (serviceId, url, true ).toDownloadEntries ()
104130 }
105131 }
106132
107- suspend fun deleteFile (context : Context , serviceId : Int , url : String ): Boolean {
108- if (serviceId < 0 || url.isBlank()) return false
133+ suspend fun deleteFile (context : Context , handle : DownloadHandle ): Boolean {
134+ if (handle. serviceId < 0 || handle. url.isBlank()) return false
109135 return withBinder(context) { binder ->
110- binder.deleteFinishedMission(serviceId, url, true )
136+ binder.deleteFinishedMission(handle. serviceId, handle. url, handle.storageUri, handle.timestamp , true )
111137 }
112138 }
113139
114- suspend fun removeLink (context : Context , serviceId : Int , url : String ): Boolean {
115- if (serviceId < 0 || url.isBlank()) return false
140+ suspend fun removeLink (context : Context , handle : DownloadHandle ): Boolean {
141+ if (handle. serviceId < 0 || handle. url.isBlank()) return false
116142 return withBinder(context) { binder ->
117- binder.deleteFinishedMission(serviceId, url, false )
143+ binder.deleteFinishedMission(handle. serviceId, handle. url, handle.storageUri, handle.timestamp , false )
118144 }
119145 }
120146
147+ /* *
148+ * Helper that briefly binds to [DownloadManagerService], executes [block] against its binder
149+ * and tears everything down in one place. All callers should use this to prevent scattering
150+ * ad-hoc bind/unbind logic across the codebase.
151+ */
121152 private suspend fun <T > withBinder (context : Context , block : (DownloadManagerBinder ) -> T ): T {
122153 val appContext = context.applicationContext
123154 val intent = Intent (appContext, DownloadManagerService ::class .java)
124155 appContext.startService(intent)
156+ // The direct call path still needs the service running long enough to complete the
157+ // binder transaction, so we explicitly start it before establishing the short-lived bind.
125158 return suspendCancellableCoroutine { continuation ->
126159 val connection = object : ServiceConnection {
127160 override fun onServiceConnected (name : ComponentName ? , service : IBinder ? ) {
@@ -176,32 +209,83 @@ object DownloadStatusRepository {
176209
177210 @VisibleForTesting
178211 @MainThread
179- internal fun DownloadManager.DownloadStatusSnapshot?.toDownloadStatus (): DownloadStatus {
180- if (this == null || state == MissionState .None ) {
181- return DownloadStatus .None
212+ internal fun List<DownloadManager.DownloadStatusSnapshot>.toDownloadEntries (): List <DownloadEntry > {
213+ return buildList {
214+ for (snapshot in this @toDownloadEntries) {
215+ snapshot.toDownloadEntry()?.let { add(it) }
216+ }
182217 }
183- return when (state) {
184- MissionState .Pending , MissionState .PendingRunning ->
185- DownloadStatus .InProgress (state == MissionState .PendingRunning )
186- MissionState .Finished -> {
187- val mission = finishedMission
188- if (mission == null ) {
189- DownloadStatus .None
190- } else {
191- val storage = mission.storage
192- val hasStorage = storage != null && ! storage.isInvalid()
193- val info = CompletedDownload (
194- displayName = storage?.getName(),
195- qualityLabel = mission.qualityLabel,
196- mimeType = if (hasStorage) storage!! .getType() else null ,
197- fileUri = if (hasStorage) storage!! .getUri() else null ,
198- parentUri = if (hasStorage) storage!! .getParentUri() else null ,
199- fileAvailable = fileExists && hasStorage
200- )
201- DownloadStatus .Completed (info)
202- }
218+ }
219+
220+ @VisibleForTesting
221+ @MainThread
222+ internal fun DownloadManager.DownloadStatusSnapshot.toDownloadEntry (): DownloadEntry ? {
223+ val stage = when (state) {
224+ MissionState .Pending -> DownloadStage .Pending
225+ MissionState .PendingRunning -> DownloadStage .Running
226+ MissionState .Finished -> DownloadStage .Finished
227+ else -> return null
228+ }
229+
230+ val (metadata, storage) = when (stage) {
231+ DownloadStage .Finished -> finishedMission?.let {
232+ MissionMetadata (
233+ serviceId = it.serviceId,
234+ url = it.source,
235+ streamUid = it.streamUid,
236+ timestamp = it.timestamp,
237+ kind = it.kind,
238+ qualityLabel = it.qualityLabel
239+ ) to it.storage
203240 }
204- else -> DownloadStatus .None
241+ else -> pendingMission?.let {
242+ MissionMetadata (
243+ serviceId = it.serviceId,
244+ url = it.source,
245+ streamUid = it.streamUid,
246+ timestamp = it.timestamp,
247+ kind = it.kind,
248+ qualityLabel = it.qualityLabel
249+ ) to it.storage
250+ }
251+ } ? : return null
252+
253+ val hasStorage = storage != null && ! storage.isInvalid()
254+ val fileUri = storage?.getUri()
255+ val parentUri = storage?.getParentUri()
256+
257+ val handle = DownloadHandle (
258+ serviceId = metadata.serviceId,
259+ url = metadata.url ? : " " ,
260+ streamUid = metadata.streamUid,
261+ storageUri = fileUri,
262+ timestamp = metadata.timestamp,
263+ kind = metadata.kind
264+ )
265+
266+ val fileAvailable = when (stage) {
267+ DownloadStage .Finished -> hasStorage && fileExists
268+ DownloadStage .Pending , DownloadStage .Running -> false
205269 }
270+
271+ return DownloadEntry (
272+ handle = handle,
273+ displayName = storage?.getName(),
274+ qualityLabel = metadata.qualityLabel,
275+ mimeType = if (hasStorage) storage.getType() else null ,
276+ fileUri = fileUri,
277+ parentUri = parentUri,
278+ fileAvailable = fileAvailable,
279+ stage = stage
280+ )
206281 }
282+
283+ private data class MissionMetadata (
284+ val serviceId : Int ,
285+ val url : String? ,
286+ val streamUid : Long ,
287+ val timestamp : Long ,
288+ val kind : Char ,
289+ val qualityLabel : String?
290+ )
207291}
0 commit comments