diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index 995a3e2a..50cc2583 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -108,7 +108,7 @@ **3.14(P3,可测试性)纯决策下沉 C++ + XCTest**(部分) 已做:`__diff.json` → `PatchManifest` 解析含类型校验下沉 `cpp/patch_core/patch_manifest.h`(`ParsePatchManifest`,向量 `cpp/patch_core/tests/patch-manifest-vectors.json` + fuzz),三端经 `archive_patch::ApplyArchivePatch` 直接传 manifest 原文,`PushyPatchManifestFromJson`、Java `appendManifestEntries`、Harmony `parseManifestToArrays` 已删。完成记录的解析/写入/激活校验在 `cpp/patch_core/install_record.cpp`(三端共用)。归档限额计数在 `cpp/patch_core/archive_limits.cpp`(`EntryAccumulator`、`CheckUncompressedSize`、`CheckExtractedDirectory`、`PlanUnknownLengthFreeSpaceProbe`,向量 `archive-limits-vectors.json`):iOS unzip guard 直接调用,Android `SafeZipFile.inspect` 每归档一次 JNI,鸿蒙解压前整包校验 + 解压后遍历经 NAPI;三端规则漂移取最严(精确比值、整包比值、任意层 `.pushy-` 保留名、拒绝 symlink 与未知大小条目)。 -剩余应下沉到 `cpp/` 的纯决策:下载注册表 join/defer 规则与 `Content-Range` 解析(Java/ObjC 各一份);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 +剩余应下沉到 `cpp/` 的纯决策:下载注册表 join/defer 规则(Java/ObjC 各一份;`Content-Range` 解析连同整个续传决策已下沉 `cpp/patch_core/resume_plan.h`);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 --- diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so index a6636d3a..c8a60737 100755 Binary files a/android/lib/arm64-v8a/librnpushy.so and b/android/lib/arm64-v8a/librnpushy.so differ diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so index ddfdb619..43761aed 100755 Binary files a/android/lib/armeabi-v7a/librnpushy.so and b/android/lib/armeabi-v7a/librnpushy.so differ diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so index 86bc6e37..a1685e4d 100755 Binary files a/android/lib/x86/librnpushy.so and b/android/lib/x86/librnpushy.so differ diff --git a/android/lib/x86_64/librnpushy.so b/android/lib/x86_64/librnpushy.so index ea2085f3..aadb9874 100755 Binary files a/android/lib/x86_64/librnpushy.so and b/android/lib/x86_64/librnpushy.so differ diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java index d55bbf3e..7aa7420c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -25,7 +25,6 @@ import okio.BufferedSource; import okio.Okio; import org.json.JSONObject; -import org.json.JSONTokener; class DownloadTask implements Runnable { // Bundle delta entry inside a patch archive, stored encoded. @@ -124,48 +123,25 @@ static void deleteResumeSidecar(File archive) { } } - private JSONObject readResumeMeta(File sidecar, String url) { + // The sidecar's text, "" when there is none or it cannot be one (larger + // than a sidecar ever is, unreadable): ResumePlan treats both as "no + // trusted partial". + private static String readSidecarText(File sidecar) { if (!sidecar.isFile()) { - return null; + return ""; } try { - JSONObject meta = (JSONObject) new JSONTokener( - UpdateFileUtils.readSmallUtf8(sidecar, MAX_SIDECAR_BYTES)).nextValue(); - if (url.equals(meta.optString("url"))) { - return meta; - } + return UpdateFileUtils.readSmallUtf8(sidecar, MAX_SIDECAR_BYTES); } catch (Throwable e) { - // A corrupt sidecar simply means "cannot resume". + return ""; } - return null; } - private void writeResumeMeta( - File sidecar, String url, Response response, JSONObject previousMeta, long total - ) { + private static void writeSidecarText(File sidecar, String text) { try { - JSONObject meta = new JSONObject(); - meta.put("url", url); - String etag = response.header("ETag"); - String lastModified = response.header("Last-Modified"); - if (etag == null && previousMeta != null) { - etag = previousMeta.optString("etag", null); - } - if (lastModified == null && previousMeta != null) { - lastModified = previousMeta.optString("lastModified", null); - } - if (etag != null) { - meta.put("etag", etag); - } - if (lastModified != null) { - meta.put("lastModified", lastModified); - } - if (total > 0) { - meta.put("total", total); - } UpdateFileUtils.ensureParentDirectory(sidecar); try (java.io.FileOutputStream out = new java.io.FileOutputStream(sidecar)) { - out.write(meta.toString().getBytes(StandardCharsets.UTF_8)); + out.write(text.getBytes(StandardCharsets.UTF_8)); } } catch (Throwable e) { // Non-fatal: without a sidecar the next attempt starts from zero. @@ -173,6 +149,18 @@ private void writeResumeMeta( } } + // A ResumePlan result; null from the native core means memory ran out. + private static JSONObject resumeDecision(String json) throws IOException { + if (json == null) { + throw new IOException("resume plan failed"); + } + try { + return new JSONObject(json); + } catch (org.json.JSONException e) { + throw new IOException("resume plan failed", e); + } + } + private void downloadFile() throws IOException { this.hash = params.hash; streamedArchiveSha256 = null; @@ -184,31 +172,35 @@ private void downloadFile() throws IOException { // Cross-launch resume (NATIVE_CHECKUPDATE_DESIGN §11.4): a brick gets // a few hundred milliseconds per launch plus a bounded crash-rescue // window, so every partial byte must survive process death and count. - long resumeOffset = 0; - JSONObject resumeMeta = readResumeMeta(sidecar, url); - if (resumeMeta != null && writePath.isFile() && writePath.length() > 0) { - long knownTotal = resumeMeta.optLong("total", 0); - long size = writePath.length(); - if (knownTotal > 0 && size == knownTotal) { - // Fully received in a previous attempt (the process died - // between download end and unzip): nothing left to transfer. - downloadPhaseCompleted = true; - postProgress(knownTotal, knownTotal); - return; - } - if (knownTotal <= 0 || size < knownTotal) { - resumeOffset = size; - } + // What to request is the shared core's decision (ResumePlan). + String sidecarText = readSidecarText(sidecar); + JSONObject plan = resumeDecision(ResumePlan.planRequest( + sidecarText, url, writePath.isFile() ? writePath.length() : 0)); + String action = plan.optString("action"); + if ("complete".equals(action)) { + // Fully received in a previous attempt (the process died + // between download end and unzip): nothing left to transfer. + long total = plan.optLong("total"); + downloadPhaseCompleted = true; + postProgress(total, total); + return; } - if (resumeOffset == 0) { + long resumeOffset = 0; + JSONObject headers = null; + if ("resume".equals(action)) { + resumeOffset = plan.optLong("offset"); + headers = plan.optJSONObject("headers"); + } else { discardPartial(writePath); + sidecarText = ""; } - if (!transferArchive(url, writePath, sidecar, resumeMeta, resumeOffset)) { - // The server rejected the range for a partial that no longer - // matches (416): drop it and retry once from zero. + if (!transferArchive(url, writePath, sidecar, sidecarText, resumeOffset, headers)) { + // The partial no longer matches what the server serves (a stale + // 416, an untrusted range response): drop it and retry once from + // zero. discardPartial(writePath); - if (!transferArchive(url, writePath, sidecar, null, 0)) { + if (!transferArchive(url, writePath, sidecar, "", 0, null)) { throw new IOException("Server rejected the download range for " + url); } } @@ -225,32 +217,23 @@ private static void discardPartial(File archive) throws IOException { /** * One HTTP transfer, appending from resumeOffset when the server honours - * the range. Returns false only for the retryable stale-partial case - * (416 with a size mismatch); throws on every other failure. + * the range. Returns false only when the partial must be discarded and the + * download retried from zero (ResumePlan's "restart"); throws on every + * other failure. */ private boolean transferArchive( - String url, File writePath, File sidecar, JSONObject resumeMeta, long resumeOffset + String url, File writePath, File sidecar, String sidecarText, long resumeOffset, + JSONObject resumeHeaders ) throws IOException { Request.Builder builder = new Request.Builder().url(url); - if (resumeOffset > 0) { - // Only resume requests pin the encoding: Range offsets must - // address the same bytes that are on disk. Fresh downloads keep - // OkHttp's transparent gzip (it decompresses and strips the - // headers itself), matching the pre-resume behaviour for servers - // that compress regardless. - builder.header("Accept-Encoding", "identity"); - builder.header("Range", "bytes=" + resumeOffset + "-"); - String validator = null; - if (resumeMeta != null) { - validator = resumeMeta.optString("etag", null); - if (validator == null) { - validator = resumeMeta.optString("lastModified", null); - } - } - if (validator != null) { - // With a validator the server falls back to a full 200 when - // the file changed instead of appending mismatched bytes. - builder.header("If-Range", validator); + if (resumeHeaders != null) { + // Range, Accept-Encoding: identity and If-Range (ResumePlan). + // Fresh downloads send none of them and keep OkHttp's + // transparent gzip, which decompresses and strips the headers. + java.util.Iterator names = resumeHeaders.keys(); + while (names.hasNext()) { + String name = names.next(); + builder.header(name, resumeHeaders.optString(name)); } } @@ -261,53 +244,42 @@ private boolean transferArchive( params.attachCall(call); try (Response response = call.execute()) { HttpClients.rejectProtocolDowngrade(url, response); - if (response.code() == 416) { - long total = resumeMeta == null ? 0 : resumeMeta.optLong("total", 0); - if (total > 0 && writePath.length() == total) { - // The partial is actually the complete file. - postProgress(total, total); - return true; - } + ResponseBody body = response.body(); + long contentLength = body == null ? -1 : body.contentLength(); + // The Content-Encoding header is visible only when the server + // encoded the body itself (OkHttp strips it when it transparently + // decompresses its own gzip). + JSONObject decision = resumeDecision(ResumePlan.classifyResponse( + response.code(), + resumeOffset, + response.header("Content-Range"), + response.header("Content-Encoding"), + contentLength, + sidecarText, + url, + writePath.isFile() ? writePath.length() : 0)); + String action = decision.optString("action"); + if ("complete".equals(action)) { + // The partial is actually the complete file. + long total = decision.optLong("total"); + postProgress(total, total); + return true; + } + if ("restart".equals(action)) { return false; } - if (!response.isSuccessful()) { + if (!"write".equals(action)) { + // The partial and its sidecar stay: that is the resume state. throw new IOException("Server error: " + response.code() + " " + response.message()); } - - ResponseBody body = response.body(); if (body == null) { throw new IOException("Empty response body for " + url); } - String contentEncoding = response.header("Content-Encoding"); - // Visible only when the server encoded the body itself (OkHttp - // strips the header when it transparently decompresses its own - // gzip). Byte offsets in an encoded representation do not match - // the decoded bytes on disk, so resume is off the table. - boolean encodedBody = contentEncoding != null - && !contentEncoding.equalsIgnoreCase("identity"); - boolean append = response.code() == 206 && resumeOffset > 0; - if (append && encodedBody) { - // The server ignored Accept-Encoding: identity on a range - // request; the appended bytes could not be trusted. - return false; - } - long baseOffset = append ? resumeOffset : 0; - long contentLength = body.contentLength(); - long totalAll; - if (append) { - totalAll = HttpUtils.parseContentRange( - response.header("Content-Range"), resumeOffset); - if (totalAll < 0) { - // Malformed or mismatched Content-Range: treat like a - // stale partial (one clean retry from zero) instead of - // failing — a throw would keep the partial and hit the - // same wall on every future attempt. - return false; - } - } else { - totalAll = contentLength > 0 ? contentLength : 0; - } + boolean append = decision.optBoolean("append"); + long baseOffset = decision.optLong("base"); + long totalAll = decision.optLong("total"); + boolean encodedBody = decision.optBoolean("encoded"); if (totalAll > ArchiveLimits.MAX_ARCHIVE_BYTES) { throw new IOException("archive too large: " + totalAll + " bytes"); } @@ -319,12 +291,15 @@ private boolean transferArchive( // writes must never leave a sidecar describing stale bytes). discardPartial(writePath); } - if (encodedBody) { + String etag = response.header("ETag"); + String lastModified = response.header("Last-Modified"); + if (decision.optBoolean("persistSidecar")) { + // Persist before streaming so a mid-stream crash can resume. + writeSidecarText(sidecar, + ResumePlan.sidecar(url, etag, lastModified, sidecarText, totalAll)); + } else { // No resume across an encoded transfer. deleteResumeSidecar(writePath); - } else { - // Persist before streaming so a mid-stream crash can resume. - writeResumeMeta(sidecar, url, response, resumeMeta, totalAll); } long bytesRead; @@ -401,6 +376,14 @@ private boolean transferArchive( throw new IOException("Download incomplete: expected " + totalAll + " bytes, got " + writePath.length()); } + if (!encodedBody) { + // Refresh with the final size: a length unknown up front + // (chunked) left the sidecar without a total, so a process + // dying between download end and unzip would otherwise + // download the whole archive again. + writeSidecarText(sidecar, ResumePlan.sidecar( + url, etag, lastModified, sidecarText, writePath.length())); + } streamedArchiveSha256 = UpdateFileUtils.toHex(digest.digest()); // Final progress event, skipped when the loop already posted this // exact value (known length reaching 100% posts it in-loop). diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java index 1919eb5f..b55dcdb9 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java @@ -14,38 +14,4 @@ private HttpUtils() { static boolean isHttpsUrl(String url) { return url != null && url.regionMatches(true, 0, "https://", 0, 8); } - - // "bytes -/". Returns the total (0 when "*"), or -1 - // when the header is missing/malformed, the start does not match the - // local partial, or the range and total contradict each other (end - // before start, or a numeric total not beyond the end) — either way the - // appended bytes could not be trusted. A total of 0 would otherwise pass - // as "unknown" and skip the final size check (RFC 9110 §14.4: the - // complete length must exceed the last position). - static long parseContentRange(String header, long expectedStart) { - if (header == null || !header.startsWith("bytes ")) { - return -1; - } - try { - String range = header.substring("bytes ".length()).trim(); - int slash = range.indexOf('/'); - int dash = range.indexOf('-'); - if (slash < 0 || dash < 0 || dash > slash) { - return -1; - } - long start = Long.parseLong(range.substring(0, dash).trim()); - long end = Long.parseLong(range.substring(dash + 1, slash).trim()); - if (start != expectedStart || end < start) { - return -1; - } - String totalPart = range.substring(slash + 1).trim(); - if (totalPart.equals("*")) { - return 0; - } - long total = Long.parseLong(totalPart); - return total > end ? total : -1; - } catch (NumberFormatException e) { - return -1; - } - } } diff --git a/android/src/main/java/cn/reactnative/modules/update/ResumePlan.java b/android/src/main/java/cn/reactnative/modules/update/ResumePlan.java new file mode 100644 index 00000000..c4be1698 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/ResumePlan.java @@ -0,0 +1,31 @@ +package cn.reactnative.modules.update; + +/** + * The resumable-download decisions (cpp/patch_core/resume_plan.h): what to + * request for a partial archive and its sidecar, what to do with the + * response, and the sidecar text to persist. JSON text out; null only when + * the native core failed (memory ran out). DownloadTask keeps the IO. + */ +final class ResumePlan { + static { + NativeCore.ensureLoaded(); + } + + private ResumePlan() { + } + + static native String planRequest(String sidecar, String url, long partialSize); + + static native String classifyResponse( + int status, + long resumeOffset, + String contentRange, + String contentEncoding, + long contentLength, + String sidecar, + String url, + long partialSize); + + static native String sidecar( + String url, String etag, String lastModified, String previousSidecar, long total); +} diff --git a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java index de2870d3..f2d445d6 100644 --- a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java @@ -7,52 +7,6 @@ import org.junit.Test; public class HttpUtilsTest { - @Test - public void parseContentRangeReturnsTotal() { - assertEquals(200, HttpUtils.parseContentRange("bytes 100-199/200", 100)); - assertEquals(10, HttpUtils.parseContentRange("bytes 0-9/10", 0)); - assertEquals(10, HttpUtils.parseContentRange("bytes 5-9 / 10 ", 5)); - } - - @Test - public void parseContentRangeUnknownTotalIsZero() { - assertEquals(0, HttpUtils.parseContentRange("bytes 100-199/*", 100)); - } - - @Test - public void parseContentRangeRejectsMismatchedStart() { - assertEquals(-1, HttpUtils.parseContentRange("bytes 0-199/200", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 101-199/200", 100)); - } - - @Test - public void parseContentRangeRejectsContradictoryRangeAndTotal() { - // A numeric total must lie beyond the last byte position; 0 would - // otherwise read as "unknown" and skip the final size check. - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-199/0", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-199/199", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-199/150", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-199/-5", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-99/200", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-x/200", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-/200", 100)); - assertEquals(200, HttpUtils.parseContentRange("bytes 100-100/200", 100)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 100-99/*", 100)); - } - - @Test - public void parseContentRangeRejectsMalformedHeaders() { - assertEquals(-1, HttpUtils.parseContentRange(null, 0)); - assertEquals(-1, HttpUtils.parseContentRange("", 0)); - assertEquals(-1, HttpUtils.parseContentRange("bytes", 0)); - assertEquals(-1, HttpUtils.parseContentRange("bytes */200", 0)); - assertEquals(-1, HttpUtils.parseContentRange("bytes x-y/z", 0)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 0-9", 0)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 10/20-30", 10)); - assertEquals(-1, HttpUtils.parseContentRange("bytes 0-9/abc", 0)); - assertEquals(-1, HttpUtils.parseContentRange("items 0-9/10", 0)); - } - @Test public void isHttpsUrlIsCaseInsensitiveAndStrict() { assertTrue(HttpUtils.isHttpsUrl("https://example.com/app.apk")); diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index a44990b6..809ae508 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -35,6 +35,9 @@ jstring InstallRecordPromote(JNIEnv* env, jclass, jstring staging_dir, jstring v jstring InstallRecordBundleSha256(JNIEnv* env, jclass, jstring version_dir); jlong ArchiveCheckEntries(JNIEnv* env, jclass, jlong archive_bytes, jobjectArray names, jlongArray sizes, jlongArray compressed_sizes); jlongArray ArchivePlanFreeSpaceProbe(JNIEnv* env, jclass, jlong known_total, jlong written_before, jlong chunk_bytes, jlong reserved_until); +jstring ResumePlanRequest(JNIEnv* env, jclass, jstring sidecar, jstring url, jlong partial_size); +jstring ResumeClassifyResponse(JNIEnv* env, jclass, jint status, jlong resume_offset, jstring content_range, jstring content_encoding, jlong content_length, jstring sidecar, jstring url, jlong partial_size); +jstring ResumeSidecar(JNIEnv* env, jclass, jstring url, jstring etag, jstring last_modified, jstring previous_sidecar, jlong total); // } // namespace jni_natives diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index 0ffd9bfc..e16ec399 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -85,6 +85,15 @@ const std::vector& NativeClasses() { {"2a1bf5df88996d40112f1dfad38382624822", "723ddefb84c2536f", reinterpret_cast(&pushy::jni_natives::ArchivePlanFreeSpaceProbe)}, }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c92b8896259343ee7c9ab", // ResumePlan + { + {"2a1bf5df9c8e7950272c08", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c18189889e6a58791ff1c3adc857554c3216f289", + reinterpret_cast(&pushy::jni_natives::ResumePlanRequest)}, + {"391bf5c2bd826e5c103a0fe9d9bd8368", "723edefda48a7e446d331df7d1fca379582e0ae6a5f7b294644e6305e7cda7f2a963463800ec938fae957d4f375cfccca4802b724a2911fbd5f4a06347350152f6d6ba9621783c17ebf1dbe2bc3a7c270bf1c5eeb29a76527d3cf8dbaf8d6726", + reinterpret_cast(&pushy::jni_natives::ResumeClassifyResponse)}, + {"291ef0d4ad8a7a", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d595959c72462c45ebc5afb9d44b412006e2cefd", + reinterpret_cast(&pushy::jni_natives::ResumeSidecar)}, + }}, // }; return classes; diff --git a/cpp/android_jni/resume_plan_jni.cpp b/cpp/android_jni/resume_plan_jni.cpp new file mode 100644 index 00000000..5219d143 --- /dev/null +++ b/cpp/android_jni/resume_plan_jni.cpp @@ -0,0 +1,58 @@ +// JNI surface of the resumable-download decisions (cpp/patch_core/ +// resume_plan.h) for DownloadTask.java, through ResumePlan.java. Strings in, +// JSON text out; a null return means the core threw (memory ran out) and the +// caller fails the download, keeping the partial and its sidecar — a C++ +// exception must never unwind through the JNI boundary. +#include + +#include "jni_natives.h" + +#include + +#include "jni_util.h" +#include "../patch_core/resume_plan.h" + +using pushy::jni_util::JStringToString; +using pushy::jni_util::ToJString; + +jstring pushy::jni_natives::ResumePlanRequest(JNIEnv* env, jclass, jstring sidecar, + jstring url, jlong partial_size) { + try { + return ToJString(env, pushy::resume::PlanRequestJson( + JStringToString(env, sidecar), JStringToString(env, url), + static_cast(partial_size))); + } catch (...) { + return nullptr; + } +} + +jstring pushy::jni_natives::ResumeClassifyResponse( + JNIEnv* env, jclass, jint status, jlong resume_offset, jstring content_range, + jstring content_encoding, jlong content_length, jstring sidecar, jstring url, + jlong partial_size) { + try { + return ToJString( + env, pushy::resume::ClassifyResponseJson( + static_cast(status), static_cast(resume_offset), + JStringToString(env, content_range), + JStringToString(env, content_encoding), + static_cast(content_length), JStringToString(env, sidecar), + JStringToString(env, url), static_cast(partial_size))); + } catch (...) { + return nullptr; + } +} + +jstring pushy::jni_natives::ResumeSidecar(JNIEnv* env, jclass, jstring url, jstring etag, + jstring last_modified, jstring previous_sidecar, + jlong total) { + try { + return ToJString(env, pushy::resume::SidecarJson( + JStringToString(env, url), JStringToString(env, etag), + JStringToString(env, last_modified), + JStringToString(env, previous_sidecar), + static_cast(total))); + } catch (...) { + return nullptr; + } +} diff --git a/cpp/patch_core/resume_plan.cpp b/cpp/patch_core/resume_plan.cpp new file mode 100644 index 00000000..55432e93 --- /dev/null +++ b/cpp/patch_core/resume_plan.cpp @@ -0,0 +1,226 @@ +#include "resume_plan.h" + +#include + +// Relative like every other cross-core include: use_frameworks! flattens the +// pod's headers. +#include "../update_flow_core/flow_json.h" + +namespace pushy { +namespace resume { +namespace { + +using flowjson::Value; + +bool IsSpace(char c) { return c == ' ' || c == '\t'; } + +std::string Trim(const std::string& text) { + size_t begin = 0; + size_t end = text.size(); + while (begin < end && IsSpace(text[begin])) { + ++begin; + } + while (end > begin && IsSpace(text[end - 1])) { + --end; + } + return text.substr(begin, end - begin); +} + +// Digits only (no sign, no trailing text), within long long. -1 otherwise. +long long ParseDigits(const std::string& text) { + if (text.empty() || text.size() > 18) { + return -1; + } + long long value = 0; + for (char c : text) { + if (c < '0' || c > '9') { + return -1; + } + value = value * 10 + (c - '0'); + } + return value; +} + +struct Sidecar { + bool valid = false; + std::string etag; + std::string last_modified; + long long total = 0; +}; + +// The sidecar for url, or invalid (absent, oversized, not a JSON object, +// another url): a partial without one is untrusted. +Sidecar ReadSidecar(const std::string& text, const std::string& url) { + Sidecar sidecar; + if (text.empty() || static_cast(text.size()) > kMaxSidecarBytes) { + return sidecar; + } + bool ok = false; + Value root = flowjson::Parse(text, &ok); + if (!ok || !root.IsObject()) { + return sidecar; + } + const Value& url_value = root.Get("url"); + if (!url_value.IsString() || url_value.AsString() != url) { + return sidecar; + } + sidecar.valid = true; + const Value& etag = root.Get("etag"); + if (etag.IsString()) { + sidecar.etag = etag.AsString(); + } + const Value& last_modified = root.Get("lastModified"); + if (last_modified.IsString()) { + sidecar.last_modified = last_modified.AsString(); + } + const Value& total = root.Get("total"); + if (total.IsNumber() && total.AsNumber() > 0 && + total.AsNumber() < 9007199254740992.0 && + std::floor(total.AsNumber()) == total.AsNumber()) { + sidecar.total = static_cast(total.AsNumber()); + } + return sidecar; +} + +Value Number(long long n) { return Value::Number(static_cast(n)); } + +Value Action(const char* action) { + Value result = Value::Object(); + result.Set("action", Value::String(action)); + return result; +} + +std::string Complete(long long total) { + Value result = Action("complete"); + result.Set("total", Number(total)); + return flowjson::Stringify(result); +} + +bool IsEncoded(const std::string& content_encoding) { + std::string encoding = Trim(content_encoding); + if (encoding.empty()) { + return false; + } + for (char& c : encoding) { + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + } + return encoding != "identity"; +} + +} // namespace + +long long ParseContentRangeTotal(const std::string& header, + long long expected_start) { + const std::string unit = "bytes "; + if (header.compare(0, unit.size(), unit) != 0) { + return -1; + } + const std::string range = header.substr(unit.size()); + const size_t slash = range.find('/'); + const size_t dash = range.find('-'); + if (slash == std::string::npos || dash == std::string::npos || dash > slash) { + return -1; + } + const long long start = ParseDigits(Trim(range.substr(0, dash))); + const long long end = ParseDigits(Trim(range.substr(dash + 1, slash - dash - 1))); + if (start < 0 || end < 0 || start != expected_start || end < start) { + return -1; + } + const std::string total_part = Trim(range.substr(slash + 1)); + if (total_part == "*") { + return 0; + } + const long long total = ParseDigits(total_part); + return total > end ? total : -1; +} + +std::string PlanRequestJson(const std::string& sidecar_text, + const std::string& url, long long partial_size) { + const Sidecar sidecar = ReadSidecar(sidecar_text, url); + if (sidecar.valid && partial_size > 0) { + if (sidecar.total > 0 && partial_size == sidecar.total) { + return Complete(sidecar.total); + } + if (sidecar.total <= 0 || partial_size < sidecar.total) { + Value headers = Value::Object(); + headers.Set("Range", Value::String("bytes=" + std::to_string(partial_size) + "-")); + headers.Set("Accept-Encoding", Value::String("identity")); + const std::string& validator = + sidecar.etag.empty() ? sidecar.last_modified : sidecar.etag; + if (!validator.empty()) { + headers.Set("If-Range", Value::String(validator)); + } + Value result = Action("resume"); + result.Set("offset", Number(partial_size)); + result.Set("headers", headers); + return flowjson::Stringify(result); + } + } + return flowjson::Stringify(Action("fresh")); +} + +std::string ClassifyResponseJson(int status, long long resume_offset, + const std::string& content_range, + const std::string& content_encoding, + long long content_length, + const std::string& sidecar_text, + const std::string& url, + long long partial_size) { + if (status == 416) { + const Sidecar sidecar = ReadSidecar(sidecar_text, url); + if (sidecar.valid && sidecar.total > 0 && partial_size == sidecar.total) { + return Complete(sidecar.total); + } + return flowjson::Stringify(Action("restart")); + } + if (status < 200 || status > 299) { + return flowjson::Stringify(Action("httpError")); + } + const bool encoded = IsEncoded(content_encoding); + const bool append = status == 206 && resume_offset > 0; + long long total = 0; + if (append) { + // Encoded range bytes or a missing/mismatched Content-Range: the appended + // bytes could not be trusted. Restarting (once) beats failing, which + // would keep the partial and hit the same wall on every attempt. + total = encoded ? -1 : ParseContentRangeTotal(content_range, resume_offset); + if (total < 0) { + return flowjson::Stringify(Action("restart")); + } + } else if (!encoded && content_length > 0) { + total = content_length; + } + Value result = Action("write"); + result.Set("append", Value::Bool(append)); + result.Set("base", Number(append ? resume_offset : 0)); + result.Set("total", Number(total)); + result.Set("encoded", Value::Bool(encoded)); + result.Set("persistSidecar", Value::Bool(!encoded)); + return flowjson::Stringify(result); +} + +std::string SidecarJson(const std::string& url, const std::string& etag, + const std::string& last_modified, + const std::string& previous_sidecar, long long total) { + const Sidecar previous = ReadSidecar(previous_sidecar, url); + const std::string& kept_etag = etag.empty() ? previous.etag : etag; + const std::string& kept_last_modified = + last_modified.empty() ? previous.last_modified : last_modified; + Value meta = Value::Object(); + meta.Set("url", Value::String(url)); + if (!kept_etag.empty()) { + meta.Set("etag", Value::String(kept_etag)); + } + if (!kept_last_modified.empty()) { + meta.Set("lastModified", Value::String(kept_last_modified)); + } + if (total > 0) { + meta.Set("total", Number(total)); + } + return flowjson::Stringify(meta); +} + +} // namespace resume +} // namespace pushy diff --git a/cpp/patch_core/resume_plan.h b/cpp/patch_core/resume_plan.h new file mode 100644 index 00000000..5aa7290f --- /dev/null +++ b/cpp/patch_core/resume_plan.h @@ -0,0 +1,88 @@ +#pragma once + +#include + +// Cross-launch resumable download (NATIVE_CHECKUPDATE_DESIGN §11.4): the pure +// decisions every platform's downloader makes around a partial archive and +// its `.resume` sidecar, one implementation for Android +// (DownloadTask.java, through JNI), iOS (RCTPushyDownloader.mm) and Harmony +// (DownloadTask.ts, through NAPI). The downloaders keep the IO: reading and +// writing the archive and the sidecar, the HTTP request, the single retry +// from zero, progress and limits. tests/resume-plan-vectors.json pins this +// layer. +// +// The sidecar records what a partial belongs to: +// {"url": "...", "etag": "...", "lastModified": "...", "total": N} +// (url always; the validators when the server sent them; total only when +// known). A partial without a sidecar for the same URL is untrusted. +// +// JSON strings in and out, so the bindings stay thin. Keep the core internal +// to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace pushy { +namespace resume { + +// A sidecar is a few hundred bytes; anything larger is not one. +constexpr long long kMaxSidecarBytes = 64 * 1024; + +// "bytes -/" (RFC 9110 §14.4), whitespace around the +// numbers allowed, digits only. Returns the total (0 when "*"), or -1 when the +// header is missing or malformed, the start is not expected_start, or the +// range and total contradict each other (end before start, a numeric total +// not beyond end): the appended bytes could not be trusted. A total of 0 +// would otherwise pass as "unknown" and skip the final size check. +long long ParseContentRangeTotal(const std::string& header, + long long expected_start); + +// What to do before the request, from the sidecar text ("" when there is +// none) and the size of the partial on disk (0 when there is none): +// {"action":"complete","total":N} the partial is the whole archive (the +// process died between download end and unzip): nothing to transfer. +// {"action":"resume","offset":N,"headers":{...}} send these headers: +// Range, Accept-Encoding: identity (offsets must address the bytes on +// disk) and, when the sidecar has a validator, If-Range (a changed file +// then comes back as a full 200 instead of mismatched bytes). +// {"action":"fresh"} discard the partial and its sidecar; send a plain +// request (fresh downloads keep the platform's transparent encoding). +std::string PlanRequestJson(const std::string& sidecar, const std::string& url, + long long partial_size); + +// What to do with the response to a request PlanRequestJson planned +// (resume_offset is its offset, 0 for a fresh request; partial_size the +// partial's size now; content_length the body's, -1 when unknown): +// {"action":"complete","total":N} 416 and the partial is the whole archive. +// {"action":"restart"} the partial is stale (416, an encoded range body, a +// missing or mismatched Content-Range): discard it and its sidecar and +// retry once from zero. +// {"action":"httpError"} any other non-2xx status: keep the partial and +// its sidecar (the resume state) and fail. +// {"action":"write","append":B,"base":N,"total":N,"encoded":B, +// "persistSidecar":B} stream the body: appended at base (append) or +// replacing the partial (base 0); total is the whole archive (0 when +// unknown or the body is encoded). Persist the sidecar before the first +// byte lands unless the body is encoded (no resume across an encoded +// transfer: offsets would address the encoded representation), in +// which case delete it. +std::string ClassifyResponseJson(int status, long long resume_offset, + const std::string& content_range, + const std::string& content_encoding, + long long content_length, + const std::string& sidecar, + const std::string& url, + long long partial_size); + +// The sidecar text for url: this response's validators, falling back to the +// previous sidecar's (same url only) when the response omits them, and total +// when it is known (> 0). Downloaders write it before streaming and once more +// with the final size after a complete, unencoded transfer, so a process +// that dies between download end and unzip finds the archive complete even +// when the length was unknown up front. +std::string SidecarJson(const std::string& url, const std::string& etag, + const std::string& last_modified, + const std::string& previous_sidecar, long long total); + +} // namespace resume +} // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/tests/resume-plan-vectors.json b/cpp/patch_core/tests/resume-plan-vectors.json new file mode 100644 index 00000000..c0bc9cce --- /dev/null +++ b/cpp/patch_core/tests/resume-plan-vectors.json @@ -0,0 +1,606 @@ +{ + "comment": "Pins cpp/patch_core/resume_plan.h. url is the request URL every plan/classify/sidecar case uses.", + "url": "https://cdn.example.com/a.ppk", + "contentRange": [ + { + "header": "bytes 100-199/200", + "start": 100, + "expected": 200 + }, + { + "header": "bytes 0-9/10", + "start": 0, + "expected": 10 + }, + { + "header": "bytes 5-9 / 10 ", + "start": 5, + "expected": 10 + }, + { + "header": "bytes 100-199 / 200 ", + "start": 100, + "expected": 200 + }, + { + "header": "bytes 100-100/200", + "start": 100, + "expected": 200 + }, + { + "header": "bytes 100-199/*", + "start": 100, + "expected": 0 + }, + { + "header": "bytes 0-199/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 101-199/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 50-199/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-199/0", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-199/199", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-199/150", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-199/-5", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-99/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-x/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-99/*", + "start": 100, + "expected": -1 + }, + { + "header": "", + "start": 0, + "expected": -1 + }, + { + "header": "bytes", + "start": 0, + "expected": -1 + }, + { + "header": "bytes */200", + "start": 0, + "expected": -1 + }, + { + "header": "bytes x-y/z", + "start": 0, + "expected": -1 + }, + { + "header": "bytes 0-9", + "start": 0, + "expected": -1 + }, + { + "header": "bytes 10/20-30", + "start": 10, + "expected": -1 + }, + { + "header": "bytes 0-9/abc", + "start": 0, + "expected": -1 + }, + { + "header": "items 0-9/10", + "start": 0, + "expected": -1 + }, + { + "header": "100-199/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-199", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes abc-199/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-199/abc", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-199/200abc", + "start": 100, + "expected": -1 + }, + { + "header": "bytes +100-199/200", + "start": 100, + "expected": -1 + }, + { + "header": "bytes 100-1x9/200", + "start": 100, + "expected": -1 + } + ], + "planRequest": [ + { + "name": "no sidecar", + "sidecar": "", + "size": 100, + "expected": { + "action": "fresh" + } + }, + { + "name": "no partial", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":200}", + "size": 0, + "expected": { + "action": "fresh" + } + }, + { + "name": "sidecar for another url", + "sidecar": "{\"url\":\"https://other/x\",\"total\":200}", + "size": 100, + "expected": { + "action": "fresh" + } + }, + { + "name": "corrupt sidecar", + "sidecar": "{oops", + "size": 100, + "expected": { + "action": "fresh" + } + }, + { + "name": "sidecar not an object", + "sidecar": "[1]", + "size": 100, + "expected": { + "action": "fresh" + } + }, + { + "name": "oversized sidecar", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"etag\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "size": 100, + "expected": { + "action": "fresh" + } + }, + { + "name": "partial complete", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"etag\":\"\\\"e1\\\"\",\"total\":200}", + "size": 200, + "expected": { + "action": "complete", + "total": 200 + } + }, + { + "name": "partial larger than total", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":200}", + "size": 300, + "expected": { + "action": "fresh" + } + }, + { + "name": "resume with etag", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"etag\":\"\\\"e1\\\"\",\"lastModified\":\"Mon, 01 Jan 2026 00:00:00 GMT\",\"total\":200}", + "size": 100, + "expected": { + "action": "resume", + "offset": 100, + "headers": { + "Range": "bytes=100-", + "Accept-Encoding": "identity", + "If-Range": "\"e1\"" + } + } + }, + { + "name": "resume with last-modified only", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"lastModified\":\"Mon, 01 Jan 2026 00:00:00 GMT\",\"total\":200}", + "size": 100, + "expected": { + "action": "resume", + "offset": 100, + "headers": { + "Range": "bytes=100-", + "Accept-Encoding": "identity", + "If-Range": "Mon, 01 Jan 2026 00:00:00 GMT" + } + } + }, + { + "name": "resume without validator or total", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\"}", + "size": 50, + "expected": { + "action": "resume", + "offset": 50, + "headers": { + "Range": "bytes=50-", + "Accept-Encoding": "identity" + } + } + }, + { + "name": "empty etag falls back to last-modified", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"etag\":\"\",\"lastModified\":\"LM\"}", + "size": 50, + "expected": { + "action": "resume", + "offset": 50, + "headers": { + "Range": "bytes=50-", + "Accept-Encoding": "identity", + "If-Range": "LM" + } + } + }, + { + "name": "non-numeric total is unknown", + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":\"200\"}", + "size": 50, + "expected": { + "action": "resume", + "offset": 50, + "headers": { + "Range": "bytes=50-", + "Accept-Encoding": "identity" + } + } + } + ], + "classifyResponse": [ + { + "name": "416 with complete partial", + "status": 416, + "offset": 200, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":200}", + "size": 200, + "expected": { + "action": "complete", + "total": 200 + }, + "contentRange": "", + "contentEncoding": "", + "contentLength": -1 + }, + { + "name": "416 with stale partial", + "status": 416, + "offset": 100, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":200}", + "size": 100, + "expected": { + "action": "restart" + }, + "contentRange": "", + "contentEncoding": "", + "contentLength": -1 + }, + { + "name": "416 without sidecar", + "status": 416, + "offset": 100, + "sidecar": "", + "size": 100, + "expected": { + "action": "restart" + }, + "contentRange": "", + "contentEncoding": "", + "contentLength": -1 + }, + { + "name": "500 keeps the resume state", + "status": 500, + "offset": 100, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":200}", + "size": 100, + "expected": { + "action": "httpError" + }, + "contentRange": "", + "contentEncoding": "", + "contentLength": -1 + }, + { + "name": "302 is an error", + "status": 302, + "offset": 0, + "sidecar": "", + "size": 0, + "expected": { + "action": "httpError" + }, + "contentRange": "", + "contentEncoding": "", + "contentLength": -1 + }, + { + "name": "206 append", + "status": 206, + "offset": 100, + "contentRange": "bytes 100-199/200", + "contentLength": 100, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":200}", + "size": 100, + "expected": { + "action": "write", + "append": true, + "base": 100, + "total": 200, + "encoded": false, + "persistSidecar": true + }, + "contentEncoding": "" + }, + { + "name": "206 append unknown total", + "status": 206, + "offset": 100, + "contentRange": "bytes 100-199/*", + "contentLength": 100, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\"}", + "size": 100, + "expected": { + "action": "write", + "append": true, + "base": 100, + "total": 0, + "encoded": false, + "persistSidecar": true + }, + "contentEncoding": "" + }, + { + "name": "206 mismatched start restarts", + "status": 206, + "offset": 100, + "contentRange": "bytes 0-199/200", + "contentLength": 200, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\"}", + "size": 100, + "expected": { + "action": "restart" + }, + "contentEncoding": "" + }, + { + "name": "206 missing content-range restarts", + "status": 206, + "offset": 100, + "contentLength": 100, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\"}", + "size": 100, + "expected": { + "action": "restart" + }, + "contentRange": "", + "contentEncoding": "" + }, + { + "name": "206 encoded restarts", + "status": 206, + "offset": 100, + "contentRange": "bytes 100-199/200", + "contentEncoding": "gzip", + "contentLength": 60, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\"}", + "size": 100, + "expected": { + "action": "restart" + } + }, + { + "name": "200 to a range request replaces the partial", + "status": 200, + "offset": 100, + "contentLength": 300, + "sidecar": "{\"url\":\"https://cdn.example.com/a.ppk\",\"total\":200}", + "size": 100, + "expected": { + "action": "write", + "append": false, + "base": 0, + "total": 300, + "encoded": false, + "persistSidecar": true + }, + "contentRange": "", + "contentEncoding": "" + }, + { + "name": "fresh 200", + "status": 200, + "offset": 0, + "contentLength": 300, + "sidecar": "", + "size": 0, + "expected": { + "action": "write", + "append": false, + "base": 0, + "total": 300, + "encoded": false, + "persistSidecar": true + }, + "contentRange": "", + "contentEncoding": "" + }, + { + "name": "fresh 200 unknown length", + "status": 200, + "offset": 0, + "contentLength": -1, + "sidecar": "", + "size": 0, + "expected": { + "action": "write", + "append": false, + "base": 0, + "total": 0, + "encoded": false, + "persistSidecar": true + }, + "contentRange": "", + "contentEncoding": "" + }, + { + "name": "fresh 200 encoded", + "status": 200, + "offset": 0, + "contentEncoding": "GZIP", + "contentLength": 120, + "sidecar": "", + "size": 0, + "expected": { + "action": "write", + "append": false, + "base": 0, + "total": 0, + "encoded": true, + "persistSidecar": false + }, + "contentRange": "" + }, + { + "name": "identity is not encoded", + "status": 200, + "offset": 0, + "contentEncoding": " Identity ", + "contentLength": 10, + "sidecar": "", + "size": 0, + "expected": { + "action": "write", + "append": false, + "base": 0, + "total": 10, + "encoded": false, + "persistSidecar": true + }, + "contentRange": "" + }, + { + "name": "206 to a fresh request is a full body", + "status": 206, + "offset": 0, + "contentRange": "bytes 0-9/10", + "contentLength": 10, + "sidecar": "", + "size": 0, + "expected": { + "action": "write", + "append": false, + "base": 0, + "total": 10, + "encoded": false, + "persistSidecar": true + }, + "contentEncoding": "" + } + ], + "sidecar": [ + { + "name": "response validators", + "etag": "\"e2\"", + "lastModified": "LM2", + "previous": "{\"url\":\"https://cdn.example.com/a.ppk\",\"etag\":\"\\\"e1\\\"\",\"lastModified\":\"LM1\"}", + "total": 200, + "expected": { + "url": "https://cdn.example.com/a.ppk", + "etag": "\"e2\"", + "lastModified": "LM2", + "total": 200 + } + }, + { + "name": "keeps the previous validators", + "etag": "", + "lastModified": "", + "previous": "{\"url\":\"https://cdn.example.com/a.ppk\",\"etag\":\"\\\"e1\\\"\",\"lastModified\":\"LM1\",\"total\":100}", + "total": 200, + "expected": { + "url": "https://cdn.example.com/a.ppk", + "etag": "\"e1\"", + "lastModified": "LM1", + "total": 200 + } + }, + { + "name": "ignores another url's validators", + "etag": "", + "lastModified": "", + "previous": "{\"url\":\"https://other/x\",\"etag\":\"\\\"e1\\\"\"}", + "total": 0, + "expected": { + "url": "https://cdn.example.com/a.ppk" + } + }, + { + "name": "unknown total is omitted", + "etag": "\"e\"", + "lastModified": "", + "previous": "", + "total": 0, + "expected": { + "url": "https://cdn.example.com/a.ppk", + "etag": "\"e\"" + } + } + ] +} diff --git a/cpp/patch_core/tests/resume_plan_test.cpp b/cpp/patch_core/tests/resume_plan_test.cpp new file mode 100644 index 00000000..8901e554 --- /dev/null +++ b/cpp/patch_core/tests/resume_plan_test.cpp @@ -0,0 +1,104 @@ +// Replays tests/resume-plan-vectors.json through cpp/patch_core/resume_plan.h. +#include +#include +#include +#include + +#include "../../update_flow_core/flow_json.h" +#include "../resume_plan.h" + +namespace { + +int failures = 0; + +void Check(bool ok, const std::string& what) { + if (!ok) { + std::fprintf(stderr, "[FAIL] %s\n", what.c_str()); + ++failures; + } +} + +flowjson::Value ParseOrDie(const std::string& text, const std::string& what) { + bool ok = false; + flowjson::Value value = flowjson::Parse(text, &ok); + Check(ok, what + ": output is not JSON: " + text); + return value; +} + +long long Int(const flowjson::Value& value) { + return static_cast(value.AsNumber()); +} + +std::string Str(const flowjson::Value& value) { + return value.IsString() ? value.AsString() : std::string(); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: resume_plan_test \n"); + return 2; + } + std::ifstream in(argv[1]); + std::stringstream buffer; + buffer << in.rdbuf(); + bool ok = false; + flowjson::ParseLimits limits; + limits.max_input_bytes = 16 * 1024 * 1024; + const flowjson::Value file = flowjson::Parse(buffer.str(), limits, &ok); + if (!ok || !file.IsObject()) { + std::fprintf(stderr, "cannot read %s\n", argv[1]); + return 2; + } + const std::string url = file.Get("url").AsString(); + int cases = 0; + + for (const auto& c : file.Get("contentRange").elements()) { + const long long got = pushy::resume::ParseContentRangeTotal( + Str(c.Get("header")), Int(c.Get("start"))); + Check(got == Int(c.Get("expected")), + "contentRange \"" + Str(c.Get("header")) + "\" -> " + std::to_string(got)); + ++cases; + } + for (const auto& c : file.Get("planRequest").elements()) { + const std::string name = "planRequest " + Str(c.Get("name")); + const std::string out = pushy::resume::PlanRequestJson( + Str(c.Get("sidecar")), url, Int(c.Get("size"))); + Check(flowjson::Value::DeepEquals(ParseOrDie(out, name), c.Get("expected")), + name + " -> " + out); + ++cases; + } + for (const auto& c : file.Get("classifyResponse").elements()) { + const std::string name = "classifyResponse " + Str(c.Get("name")); + const std::string out = pushy::resume::ClassifyResponseJson( + static_cast(Int(c.Get("status"))), Int(c.Get("offset")), + Str(c.Get("contentRange")), Str(c.Get("contentEncoding")), + Int(c.Get("contentLength")), Str(c.Get("sidecar")), url, + Int(c.Get("size"))); + Check(flowjson::Value::DeepEquals(ParseOrDie(out, name), c.Get("expected")), + name + " -> " + out); + ++cases; + } + for (const auto& c : file.Get("sidecar").elements()) { + const std::string name = "sidecar " + Str(c.Get("name")); + const std::string out = pushy::resume::SidecarJson( + url, Str(c.Get("etag")), Str(c.Get("lastModified")), + Str(c.Get("previous")), Int(c.Get("total"))); + Check(flowjson::Value::DeepEquals(ParseOrDie(out, name), c.Get("expected")), + name + " -> " + out); + // What a downloader writes it must read back as the same partial. + const std::string plan = pushy::resume::PlanRequestJson(out, url, 1); + Check(ParseOrDie(plan, name).Get("action").AsString() != "fresh" || + Int(c.Get("total")) == 1, + name + ": written sidecar does not read back"); + ++cases; + } + + if (failures > 0) { + std::fprintf(stderr, "%d resume plan vector(s) failed\n", failures); + return 1; + } + std::printf("%d resume plan vectors passed\n", cases); + return 0; +} diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index 23fcfb35..74efbd53 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -518,6 +518,14 @@ afterDownload)`:本地 silent 策略 或 响应标记 forceBoot 即激活。 If-Range 验证器防混拼、最终长度对账、解压/patch 本身的结构校验;任何一 层失败走"删两者重来",自愈。 - **进度事件**:received 含续传偏移量,total 取 Content-Range 总长。 +- **完成后刷新 sidecar**:下载完整收齐(且未编码)后,用最终文件大小重写 + sidecar。总长事先未知(chunked)时首次写入的 sidecar 没有 total,不刷新 + 的话"下载完、解压前死"的进程下次会整包重下。 +- **单一实现**:上述决策(读 sidecar 并校验、发不发 Range 与带哪些头、响应 + 判定、写什么 sidecar、Content-Range 解析)由 `cpp/patch_core/resume_plan.h` + 一份实现,三端只做 IO(Android 经 JNI 的 `ResumePlan`、鸿蒙经 NAPI、iOS + 直调),`cpp/patch_core/tests/resume-plan-vectors.json` 锁定行为。改规格只改 + 内核与向量。sidecar 一律以 64KB 为上限;Content-Range 的数字只接受纯数字。 - **round-incomplete 标记**:runOnce 入口落盘 KV 标记、finally 清除。进程 死于轮中 → 标记残留 → **下次启动编排器跳过 5s 延迟立即(异步)续传**。 这不是阻塞、没有误判成本,是"上次没救完"最诚实的信号,让被截断的 diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 6546da53..8c5d28f9 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -21,6 +21,7 @@ #include "install_record.h" #include "hbc_transform_wire.h" #include "patch_core.h" +#include "resume_plan.h" #include "state_core.h" #include "state_ops.h" @@ -1442,6 +1443,59 @@ static napi_value FlowNormalizeConfiguration(napi_env env, return NewString(env, config); } +// ---- resumable download (patch_core/resume_plan.h) ---- +// Strings in (null/undefined read as ""), JSON text out; DownloadTask.ts +// keeps the IO. + +// planRequest(sidecar, url, partialSize) +static napi_value ResumePlanRequest(napi_env env, napi_callback_info info) { + napi_value args[3] = {nullptr, nullptr, nullptr}; + std::string text[3]; + double partial_size = 0; + if (!GetFlowArgs(env, info, "resumePlanRequest", 3, 0b011, args, text) || + !GetFlowNumber(env, args[2], "resumePlanRequest", &partial_size)) { + return nullptr; + } + return NewString(env, pushy::resume::PlanRequestJson( + text[0], text[1], static_cast(partial_size))); +} + +// classifyResponse(status, resumeOffset, contentRange, contentEncoding, +// contentLength, sidecar, url, partialSize) +static napi_value ResumeClassifyResponse(napi_env env, napi_callback_info info) { + napi_value args[8] = {}; + std::string text[8]; + double status = 0; + double resume_offset = 0; + double content_length = 0; + double partial_size = 0; + if (!GetFlowArgs(env, info, "resumeClassifyResponse", 8, 0b01101100, args, text) || + !GetFlowNumber(env, args[0], "resumeClassifyResponse", &status) || + !GetFlowNumber(env, args[1], "resumeClassifyResponse", &resume_offset) || + !GetFlowNumber(env, args[4], "resumeClassifyResponse", &content_length) || + !GetFlowNumber(env, args[7], "resumeClassifyResponse", &partial_size)) { + return nullptr; + } + return NewString(env, pushy::resume::ClassifyResponseJson( + static_cast(status), + static_cast(resume_offset), text[2], text[3], + static_cast(content_length), text[5], text[6], + static_cast(partial_size))); +} + +// sidecar(url, etag, lastModified, previousSidecar, total) +static napi_value ResumeSidecar(napi_env env, napi_callback_info info) { + napi_value args[5] = {}; + std::string text[5]; + double total = 0; + if (!GetFlowArgs(env, info, "resumeSidecar", 5, 0b01111, args, text) || + !GetFlowNumber(env, args[4], "resumeSidecar", &total)) { + return nullptr; + } + return NewString(env, pushy::resume::SidecarJson(text[0], text[1], text[2], text[3], + static_cast(total))); +} + napi_value Init(napi_env env, napi_value exports) { // (scripts/contract.ts; bun scripts/generate-contract.ts) if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", Guarded) || @@ -1466,7 +1520,10 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "handleResponse", Guarded) || !ExportFunction(env, exports, "configsEquivalent", Guarded) || !ExportFunction(env, exports, "runnableConfig", Guarded) || - !ExportFunction(env, exports, "normalizeConfiguration", Guarded)) { + !ExportFunction(env, exports, "normalizeConfiguration", Guarded) || + !ExportFunction(env, exports, "resumePlanRequest", Guarded) || + !ExportFunction(env, exports, "resumeClassifyResponse", Guarded) || + !ExportFunction(env, exports, "resumeSidecar", Guarded)) { // return nullptr; } diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index 029c76c3..27b058ef 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -63,47 +63,25 @@ const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000; // 断点续传 sidecar(NATIVE_CHECKUPDATE_DESIGN §11.4):记录 partial 属于 // 哪个 url、验证器与总长。归档与 sidecar 同生共死;有归档无 sidecar(或 -// url 不符)一律视为不可信,删掉重来。 -interface ResumeMeta { - url: string; - etag?: string; - lastModified?: string; +// url 不符)一律视为不可信,删掉重来。续传的所有判定(读 sidecar、发不发 +// Range、响应怎么处理、写什么 sidecar)都在共享内核 +// cpp/patch_core/resume_plan.h,与 Android/iOS 同一份实现,这里只做 IO。 +const MAX_SIDECAR_BYTES = 64 * 1024; + +interface ResumeRequestPlan { + action: string; total?: number; + offset?: number; + headers?: Record; } -// "bytes -/"。返回总长(“*”记 0);缺失/畸形/起点与 -// 本地 partial 不符、或 range 与 total 自相矛盾(end 在 start 之前、数值 -// total 不大于 end)返回 -1——那样追加的字节不可信。RFC 9110 §14.4 要求 -// 完整长度必须大于 last-pos。 -export function parseContentRangeTotal( - header: string, - expectedStart: number, -): number { - if (!header.startsWith('bytes ')) { - return -1; - } - const range = header.substring('bytes '.length).trim(); - const slash = range.indexOf('/'); - const dash = range.indexOf('-'); - if (slash < 0 || dash < 0 || dash > slash) { - return -1; - } - const start = parseInt(range.substring(0, dash).trim(), 10); - const end = parseInt(range.substring(dash + 1, slash).trim(), 10); - if ( - Number.isNaN(start) || - Number.isNaN(end) || - start !== expectedStart || - end < start - ) { - return -1; - } - const totalPart = range.substring(slash + 1).trim(); - if (totalPart === '*') { - return 0; - } - const total = parseInt(totalPart, 10); - return Number.isNaN(total) || total <= end ? -1 : total; +interface ResumeResponseDecision { + action: string; + total?: number; + append?: boolean; + base?: number; + encoded?: boolean; + persistSidecar?: boolean; } export class DownloadTask { @@ -142,33 +120,33 @@ export class DownloadTask { return `${targetFile}.resume`; } - private async readResumeMeta( - targetFile: string, - url: string, - ): Promise { + // sidecar 原文;不存在、超过 sidecar 应有的大小或读失败时为 "" + // (共享内核把这些都当作“没有可信的 partial”)。 + private async readSidecarText(targetFile: string): Promise { const sidecar = this.resumeSidecarPath(targetFile); - if (!fileIo.accessSync(sidecar)) { - return null; - } try { - const meta = this.parseJsonEntry( - await this.readFileContent(sidecar), - ) as ResumeMeta; - if (meta && meta.url === url) { - return meta; + if (!fileIo.accessSync(sidecar)) { + return ''; } + const stat = await fileIo.stat(sidecar); + if (stat.size > MAX_SIDECAR_BYTES) { + return ''; + } + return new util.TextDecoder().decodeToString( + new Uint8Array(await this.readFileContent(sidecar)), + ); } catch (e) { // 坏 sidecar 只意味着“无法续传”。 + return ''; } - return null; } - private async writeResumeMeta( + private async writeSidecarText( targetFile: string, - meta: ResumeMeta, + text: string, ): Promise { try { - const encoded = new util.TextEncoder().encodeInto(JSON.stringify(meta)); + const encoded = new util.TextEncoder().encodeInto(text); await this.writeFileContent(this.resumeSidecarPath(targetFile), encoded); } catch (e) { // 非致命:没有 sidecar,下次从零开始。 @@ -482,32 +460,36 @@ export class DownloadTask { ); } - // 续传状态盘点:有匹配的 sidecar 且 partial 未收齐才发 Range。 - let resumeMeta: ResumeMeta | null = allowResume - ? await this.readResumeMeta(params.targetFile, params.url) - : null; - let resumeOffset = 0; - if (resumeMeta && fileIo.accessSync(params.targetFile)) { - const stat = await fileIo.stat(params.targetFile); - const knownTotal = resumeMeta.total ?? 0; - if (stat.size > 0 && knownTotal > 0 && stat.size === knownTotal) { - // 上次尝试已收齐(进程死在下载结束与解压之间):无需再传。 - httpRequest.destroy(); - this.onProgressUpdate(knownTotal, knownTotal); - return 'done'; - } - if (stat.size > 0 && (knownTotal <= 0 || stat.size < knownTotal)) { - resumeOffset = stat.size; - } + // 续传状态盘点(共享内核决定):已收齐 / 带 Range 续传 / 从零开始。 + let sidecarText = allowResume + ? await this.readSidecarText(params.targetFile) + : ''; + const partialSize = fileIo.accessSync(params.targetFile) + ? (await fileIo.stat(params.targetFile)).size + : 0; + const plan = JSON.parse( + NativePatchCore.resumePlanRequest(sidecarText, params.url, partialSize), + ) as ResumeRequestPlan; + if (plan.action === 'complete') { + // 上次尝试已收齐(进程死在下载结束与解压之间):无需再传。 + httpRequest.destroy(); + const knownTotal = plan.total ?? 0; + this.onProgressUpdate(knownTotal, knownTotal); + return 'done'; } - if (resumeOffset === 0) { + let resumeOffset = 0; + let resumeHeaders: Record = {}; + if (plan.action === 'resume') { + resumeOffset = plan.offset ?? 0; + resumeHeaders = plan.headers ?? {}; + } else { if (fileIo.accessSync(params.targetFile)) { await fileIo.unlink(params.targetFile); } else { await this.ensureParentDirectory(params.targetFile); } await this.deleteResumeSidecar(params.targetFile); - resumeMeta = null; + sidecarText = ''; } // 响应体在状态码判定前一律只进内存缓冲:416/5xx 的错误体若被追加进 @@ -520,23 +502,16 @@ export class DownloadTask { authorize = resolve; }); - // 本 url 的 sidecar 内容:验证器优先取本次响应头,缺失时沿用上次的; - // 总长未知(0)时不写。 - const buildResumeMeta = (total: number): ResumeMeta => { - const meta: ResumeMeta = { url: params.url }; - const etag = etagHeader || resumeMeta?.etag; - const lastModified = lastModifiedHeader || resumeMeta?.lastModified; - if (etag) { - meta.etag = etag; - } - if (lastModified) { - meta.lastModified = lastModified; - } - if (total > 0) { - meta.total = total; - } - return meta; - }; + // 本 url 的 sidecar 原文:验证器优先取本次响应头,缺失时沿用上次的; + // 总长未知(0)时不写(共享内核)。 + const sidecarFor = (total: number): string => + NativePatchCore.resumeSidecar( + params.url, + etagHeader, + lastModifiedHeader, + sidecarText, + total, + ); const reportProgress = () => { const overall = baseOffset + received; @@ -729,17 +704,10 @@ export class DownloadTask { const requestHeader: Record = { 'Content-Type': 'application/octet-stream', }; - if (resumeOffset > 0) { - // 只有续传请求钉死编码:Range 偏移必须与盘上字节一一对应。 - // 全新下载保留平台默认的压缩处理,与引入续传前的行为一致。 - requestHeader['Accept-Encoding'] = 'identity'; - requestHeader.Range = `bytes=${resumeOffset}-`; - const validator = resumeMeta?.etag || resumeMeta?.lastModified; - if (validator) { - // 带验证器时文件已变更的服务端会退回完整 200,而不是把不匹配 - // 的字节接上来。 - requestHeader['If-Range'] = validator; - } + // 续传请求的 Range / Accept-Encoding: identity / If-Range(共享内核 + // 给出);全新下载不带,保留平台默认的压缩处理。 + for (const name of Object.keys(resumeHeaders)) { + requestHeader[name] = resumeHeaders[name]; } const remainingMs = Math.max(1, deadlineUptimeMs - monotonicNowMs()); @@ -768,31 +736,6 @@ export class DownloadTask { deadlinePromise, ]); - if (responseCode === 416) { - discardBody = true; - pendingChunks = null; - const knownTotal = resumeMeta?.total ?? 0; - if (knownTotal > 0 && fileIo.accessSync(params.targetFile)) { - const stat = await fileIo.stat(params.targetFile); - if (stat.size === knownTotal) { - // partial 实际上就是完整文件。 - this.onProgressUpdate(knownTotal, knownTotal); - return 'done'; - } - } - return 'staleRange'; - } - if (responseCode > 299) { - // 响应体从未落盘,partial + sidecar 原样保留——那就是续传状态。 - discardBody = true; - pendingChunks = null; - throw createPushyError( - ERROR_DOWNLOAD_FAILED, - `Server error: ${responseCode}`, - ); - } - - let encodedBody = false; if (resumeOffset > 0 && responseCode === 206) { // Content-Range 只能从 headersReceive 拿到,而它与 promise 的先后 // 顺序不保证——等头到达(dataEnd 前必到,兜底 5s)再判定。 @@ -804,22 +747,50 @@ export class DownloadTask { }), ]); clearTimeout(headersTimer); - encodedBody = - !!contentEncodingHeader && - contentEncodingHeader.toLowerCase() !== 'identity'; - const parsedTotal = encodedBody - ? -1 - : parseContentRangeTotal(contentRangeHeader, resumeOffset); - if (parsedTotal < 0) { - // 编码过的 range 字节、或 Content-Range 缺失/畸形/起点不符: - // 追加不可信。按“partial 已过期”处理(删掉、干净地重来一次), - // 而不是抛错——抛错会保留 partial,之后每次都撞同一堵墙。 - discardBody = true; - pendingChunks = null; - return 'staleRange'; - } - baseOffset = resumeOffset; - totalAll = parsedTotal; + } + // 这个响应怎么处理由共享内核决定(与 Android/iOS 同一份)。 + const partialNow = fileIo.accessSync(params.targetFile) + ? (await fileIo.stat(params.targetFile)).size + : 0; + const decision = JSON.parse( + NativePatchCore.resumeClassifyResponse( + responseCode, + resumeOffset, + contentRangeHeader, + contentEncodingHeader, + contentLength > 0 ? contentLength : -1, + sidecarText, + params.url, + partialNow, + ), + ) as ResumeResponseDecision; + if (decision.action !== 'write') { + // 响应体从未落盘,partial + sidecar 原样保留,除非下面判定要重来。 + discardBody = true; + pendingChunks = null; + } + if (decision.action === 'complete') { + // 416 且 partial 实际上就是完整文件。 + const knownTotal = decision.total ?? 0; + this.onProgressUpdate(knownTotal, knownTotal); + return 'done'; + } + if (decision.action === 'restart') { + // 过期的 416、编码过的 range 字节、或 Content-Range 缺失/畸形/ + // 起点不符:追加不可信。按“partial 已过期”处理(删掉、干净地 + // 重来一次),而不是抛错——抛错会保留 partial,之后每次都撞同一堵墙。 + return 'staleRange'; + } + if (decision.action !== 'write') { + // 其余非 2xx:partial + sidecar 原样保留——那就是续传状态。 + throw createPushyError( + ERROR_DOWNLOAD_FAILED, + `Server error: ${responseCode}`, + ); + } + baseOffset = decision.base ?? 0; + totalAll = decision.total ?? 0; + if (decision.append) { writer = await fileIo.open( params.targetFile, fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.APPEND, @@ -827,8 +798,6 @@ export class DownloadTask { } else { // 服务端忽略了 Range(或本就没发):从零开始。 await unlinkIfExists(params.targetFile); - baseOffset = 0; - totalAll = contentLength > 0 ? contentLength : 0; writer = await fileIo.open( params.targetFile, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, @@ -846,8 +815,13 @@ export class DownloadTask { totalAll > 0 ? totalAll - baseOffset : 0, ERROR_DOWNLOAD_FAILED, ); - // 先落 sidecar 再放行写入,流中崩溃才有得续。 - await this.writeResumeMeta(params.targetFile, buildResumeMeta(totalAll)); + if (decision.persistSidecar) { + // 先落 sidecar 再放行写入,流中崩溃才有得续。 + await this.writeSidecarText(params.targetFile, sidecarFor(totalAll)); + } else { + // 编码传输不可续传:偏移会落在编码后的表示上。 + await this.deleteResumeSidecar(params.targetFile); + } // 鉴权通过:回放缓冲,之后的数据直写。 const buffered = pendingChunks ?? []; @@ -889,10 +863,7 @@ export class DownloadTask { } // 用最终观测值刷新 sidecar:首次写入可能因头部竞态缺 total/验证器, // 补齐后“下载完、解压前死”的窗口才能被 complete 检测覆盖。 - await this.writeResumeMeta( - params.targetFile, - buildResumeMeta(stats.size), - ); + await this.writeSidecarText(params.targetFile, sidecarFor(stats.size)); } else { // 编码传输不可续传:盘上是解码后的字节,编码域的长度与偏移都失效。 await this.deleteResumeSidecar(params.targetFile); diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index b92b943e..fce94758 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -197,6 +197,30 @@ interface NativePatchCoreBindings { * 不合法时抛出 Error("Invalid native configuration: ...")。 */ normalizeConfiguration(optionsJson: string | undefined): string; + + // 断点续传决策(cpp/patch_core/resume_plan.h,与 Android/iOS 共用), + // 返回 JSON 文本;IO 留在 DownloadTask.ts。sidecar 为 "" 表示没有。 + /** 请求前:complete / resume(offset + headers) / fresh。 */ + resumePlanRequest(sidecar: string, url: string, partialSize: number): string; + /** 响应到达后:complete / restart / httpError / write(append, base, total, encoded, persistSidecar)。 */ + resumeClassifyResponse( + status: number, + resumeOffset: number, + contentRange: string, + contentEncoding: string, + contentLength: number, + sidecar: string, + url: string, + partialSize: number, + ): string; + /** 要写入的 sidecar 文本:本次响应的验证器,缺失时沿用上一份;total>0 才记。 */ + resumeSidecar( + url: string, + etag: string, + lastModified: string, + previousSidecar: string, + total: number, + ): string; } export default NativeCore as unknown as NativePatchCoreBindings; diff --git a/ios/RCTPushy/RCTPushyDownloader.mm b/ios/RCTPushy/RCTPushyDownloader.mm index a51335c6..b91f64d4 100644 --- a/ios/RCTPushy/RCTPushyDownloader.mm +++ b/ios/RCTPushy/RCTPushyDownloader.mm @@ -1,6 +1,7 @@ #import "RCTPushyDownloader.h" #import "RCTPushyCommon.h" #include "../../cpp/patch_core/archive_limits.h" +#include "../../cpp/patch_core/resume_plan.h" // Cross-launch resumable download (NATIVE_CHECKUPDATE_DESIGN §11.4): a data // task streams the archive straight into savePath (a download task's @@ -14,20 +15,31 @@ return [savePath stringByAppendingString:@".resume"]; } -static NSDictionary *RCTPushyReadResumeMeta(NSString *savePath, NSString *url) { - NSData *data = [NSData dataWithContentsOfFile:RCTPushyResumeSidecarPath(savePath)]; - if (data == nil) { - return nil; - } - id meta = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![meta isKindOfClass:[NSDictionary class]]) { - return nil; - } - // An archive whose sidecar names another URL is untrusted. - if (![url isEqualToString:meta[@"url"]]) { - return nil; - } - return meta; +// The sidecar's text, "" when there is none or it cannot be one (larger than +// a sidecar ever is): pushy::resume treats both as "no trusted partial". +static NSString *RCTPushyReadSidecarText(NSString *savePath) { + NSString *path = RCTPushyResumeSidecarPath(savePath); + NSNumber *size = [[NSFileManager defaultManager] attributesOfItemAtPath:path + error:nil][NSFileSize]; + if (size == nil || size.longLongValue > pushy::resume::kMaxSidecarBytes) { + return @""; + } + NSString *text = [NSString stringWithContentsOfFile:path + encoding:NSUTF8StringEncoding + error:nil]; + return text ?: @""; +} + +static std::string RCTPushyStd(NSString *value) { + const char *utf8 = value.UTF8String; + return utf8 == nullptr ? std::string() : std::string(utf8); +} + +// A pushy::resume result as a dictionary; nil only if it did not parse. +static NSDictionary *RCTPushyResumeDecision(const std::string &json) { + NSData *data = [NSData dataWithBytes:json.data() length:json.size()]; + id value = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [value isKindOfClass:[NSDictionary class]] ? value : nil; } static void RCTPushyDeleteResumeSidecar(NSString *savePath) { @@ -78,54 +90,6 @@ static long long RCTPushyFreeDiskSpaceForPath(NSString *path) { return nil; } -// "bytes -/". Returns the total (0 when "*"), or -1 when -// missing/malformed, the start does not match the local partial, or the -// range and total contradict each other (end before start, or a numeric -// total not beyond the end — RFC 9110 §14.4). A total of 0 would otherwise -// pass as "unknown" and skip the final size check. -static long long RCTPushyParseContentRange(NSString *header, long long expectedStart) { - if (![header hasPrefix:@"bytes "]) { - return -1; - } - NSString *range = [[header substringFromIndex:6] - stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; - NSRange slash = [range rangeOfString:@"/"]; - NSRange dash = [range rangeOfString:@"-"]; - if (slash.location == NSNotFound || dash.location == NSNotFound - || dash.location > slash.location) { - return -1; - } - NSCharacterSet *whitespace = [NSCharacterSet whitespaceCharacterSet]; - NSString *startPart = [[range substringToIndex:dash.location] - stringByTrimmingCharactersInSet:whitespace]; - NSString *endPart = [[range substringWithRange: - NSMakeRange(dash.location + 1, slash.location - dash.location - 1)] - stringByTrimmingCharactersInSet:whitespace]; - NSString *totalPart = [[range substringFromIndex:slash.location + 1] - stringByTrimmingCharactersInSet:whitespace]; - // longLongValue reads 0 for non-numeric text; require real digits. - NSCharacterSet *digits = [NSCharacterSet decimalDigitCharacterSet]; - if (startPart.length == 0 || endPart.length == 0 - || [startPart rangeOfCharacterFromSet:digits.invertedSet].location != NSNotFound - || [endPart rangeOfCharacterFromSet:digits.invertedSet].location != NSNotFound) { - return -1; - } - long long start = startPart.longLongValue; - long long end = endPart.longLongValue; - if (start != expectedStart || end < start) { - return -1; - } - if ([totalPart isEqualToString:@"*"]) { - return 0; - } - if (totalPart.length == 0 - || [totalPart rangeOfCharacterFromSet:digits.invertedSet].location != NSNotFound) { - return -1; - } - long long total = totalPart.longLongValue; - return total > end ? total : -1; -} - @interface RCTPushyDownloader() @property (nonatomic, strong) NSURLSession *session; @@ -135,7 +99,11 @@ @interface RCTPushyDownloader() @property (copy) NSString *urlString; @property (nonatomic, assign) NSTimeInterval timeoutInterval; @property (nonatomic, strong) NSFileHandle *fileHandle; -@property (nonatomic, strong) NSDictionary *resumeMeta; +// The sidecar the planned request was based on ("" for a fresh request), +// and this response's validators: pushy::resume builds the next sidecar. +@property (nonatomic, copy) NSString *sidecarText; +@property (nonatomic, copy) NSString *responseEtag; +@property (nonatomic, copy) NSString *responseLastModified; @property (nonatomic, assign) long long resumeOffset; // requested Range start @property (nonatomic, assign) long long baseOffset; // granted by the response @property (nonatomic, assign) long long receivedBytes; // streamed this session @@ -191,45 +159,36 @@ - (void)startTransfer return; } - NSFileManager *fileManager = [NSFileManager defaultManager]; - self.resumeMeta = RCTPushyReadResumeMeta(self.savePath, self.urlString); + // What to request is the shared core's decision (pushy::resume). + NSString *sidecarText = self.retriedFromZero ? @"" : RCTPushyReadSidecarText(self.savePath); + long long partialSize = RCTPushyFileSize(self.savePath); + NSDictionary *plan = RCTPushyResumeDecision(pushy::resume::PlanRequestJson( + RCTPushyStd(sidecarText), RCTPushyStd(self.urlString), MAX(0LL, partialSize))); + NSString *action = plan[@"action"]; self.resumeOffset = 0; - if (self.resumeMeta != nil) { - long long size = RCTPushyFileSize(self.savePath); - long long knownTotal = [self.resumeMeta[@"total"] longLongValue]; - if (size > 0 && knownTotal > 0 && size == knownTotal) { - // Fully received in a previous attempt (the process died between - // download end and unzip): nothing left to transfer. - if (self.progressHandler) { - self.progressHandler(knownTotal, knownTotal); - } - [self completeWithError:nil]; - return; - } - if (size > 0 && (knownTotal <= 0 || size < knownTotal)) { - self.resumeOffset = size; + if ([action isEqualToString:@"complete"]) { + // Fully received in a previous attempt (the process died between + // download end and unzip): nothing left to transfer. + long long knownTotal = [plan[@"total"] longLongValue]; + if (self.progressHandler) { + self.progressHandler(knownTotal, knownTotal); } + [self completeWithError:nil]; + return; } - if (self.resumeOffset == 0) { - [fileManager removeItemAtPath:self.savePath error:nil]; - RCTPushyDeleteResumeSidecar(self.savePath); - } - NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; - if (self.resumeOffset > 0) { - // Only resume requests pin the encoding: Range offsets must address - // the same bytes that are on disk. Fresh downloads keep the system's - // transparent gzip handling, matching the pre-resume behaviour for - // servers that compress regardless. - [request setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; - [request setValue:[NSString stringWithFormat:@"bytes=%lld-", self.resumeOffset] - forHTTPHeaderField:@"Range"]; - NSString *validator = self.resumeMeta[@"etag"] ?: self.resumeMeta[@"lastModified"]; - if (validator != nil) { - // With a validator the server falls back to a full 200 when the - // file changed instead of appending mismatched bytes. - [request setValue:validator forHTTPHeaderField:@"If-Range"]; + if ([action isEqualToString:@"resume"]) { + self.resumeOffset = [plan[@"offset"] longLongValue]; + self.sidecarText = sidecarText; + // Range, Accept-Encoding: identity and If-Range. Fresh downloads send + // none of them and keep the system's transparent gzip handling. + NSDictionary *headers = plan[@"headers"]; + for (NSString *name in headers) { + [request setValue:headers[name] forHTTPHeaderField:name]; } + } else { + self.sidecarText = @""; + RCTPushyDiscardArchive(self.savePath); } NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration]; @@ -248,28 +207,15 @@ - (void)startTransfer [task resume]; } -- (void)writeResumeMetaWithResponse:(NSHTTPURLResponse *)response total:(long long)total +- (void)writeSidecarWithTotal:(long long)total { - NSMutableDictionary *meta = [NSMutableDictionary dictionary]; - meta[@"url"] = self.urlString; - NSString *etag = response.allHeaderFields[@"ETag"] ?: self.resumeMeta[@"etag"]; - NSString *lastModified = - response.allHeaderFields[@"Last-Modified"] ?: self.resumeMeta[@"lastModified"]; - if (etag != nil) { - meta[@"etag"] = etag; - } - if (lastModified != nil) { - meta[@"lastModified"] = lastModified; - } - if (total > 0) { - meta[@"total"] = @(total); - } - NSData *data = [NSJSONSerialization dataWithJSONObject:meta options:0 error:nil]; - if (data != nil) { - // Non-fatal on failure: without a sidecar the next attempt starts - // from zero. - [data writeToFile:RCTPushyResumeSidecarPath(self.savePath) atomically:YES]; - } + std::string json = pushy::resume::SidecarJson( + RCTPushyStd(self.urlString), RCTPushyStd(self.responseEtag), + RCTPushyStd(self.responseLastModified), RCTPushyStd(self.sidecarText), total); + NSData *data = [NSData dataWithBytes:json.data() length:json.size()]; + // Non-fatal on failure: without a sidecar the next attempt starts from + // zero. + [data writeToFile:RCTPushyResumeSidecarPath(self.savePath) atomically:YES]; } - (void)closeFileHandle @@ -321,7 +267,7 @@ - (void)failWithDescription:(NSString *)description code:(NSInteger)code - (void)restartFromZero { self.retriedFromZero = YES; - self.resumeMeta = nil; + self.sidecarText = @""; self.resumeOffset = 0; self.baseOffset = 0; self.receivedBytes = 0; @@ -382,13 +328,33 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data [self completeWithError:self.fileError]; return; } - if (statusCode == 416) { - long long knownTotal = [self.resumeMeta[@"total"] longLongValue]; + // What to do with this response is the shared core's decision + // (pushy::resume). A non-HTTP response cannot happen for an http(s) URL; + // it would carry no status, so it is classified like a plain 200. + NSDictionary *headers = httpResponse.allHeaderFields; + NSDictionary *decision = RCTPushyResumeDecision(pushy::resume::ClassifyResponseJson( + httpResponse != nil ? (int)statusCode : 200, self.resumeOffset, + RCTPushyStd(headers[@"Content-Range"]), RCTPushyStd(headers[@"Content-Encoding"]), + response.expectedContentLength, RCTPushyStd(self.sidecarText), + RCTPushyStd(self.urlString), MAX(0LL, RCTPushyFileSize(self.savePath)))); + NSString *action = decision[@"action"]; + if ([action isEqualToString:@"complete"]) { + // 416 and the partial is actually the complete file. + long long knownTotal = [decision[@"total"] longLongValue]; + completionHandler(NSURLSessionResponseCancel); + if (self.progressHandler) { + self.progressHandler(knownTotal, knownTotal); + } + [self completeWithError:nil]; + return; + } + if ([action isEqualToString:@"restart"]) { + // A stale 416, encoded range bytes or a malformed/mismatched + // Content-Range: the partial cannot be trusted. One clean retry from + // zero instead of failing terminally, which would keep the partial + // and hit the same wall on every attempt. completionHandler(NSURLSessionResponseCancel); - if (knownTotal > 0 && RCTPushyFileSize(self.savePath) == knownTotal) { - // The partial is actually the complete file. - [self completeWithError:nil]; - } else if (!self.retriedFromZero) { + if (!self.retriedFromZero) { [self restartFromZero]; } else { [self failWithDescription:@"server rejected the download range" code:statusCode]; @@ -396,7 +362,8 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data } return; } - if (httpResponse != nil && (statusCode < 200 || statusCode >= 300)) { + if (![action isEqualToString:@"write"]) { + // The partial and its sidecar stay: that is the resume state. [self failWithDescription:[NSString stringWithFormat:@"unexpected http status code %ld", (long)statusCode] code:statusCode]; @@ -405,39 +372,17 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data return; } - NSString *contentEncoding = - [httpResponse.allHeaderFields[@"Content-Encoding"] lowercaseString]; - self.encodedBody = - contentEncoding.length > 0 && ![contentEncoding isEqualToString:@"identity"]; - - BOOL append = statusCode == 206 && self.resumeOffset > 0; - if (append && (self.encodedBody - || RCTPushyParseContentRange(httpResponse.allHeaderFields[@"Content-Range"], - self.resumeOffset) < 0)) { - // Encoded range bytes or a malformed/mismatched Content-Range: the - // appended bytes could not be trusted. Treat like a stale partial — - // one clean retry from zero — instead of failing terminally, which - // would keep the partial and hit the same wall on every attempt. - completionHandler(NSURLSessionResponseCancel); - if (!self.retriedFromZero) { - [self restartFromZero]; - } else { - [self failWithDescription:@"untrusted resume response" code:-1]; - [self completeWithError:self.fileError]; - } - return; - } - self.baseOffset = append ? self.resumeOffset : 0; + BOOL append = [decision[@"append"] boolValue]; + // NSURLSession delivers decoded bytes for an encoded body: the core then + // reports total 0 (the encoded lengths say nothing about the file). + self.encodedBody = [decision[@"encoded"] boolValue]; + self.baseOffset = [decision[@"base"] longLongValue]; self.contentLength = response.expectedContentLength; - if (append) { - self.expectedTotal = RCTPushyParseContentRange( - httpResponse.allHeaderFields[@"Content-Range"], self.resumeOffset); - } else if (self.encodedBody) { - // Decoded bytes are being written; the encoded lengths say nothing. - self.expectedTotal = 0; - } else { - self.expectedTotal = self.contentLength > 0 ? self.contentLength : 0; - } + self.expectedTotal = [decision[@"total"] longLongValue]; + id etag = headers[@"ETag"]; + id lastModified = headers[@"Last-Modified"]; + self.responseEtag = [etag isKindOfClass:[NSString class]] ? etag : nil; + self.responseLastModified = [lastModified isKindOfClass:[NSString class]] ? lastModified : nil; // Caps from archive_limits.h, applied before the first body byte lands // (Android does the same): the announced size up front here, streamed @@ -494,13 +439,13 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data return; } } - if (self.encodedBody) { + if (![decision[@"persistSidecar"] boolValue]) { // No resume across an encoded transfer: on-disk bytes are decoded, // Range offsets would address the encoded representation. RCTPushyDeleteResumeSidecar(self.savePath); - } else if (httpResponse != nil) { + } else { // Persist before streaming so a mid-stream crash can resume. - [self writeResumeMetaWithResponse:httpResponse total:self.expectedTotal]; + [self writeSidecarWithTotal:self.expectedTotal]; } completionHandler(NSURLSessionResponseAllow); } @@ -614,6 +559,12 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task finalError = self.fileError; } } + if (finalError == nil && !self.encodedBody) { + // Refresh with the final size: a length unknown up front (chunked) + // left the sidecar without a total, so a process dying between + // download end and unzip would otherwise download it all again. + [self writeSidecarWithTotal:RCTPushyFileSize(self.savePath)]; + } // On failure the partial + sidecar stay on disk — that is the resume // state a later launch (or crash-rescue window) picks up — unless the // archive was rejected outright (discardPartial). diff --git a/scripts/contract.ts b/scripts/contract.ts index 7fc9f290..f13df61c 100644 --- a/scripts/contract.ts +++ b/scripts/contract.ts @@ -278,6 +278,9 @@ export const jniFunctions: Record = { 'InstallRecord.bundleSha256Of': 'InstallRecordBundleSha256', 'ArchiveLimits.checkEntries': 'ArchiveCheckEntries', 'ArchiveLimits.planFreeSpaceProbe': 'ArchivePlanFreeSpaceProbe', + 'ResumePlan.planRequest': 'ResumePlanRequest', + 'ResumePlan.classifyResponse': 'ResumeClassifyResponse', + 'ResumePlan.sidecar': 'ResumeSidecar', }; // NativePatchCore.ts binding -> the NAPI callback in pushy.cpp. @@ -305,6 +308,9 @@ export const napiFunctions: Record = { configsEquivalent: 'FlowConfigsEquivalent', runnableConfig: 'FlowRunnableConfig', normalizeConfiguration: 'FlowNormalizeConfiguration', + resumePlanRequest: 'ResumePlanRequest', + resumeClassifyResponse: 'ResumeClassifyResponse', + resumeSidecar: 'ResumeSidecar', }; export type TextHost = 'cpp' | 'java' | 'arkts'; diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index c7db3fe1..bd28c112 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -62,6 +62,17 @@ c++ \ "$BUILD_DIR/patch_core_test" "$ROOT_DIR/cpp/patch_core/tests/fixtures" "$BUILD_DIR/hbc_transform_test" "$ROOT_DIR/cpp/patch_core/tests/fixtures" +# The resumable-download decisions every platform's downloader delegates to. +c++ \ + -std=c++17 \ + $CORE_WARNINGS \ + $SANITIZE_FLAGS \ + "$ROOT_DIR/cpp/patch_core/tests/resume_plan_test.cpp" \ + "$ROOT_DIR/cpp/patch_core/resume_plan.cpp" \ + "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ + -o "$BUILD_DIR/resume_plan_test" +"$BUILD_DIR/resume_plan_test" "$ROOT_DIR/cpp/patch_core/tests/resume-plan-vectors.json" + # Opt-in libFuzzer runs of the hbcTransform wire parser (untrusted __diff.json # metadata) and of the whole __diff.json parser: FUZZ=1 bun run test:patch-core. if cpp_test_fuzz_compiler; then diff --git a/src/__tests__/harmonyDownloadTask.test.ts b/src/__tests__/harmonyDownloadTask.test.ts index 23890390..efd99278 100644 --- a/src/__tests__/harmonyDownloadTask.test.ts +++ b/src/__tests__/harmonyDownloadTask.test.ts @@ -1,72 +1,9 @@ import { describe, expect, test } from 'bun:test'; import { DownloadTaskParams } from '../../harmony/pushy/src/main/ets/DownloadTaskParams'; -import { BUNDLE_DELTA_ENTRY } from '../../harmony/pushy/src/main/ets/Texts'; -import { evalHarmonyModule } from './nativeSources'; -// The pure parsers of Harmony DownloadTask.ts, evaluated from source with its -// platform imports stripped (the download/patch class itself needs a device). -// InstallRecord.ts imports the NAPI module, so its bundle name is inlined. -// __diff.json is parsed by the native core only -// (cpp/patch_core/tests/patch-manifest-vectors.json). -const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; - -const { parseContentRangeTotal } = evalHarmonyModule( - 'DownloadTask.ts', - '({ parseContentRangeTotal });', - { HARMONY_BUNDLE_FILE_NAME, BUNDLE_DELTA_ENTRY } -) as { - parseContentRangeTotal(header: string, expectedStart: number): number; -}; - -// A 206's Content-Range decides whether appended bytes can be trusted. -describe('harmony parseContentRangeTotal', () => { - test('returns the total for a well-formed range at the expected start', () => { - expect(parseContentRangeTotal('bytes 100-199/200', 100)).toBe(200); - expect(parseContentRangeTotal('bytes 0-9/10', 0)).toBe(10); - expect(parseContentRangeTotal('bytes 100-100/200', 100)).toBe(200); - }); - - test('tolerates surrounding whitespace', () => { - expect(parseContentRangeTotal('bytes 100-199 / 200 ', 100)).toBe(200); - }); - - test('returns 0 for an unknown total (*)', () => { - expect(parseContentRangeTotal('bytes 100-199/*', 100)).toBe(0); - }); - - test('rejects a start that does not match the local partial', () => { - expect(parseContentRangeTotal('bytes 50-199/200', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 0-199/200', 100)).toBe(-1); - }); - - test('rejects missing or foreign units', () => { - expect(parseContentRangeTotal('', 0)).toBe(-1); - expect(parseContentRangeTotal('100-199/200', 100)).toBe(-1); - expect(parseContentRangeTotal('items 100-199/200', 100)).toBe(-1); - }); - - test('rejects malformed ranges', () => { - expect(parseContentRangeTotal('bytes 100-199', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 100/200', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes */200', 0)).toBe(-1); - expect(parseContentRangeTotal('bytes abc-199/200', 100)).toBe(-1); - }); - - test('rejects non-positive or non-numeric totals', () => { - expect(parseContentRangeTotal('bytes 100-199/0', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 100-199/-5', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 100-199/abc', 100)).toBe(-1); - }); - - // A numeric total must exceed end (RFC 9110 §14.4); end cannot precede start. - test('rejects a range that contradicts its total', () => { - expect(parseContentRangeTotal('bytes 100-199/199', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 100-199/150', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 100-99/200', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 100-99/*', 100)).toBe(-1); - expect(parseContentRangeTotal('bytes 100-x/200', 100)).toBe(-1); - }); -}); +// The resume decisions DownloadTask.ts used to parse itself (Content-Range +// and the rest) live in the shared native core and are pinned by +// cpp/patch_core/tests/resume-plan-vectors.json. describe('harmony DownloadTaskParams', () => { test('defaults to a full-package task with empty paths', () => {