diff --git a/Android/project/app/src/main/kotlin/com/xmedia/archive/data/ArchiveDao.kt b/Android/project/app/src/main/kotlin/com/xmedia/archive/data/ArchiveDao.kt index c492b5f7..946b9a12 100644 --- a/Android/project/app/src/main/kotlin/com/xmedia/archive/data/ArchiveDao.kt +++ b/Android/project/app/src/main/kotlin/com/xmedia/archive/data/ArchiveDao.kt @@ -36,8 +36,8 @@ interface ArchiveDao { @Query("DELETE FROM media WHERE jobId = :jobId") suspend fun deleteMedia(jobId: String) - @Query("DELETE FROM jobs WHERE status IN ('completed', 'failed', 'canceled')") - suspend fun deleteHistory(): Int + @Query("DELETE FROM jobs WHERE id IN (:ids) AND status IN ('completed', 'failed', 'canceled')") + suspend fun deleteTerminalJobs(ids: List): Int @Query("DELETE FROM media WHERE jobId NOT IN (SELECT id FROM jobs)") suspend fun deleteOrphanMedia() @@ -45,6 +45,9 @@ interface ArchiveDao { @Query("UPDATE jobs SET status = 'queued', progress = 0, error = NULL, updatedAt = :updatedAt WHERE status IN ('resolving', 'downloading')") suspend fun requeueInterrupted(updatedAt: String): Int + @Query("SELECT * FROM jobs WHERE status IN ('resolving', 'downloading')") + suspend fun interruptedJobs(): List + @Query("SELECT EXISTS(SELECT 1 FROM jobs WHERE status IN ('queued', 'resolving', 'downloading'))") suspend fun hasPendingJobs(): Boolean diff --git a/Android/project/app/src/main/kotlin/com/xmedia/archive/repository/ArchiveRepository.kt b/Android/project/app/src/main/kotlin/com/xmedia/archive/repository/ArchiveRepository.kt index 5f6c1de1..16cdf29a 100644 --- a/Android/project/app/src/main/kotlin/com/xmedia/archive/repository/ArchiveRepository.kt +++ b/Android/project/app/src/main/kotlin/com/xmedia/archive/repository/ArchiveRepository.kt @@ -52,7 +52,10 @@ class ArchiveRepository(context: Context) { suspend fun update(job: JobEntity) = dao.upsertJob(job.copy(updatedAt = Instant.now().toString())) - suspend fun requeueInterrupted() = dao.requeueInterrupted(Instant.now().toString()) + suspend fun requeueInterrupted(): Int { + dao.interruptedJobs().forEach { job -> removeMedia(job.id) } + return dao.requeueInterrupted(Instant.now().toString()) + } suspend fun hasPendingJobs() = dao.hasPendingJobs() @@ -60,10 +63,11 @@ class ArchiveRepository(context: Context) { suspend fun clearHistory(): Int { val terminal = dao.listJobs().filter { it.status in setOf("completed", "failed", "canceled") } + if (terminal.isEmpty()) return 0 terminal.flatMap { dao.mediaForJob(it.id) }.mapNotNull { it.mediaStoreUri }.forEach { uri -> runCatching { appContext.contentResolver.delete(android.net.Uri.parse(uri), null, null) } } - val removed = dao.deleteHistory() + val removed = dao.deleteTerminalJobs(terminal.map { it.id }) dao.deleteOrphanMedia() return removed } @@ -71,7 +75,7 @@ class ArchiveRepository(context: Context) { suspend fun retry(id: String): Boolean { val job = dao.getJob(id) ?: return false if (job.status != JobStatus.FAILED.name.lowercase() && job.status != JobStatus.CANCELED.name.lowercase()) return false - dao.deleteMedia(id) + removeMedia(id) update(job.copy(status = JobStatus.QUEUED.name.lowercase(), progress = 0, error = null, completedAt = null)) return true } @@ -87,6 +91,13 @@ class ArchiveRepository(context: Context) { suspend fun jobJson(id: String): JSONObject? = dao.getJob(id)?.let { toJson(it, dao.mediaForJob(id)) } + private suspend fun removeMedia(jobId: String) { + dao.mediaForJob(jobId).mapNotNull { it.mediaStoreUri }.forEach { uri -> + runCatching { appContext.contentResolver.delete(android.net.Uri.parse(uri), null, null) } + } + dao.deleteMedia(jobId) + } + private fun toJson(job: JobEntity, media: List): JSONObject { val result = JSONObject() .put("id", job.id).put("tweetId", job.tweetId).put("sourceUrl", job.sourceUrl) diff --git a/Android/project/app/src/main/kotlin/com/xmedia/archive/resolver/XPostResolver.kt b/Android/project/app/src/main/kotlin/com/xmedia/archive/resolver/XPostResolver.kt index 9357c03d..8d57b285 100644 --- a/Android/project/app/src/main/kotlin/com/xmedia/archive/resolver/XPostResolver.kt +++ b/Android/project/app/src/main/kotlin/com/xmedia/archive/resolver/XPostResolver.kt @@ -6,6 +6,7 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject +import kotlinx.coroutines.CancellationException import java.net.URI import java.time.Instant import java.util.UUID @@ -45,7 +46,7 @@ class XPostResolver(private val client: OkHttpClient = OkHttpClient()) { } fun resolve(tweetId: String, signal: () -> Boolean = { false }): ResolvedPost { - if (signal()) throw InterruptedException("任务已取消") + if (signal()) throw CancellationException("任务已取消") val syndication = runCatching { requestSyndication(tweetId, signal) }.getOrNull() if (syndication != null) return syndication return resolveGraphql(tweetId, signal) @@ -53,12 +54,13 @@ class XPostResolver(private val client: OkHttpClient = OkHttpClient()) { } private fun requestSyndication(tweetId: String, signal: () -> Boolean): ResolvedPost { - if (signal()) throw InterruptedException("任务已取消") + if (signal()) throw CancellationException("任务已取消") val token = base36((tweetId.toDouble() / 1e15) * PI).replace(Regex("(0+|\\.)"), "") val requestUrl = "https://cdn.syndication.twimg.com/tweet-result?id=$tweetId&token=$token&lang=zh-cn" val request = Request.Builder().url(requestUrl).header("User-Agent", USER_AGENT).header("Accept", "application/json").build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw IllegalStateException("Syndication HTTP ${response.code}") + if (signal()) throw CancellationException("任务已取消") return postFromSyndication(JSONObject(response.body?.string() ?: "{}"), tweetId) } } @@ -88,7 +90,7 @@ class XPostResolver(private val client: OkHttpClient = OkHttpClient()) { private fun resolveGraphql(tweetId: String, signal: () -> Boolean): ResolvedPost? { var token = guestToken() ?: return null repeat(2) { attempt -> - if (signal()) throw InterruptedException("任务已取消") + if (signal()) throw CancellationException("任务已取消") val url = GRAPHQL_URL.toHttpUrl().newBuilder() .addQueryParameter("variables", JSONObject().put("focalTweetId", tweetId).put("with_rux_injections", false).put("rankingMode", "Relevance").put("includePromotedContent", true).put("withCommunity", true).toString()) .addQueryParameter("features", GRAPHQL_FEATURES) diff --git a/Android/project/app/src/main/kotlin/com/xmedia/archive/service/DownloadForegroundService.kt b/Android/project/app/src/main/kotlin/com/xmedia/archive/service/DownloadForegroundService.kt index 021f8fd8..62efdbd4 100644 --- a/Android/project/app/src/main/kotlin/com/xmedia/archive/service/DownloadForegroundService.kt +++ b/Android/project/app/src/main/kotlin/com/xmedia/archive/service/DownloadForegroundService.kt @@ -112,6 +112,7 @@ class DownloadForegroundService : Service() { repository.update(job) val resolved = resolver.resolve(job.tweetId) { canceledJobs.contains(job.id) } coroutineContext.ensureActive() + if (canceledJobs.contains(job.id)) throw CancellationException("任务已取消") val media = resolved.media.map { MediaEntity(it.id, job.id, it.kind, it.filename, sourceUrl = it.sourceUrl, position = it.position) } @@ -163,6 +164,7 @@ class DownloadForegroundService : Service() { } val uri = contentResolver.insert(collection, values) ?: throw IllegalStateException("无法创建媒体文件") try { + ArchiveDatabase.get(this).dao().upsertMedia(listOf(item.copy(contentType = contentType, mediaStoreUri = uri.toString()))) contentResolver.openOutputStream(uri)?.use { output -> body.byteStream().use { input -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE)