diff --git a/Android/project/app/src/main/kotlin/com/xmedia/archive/plugin/LocalArchivePlugin.kt b/Android/project/app/src/main/kotlin/com/xmedia/archive/plugin/LocalArchivePlugin.kt index 69a293b8..c3ecdf88 100644 --- a/Android/project/app/src/main/kotlin/com/xmedia/archive/plugin/LocalArchivePlugin.kt +++ b/Android/project/app/src/main/kotlin/com/xmedia/archive/plugin/LocalArchivePlugin.kt @@ -12,6 +12,7 @@ import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.ActivityCallback import com.xmedia.archive.XLoginActivity +import com.xmedia.archive.data.JobStatus import com.xmedia.archive.repository.ArchiveRepository import com.xmedia.archive.resolver.XAuthSessionStore import com.xmedia.archive.service.DownloadForegroundService @@ -213,7 +214,16 @@ class LocalArchivePlugin : Plugin() { call.reject("该任务无法${if (action == "cancel") "取消" else "重试"}") return@launch } - if (action == "retry") DownloadForegroundService.start(context) + if (action == "retry" && !DownloadForegroundService.start(context)) { + repository.getJob(id)?.let { job -> + repository.update(job.copy( + status = JobStatus.FAILED.name.lowercase(), + error = "系统暂不允许后台启动下载;请解锁设备并保持应用在前台后重试", + )) + } + call.reject("请解锁设备并保持应用在前台后重试") + return@launch + } val result = repository.jobJson(id) if (result == null) call.reject("任务不存在") else call.resolve(JSObject(result.toString())) } 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 8f1b307a..b704ac76 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 @@ -2,6 +2,7 @@ package com.xmedia.archive.resolver import android.media.MediaExtractor import android.media.MediaFormat +import android.util.Log import okhttp3.OkHttpClient import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl @@ -135,7 +136,16 @@ class XPostResolver( repeat(2) { attempt -> 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("variables", JSONObject() + .put("focalTweetId", tweetId) + .put("with_rux_injections", false) + .put("rankingMode", "Relevance") + .put("includePromotedContent", true) + .put("withCommunity", true) + .put("withQuickPromoteEligibilityTweetFields", true) + .put("withBirdwatchNotes", true) + .put("withVoice", true) + .toString()) .addQueryParameter("features", GRAPHQL_FEATURES) .addQueryParameter("fieldToggles", GRAPHQL_FIELD_TOGGLES) .build() @@ -154,25 +164,54 @@ class XPostResolver( return GraphqlOutcome.Unavailable } val root = JSONObject(response.body?.string() ?: "{}") - val result = findTweetResult(root, tweetId) ?: return GraphqlOutcome.Unavailable + val result = findTweetResult(root, tweetId) ?: run { + logWarning("TweetDetail response has no focal result: ${graphqlErrorSummary(root)}") + return GraphqlOutcome.Unavailable + } unavailableOutcome(result)?.let { outcome -> + logWarning("TweetDetail unavailable result: typename=${result.optString("__typename", "missing")}, outcome=$outcome") return if (session != null && outcome == GraphqlOutcome.RequiresSession) GraphqlOutcome.SessionRejected else outcome } val base = if (result.optString("__typename") == "TweetWithVisibilityResults") result.optJSONObject("tweet") ?: result else result - val legacy = base.optJSONObject("legacy") ?: return GraphqlOutcome.Unavailable + val legacy = base.optJSONObject("legacy") ?: run { + logGraphqlShape("missing tweet legacy", result, base) + return GraphqlOutcome.Unavailable + } val media = legacy.optJSONObject("extended_entities")?.optJSONArray("media") ?: legacy.optJSONObject("entities")?.optJSONArray("media") val parsedMedia = parseMedia(media, tweetId) - if (parsedMedia.isEmpty()) return GraphqlOutcome.Unavailable - val user = base.optJSONObject("core")?.optJSONObject("user_results")?.optJSONObject("result")?.optJSONObject("legacy") - ?: return GraphqlOutcome.Unavailable - val username = user.optString("screen_name") - val userId = user.optString("id_str") - if (user.optBoolean("protected", false) && session == null) return GraphqlOutcome.Protected - if (username.isBlank() || userId.isBlank()) return GraphqlOutcome.Unavailable + if (parsedMedia.isEmpty()) { + logGraphqlShape("missing supported media", result, base, legacy) + return GraphqlOutcome.Unavailable + } + val userResult = base.optJSONObject("core")?.optJSONObject("user_results")?.optJSONObject("result") + if (userResult == null) { + logGraphqlShape("missing user result", result, base) + return GraphqlOutcome.Unavailable + } + // X is migrating user identity fields from `legacy` to the newer split + // core/avatar/privacy/rest_id shape. Accept both because TweetDetail may return + // either shape depending on account and feature switches. + val userLegacy = userResult.optJSONObject("legacy") + val userCore = userResult.optJSONObject("core") + val username = userLegacy?.optString("screen_name").orEmpty() + .ifBlank { userCore?.optString("screen_name").orEmpty() } + val userId = userLegacy?.optString("id_str").orEmpty() + .ifBlank { userResult.optString("rest_id") } + val isProtected = userLegacy?.optBoolean("protected", false) == true || + userResult.optJSONObject("privacy")?.optBoolean("protected", false) == true + if (isProtected && session == null) return GraphqlOutcome.Protected + if (username.isBlank() || userId.isBlank()) { + logGraphqlShape("missing user identity", result, base, userResult, userLegacy, userCore) + return GraphqlOutcome.Unavailable + } return GraphqlOutcome.Resolved(ResolvedPost(tweetId, "https://x.com/i/status/$tweetId", ResolvedMetadata( - user.optString("name", username), username, userId, - user.optString("profile_image_url_https", "").replace("_normal.", "_400x400."), + userLegacy?.optString("name").orEmpty().ifBlank { userCore?.optString("name").orEmpty() }.ifBlank { username }, + username, + userId, + userLegacy?.optString("profile_image_url_https").orEmpty() + .ifBlank { userResult.optJSONObject("avatar")?.optString("image_url").orEmpty() } + .replace("_normal.", "_400x400."), legacy.optString("full_text", legacy.optString("text", "")), legacy.optString("lang", "und"), legacy.optString("created_at", ""), ), parsedMedia)) @@ -189,6 +228,8 @@ class XPostResolver( .add("X-Twitter-Client-Language", "en") .add("X-Twitter-Active-User", "yes") .add("Accept-Language", "en") + .add("Accept", "application/json") + .add("Content-Type", "application/json") if (session == null) { headers.add("X-Guest-Token", requireNotNull(guestToken)) headers.add("Cookie", "guest_id=v1:${requireNotNull(guestToken)}") @@ -208,6 +249,34 @@ class XPostResolver( return if (reason == "NsfwLoggedOut" || text.startsWith("Age-restricted")) GraphqlOutcome.RequiresSession else GraphqlOutcome.Unavailable } + /** Returns only X error codes/messages; response data and session values are never logged. */ + private fun graphqlErrorSummary(root: JSONObject): String { + val errors = root.optJSONArray("errors") ?: return "no GraphQL errors" + return buildList { + for (index in 0 until errors.length()) { + val error = errors.optJSONObject(index) ?: continue + val code = error.optString("code", error.optString("kind", "unknown")) + val message = error.optString("message", "unknown") + .replace(Regex("\\s+"), " ") + .take(160) + add("$code: $message") + } + }.joinToString(" | ").ifBlank { "empty GraphQL errors" } + } + + private fun logGraphqlShape(stage: String, vararg objects: JSONObject?) { + val shapes = objects.mapIndexed { index, value -> + val keys = value?.keys()?.asSequence()?.toList()?.sorted()?.joinToString(",").orEmpty() + "$index={${keys.take(600)}}" + }.joinToString(" ") + logWarning("TweetDetail $stage: $shapes") + } + + // android.util.Log is unavailable in local JVM tests; diagnostics must never affect resolving. + private fun logWarning(message: String) { + runCatching { Log.w(LOG_TAG, message) } + } + private sealed interface GraphqlOutcome { data class Resolved(val post: ResolvedPost) : GraphqlOutcome data object RequiresSession : GraphqlOutcome @@ -361,12 +430,15 @@ class XPostResolver( } companion object { + private const val LOG_TAG = "XPostResolver" private const val USER_AGENT = "Mozilla/5.0 (Android) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36" private const val GRAPHQL_URL = "https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail" private const val GUEST_TOKEN_URL = "https://api.x.com/1.1/guest/activate.json" private const val BEARER = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" - private const val GRAPHQL_FEATURES = "{\"responsive_web_graphql_timeline_navigation_enabled\":true,\"responsive_web_graphql_skip_user_profile_image_extensions_enabled\":false,\"tweet_awards_web_tipping_enabled\":false,\"longform_notetweets_consumption_enabled\":true,\"responsive_web_enhance_cards_enabled\":false}" - private const val GRAPHQL_FIELD_TOGGLES = "{\"withArticleRichContentState\":true,\"withArticlePlainText\":false}" + // Keep this request contract aligned with cobalt's current Twitter resolver. X returns + // HTTP 200 plus an empty timeline when required feature flags are omitted. + private const val GRAPHQL_FEATURES = "{\"rweb_video_screen_enabled\":false,\"payments_enabled\":false,\"rweb_xchat_enabled\":false,\"profile_label_improvements_pcf_label_in_post_enabled\":true,\"rweb_tipjar_consumption_enabled\":true,\"verified_phone_label_enabled\":false,\"creator_subscriptions_tweet_preview_api_enabled\":true,\"responsive_web_graphql_timeline_navigation_enabled\":true,\"responsive_web_graphql_skip_user_profile_image_extensions_enabled\":false,\"premium_content_api_read_enabled\":false,\"communities_web_enable_tweet_community_results_fetch\":true,\"c9s_tweet_anatomy_moderator_badge_enabled\":true,\"responsive_web_grok_analyze_button_fetch_trends_enabled\":false,\"responsive_web_grok_analyze_post_followups_enabled\":true,\"responsive_web_jetfuel_frame\":true,\"responsive_web_grok_share_attachment_enabled\":true,\"articles_preview_enabled\":true,\"responsive_web_edit_tweet_api_enabled\":true,\"graphql_is_translatable_rweb_tweet_is_translatable_enabled\":true,\"view_counts_everywhere_api_enabled\":true,\"longform_notetweets_consumption_enabled\":true,\"responsive_web_twitter_article_tweet_consumption_enabled\":true,\"tweet_awards_web_tipping_enabled\":false,\"responsive_web_grok_show_grok_translated_post\":false,\"responsive_web_grok_analysis_button_from_backend\":true,\"creator_subscriptions_quote_tweet_preview_enabled\":false,\"freedom_of_speech_not_reach_fetch_enabled\":true,\"standardized_nudges_misinfo\":true,\"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled\":true,\"longform_notetweets_rich_text_read_enabled\":true,\"longform_notetweets_inline_media_enabled\":true,\"responsive_web_grok_image_annotation_enabled\":true,\"responsive_web_grok_imagine_annotation_enabled\":true,\"responsive_web_grok_community_note_auto_translation_is_enabled\":false,\"responsive_web_enhance_cards_enabled\":false}" + private const val GRAPHQL_FIELD_TOGGLES = "{\"withArticleRichContentState\":true,\"withArticlePlainText\":false,\"withGrokAnalyze\":false,\"withDisallowedReplyControls\":false}" private val RESOLUTION_PATTERN = Regex("(?() - fun start(context: android.content.Context) { + /** Returns false when Android temporarily forbids foreground-service starts. */ + fun start(context: android.content.Context): Boolean = try { val intent = Intent(context, DownloadForegroundService::class.java) androidx.core.content.ContextCompat.startForegroundService(context, intent) + true + } catch (_: RuntimeException) { + false } fun cancel(id: String) { diff --git a/Android/project/app/src/test/kotlin/com/xmedia/archive/resolver/XPostResolverTest.kt b/Android/project/app/src/test/kotlin/com/xmedia/archive/resolver/XPostResolverTest.kt index d01185de..e9f99249 100644 --- a/Android/project/app/src/test/kotlin/com/xmedia/archive/resolver/XPostResolverTest.kt +++ b/Android/project/app/src/test/kotlin/com/xmedia/archive/resolver/XPostResolverTest.kt @@ -76,6 +76,36 @@ class XPostResolverTest { assertEquals("auth_token=auth-value; ct0=csrf-value", headers["Cookie"]) assertEquals("csrf-value", headers["X-Csrf-Token"]) assertEquals("OAuth2Session", headers["X-Twitter-Auth-Type"]) + assertEquals("application/json", headers["Content-Type"]) + } + + @Test + fun usesTheCurrentCobaltTweetDetailRequestContract() { + var graphqlRequest: Request? = null + val resolver = resolverFor { request -> + when { + request.url.host == "cdn.syndication.twimg.com" -> jsonResponse(request, "{}", 503) + request.url.encodedPath.endsWith("guest/activate.json") -> jsonResponse(request, "{\"guest_token\":\"guest-token\"}") + else -> { + graphqlRequest = request + jsonResponse(request, resolvedTweet("123")) + } + } + } + + resolver.resolve("123") + + val variables = JSONObject(graphqlRequest?.url?.queryParameter("variables").orEmpty()) + val features = JSONObject(graphqlRequest?.url?.queryParameter("features").orEmpty()) + val fieldToggles = JSONObject(graphqlRequest?.url?.queryParameter("fieldToggles").orEmpty()) + assertTrue(variables.getBoolean("withQuickPromoteEligibilityTweetFields")) + assertTrue(variables.getBoolean("withBirdwatchNotes")) + assertTrue(variables.getBoolean("withVoice")) + assertTrue(features.length() >= 30) + assertTrue(features.getBoolean("communities_web_enable_tweet_community_results_fetch")) + assertFalse(features.getBoolean("responsive_web_grok_show_grok_translated_post")) + assertFalse(fieldToggles.getBoolean("withGrokAnalyze")) + assertFalse(fieldToggles.getBoolean("withDisallowedReplyControls")) } @Test @@ -198,6 +228,24 @@ class XPostResolverTest { assertTrue(authorizedRequestMade) } + @Test + fun parsesTheModernUserShapeForAnAuthorizedProtectedPost() { + val resolver = resolverFor { request -> + when { + request.url.encodedPath.endsWith("guest/activate.json") -> jsonResponse(request, "{\"guest_token\":\"guest-token\"}") + request.header("X-Twitter-Auth-Type") == "OAuth2Session" -> jsonResponse(request, resolvedTweet("123", protected = true, modernUser = true)) + else -> jsonResponse(request, protectedTweet("123")) + } + } + + val post = resolver.resolve("123") + + assertEquals("example", post.metadata.username) + assertEquals("Example", post.metadata.authorName) + assertEquals("1", post.metadata.userId) + assertEquals("https://pbs.twimg.com/profile_400x400.jpg", post.metadata.avatarUrl) + } + @Test fun retriesAnUnavailableGuestResultWithTheAuthorizedSession() { var authorizedRequestMade = false @@ -311,21 +359,29 @@ class XPostResolverTest { .put("media_url_https", "https://pbs.twimg.com/media/example.jpg"))) .toString() - private fun resolvedTweet(id: String, protected: Boolean = false): String = tweetEnvelope(id, JSONObject() - .put("__typename", "Tweet") - .put("legacy", JSONObject() - .put("full_text", "hello") - .put("lang", "en") - .put("created_at", "2025-01-01T00:00:00Z") - .put("extended_entities", JSONObject().put("media", JSONArray().put(JSONObject() - .put("type", "photo") - .put("media_url_https", "https://pbs.twimg.com/media/example.jpg"))))) - .put("core", JSONObject().put("user_results", JSONObject().put("result", JSONObject().put("legacy", JSONObject() + private fun resolvedTweet(id: String, protected: Boolean = false, modernUser: Boolean = false): String { + val user = if (modernUser) JSONObject() + .put("rest_id", "1") + .put("core", JSONObject().put("name", "Example").put("screen_name", "example")) + .put("avatar", JSONObject().put("image_url", "https://pbs.twimg.com/profile_normal.jpg")) + .put("privacy", JSONObject().put("protected", protected)) + else JSONObject().put("legacy", JSONObject() .put("name", "Example") .put("screen_name", "example") .put("id_str", "1") .put("protected", protected) - .put("profile_image_url_https", "")))))) + .put("profile_image_url_https", "")) + return tweetEnvelope(id, JSONObject() + .put("__typename", "Tweet") + .put("legacy", JSONObject() + .put("full_text", "hello") + .put("lang", "en") + .put("created_at", "2025-01-01T00:00:00Z") + .put("extended_entities", JSONObject().put("media", JSONArray().put(JSONObject() + .put("type", "photo") + .put("media_url_https", "https://pbs.twimg.com/media/example.jpg"))))) + .put("core", JSONObject().put("user_results", JSONObject().put("result", user)))) + } private fun tweetEnvelope(id: String, result: JSONObject): String { val entry = JSONObject()