mirror of
https://github.com/yuzono/anime-extensions.git
synced 2026-06-13 05:29:44 +00:00
Fixes/hanime: Experimental full extension rewrite for new website obfuscation (#192)
* Docs
* Docs
* Docs
* Docs
* feat(hanime): Chicory WASM signature system with live API integration tests - Replace custom ~5600-line WASM interpreter with Chicory runtime (364KB dep) - WASM binary always fetched fresh from live hanime.tv JS bundle (no disk cache) - Chicory WASM Runtime is now the default signature provider (WebView is fallback) - Add 63 unit + integration tests (all passing): - ChicoryGlueTest (17): WASM import function types - ChicorySignatureProviderTest (11): Provider lifecycle - ChicorySignatureProviderIntegrationTest (5): End-to-end WASM signatures - HanimeApiIntegrationTest (11): Live API endpoint validation - SignatureProviderTest (15): Signature cache/headers/expiry - Base64HelperTest (4): Platform Base64 abstraction - Remove android.util.Log from HanimeWasmBinary (JVM test compat) - Use java.net.HttpURLConnection for integration tests (no Android deps) - Add kotlinx-serialization-json 1.7.3 test dependency - Signature freshness: generate immediately before manifest requests - CDN manifest endpoint uses cached.freeanimehentai.net origin
* Bump version
* fix: avoid IllegalStateException from consumed response body in fetchVideoListWithSignature
videoResponse.body.string() at line 178 consumes the OkHttp response body, making subsequent calls to videoListParse(videoResponse) fail with IllegalStateException: closed. Replace all three fallback calls with a new parseVideoModelString(videoString) helper that reuses the already-read string instead of attempting to re-read the closed response body.
* fix: add missing hv_id field to ManifestStream data class
The ManifestStream data class was missing the hv_id field, which is required by the logic in Hanime.kt to resolve the video ID for manifest requests. Without it, the extension would fail to correctly identify the video ID from manifest streams.
* fix: throw SignatureException when WASM malloc is unavailable
Writing to an arbitrary memory address (endAddr) as a fallback when
malloc is unavailable is dangerous and can lead to memory corruption
or crashes if it overlaps with the WASM stack or other data. It is
safer to throw a SignatureException if the required memory management
functions are not exported by the WASM module.
* fix: avoid runBlocking in lazy signatureProvider initialization
Using runBlocking inside a lazy property initializer is risky.
If signatureProvider is accessed from the main thread (e.g., during
preference setup or UI interactions), it will perform synchronous
network requests on the main thread, leading to a
NetworkOnMainThreadException or an ANR error.
Replace the lazy property with a nullable var initialized via a
suspend helper that uses withContext(Dispatchers.IO) instead of
runBlocking.
* fix: replace blocking execute() with non-blocking await() in suspend functions
Blocking calls like execute() should be avoided in suspend functions.
Use the await() extension function from the network library to perform
non-blocking network requests instead. This prevents thread starvation
and allows proper coroutine cancellation.
* fix: remove unused addSignatureHeaders extension function
The addSignatureHeaders extension function is defined but not used anywhere in the class. The actual signature header logic is handled inline in fetchVideoListWithSignature. Remove the dead code to reduce confusion and maintenance burden.
* feat(en/hanime): harden extension robustness across security, threading, and reliability
Security:
- Fix JSON injection in searchRequestBody/latestSearchRequestBody by replacing string-template interpolation with kotlinx.serialization SearchRequest data class; remove pre-quoted JSON strings from SearchParameters (List<String> instead of ArrayList<String>)
Resource management:
- Fix OkHttp Response body leaks in HanimeWasmBinary.fetchPage, fetchVideoListWithSignature, and fetchManifestVideos by wrapping all responses in .use {} blocks
Thread safety:
- Add Mutex-based double-checked locking to ensureSignatureProvider()
- Make SignatureCache.invalidate() suspend with lock acquisition
- Add @Volatile to ChicoryGlue captured fields (capturedSignature, capturedTimestamp)
- Replace mutableSetOf with ConcurrentHashMap.newKeySet for registeredEventTypes
- Add isClosed flag to ChicorySignatureProvider; getSignature() throws after close(), close() acquires initMutex to prevent TOCTOU races
- Make ChicorySignatureProvider.initialize() private
- Replace instance!!/glue!! with local references in getSignature()
- Add @Volatile to authCookie, replace authCookie!! with safe capture
Reliability:
- Add signature refresh-and-retry on 401 from manifest endpoint (invalidates cache, retries once with fresh signature)
- Fix WebView onReceivedSslError to cancel instead of proceed
- Fix WebView onReceivedError to fast-fail for main frame requests
- Fix WebView pollForSignature race (check result before evaluateJavascript)
- Remove redundant @Synchronized from ChicorySignatureProvider.initialize()
Data integrity:
- Use HttpUrl.resolve() instead of string concatenation for URL resolution
- Add MAX_SIGNATURE_LENGTH bounds check in readEmAsmArgs
- Log warnings for unknown ASM_CONSTS IDs
- Clear registeredEventTypes in ChicoryGlue.reset()
- Add null-safety to fetchVideoListPremium, animeDetailsParse, parseSearchJson (empty hits guard)
- Deduplicate videoListParse -> parseVideoModelStreams
- Update User-Agent from Chrome/120 to Chrome/130
Tests:
- Add missing assertNotEquals in testMultipleSignaturesAreDifferent
- Migrate ChicorySignatureProviderTest from runBlocking to runTest
- Standardize Base64HelperTest from JUnit 4 to kotlin.test
- Update tests for private initialize()
* build(en/hanime): override minSdk to 26 for Chicory WASM runtime
The Chicory WASM runtime library (com.dylibso.chicory:runtime:1.7.5) uses
MethodHandle.invoke/MethodHandle.invokeExact bytecode that D8 cannot
desugar below API 26. Override the project-wide minSdk (21) to 26 in the
hanime extension's defaultConfig. This means the extension requires
Android 8.0+ — a hard constraint of the WASM runtime library with no
workaround available.
* Migrate from dead search.htv-services.com to new v10 search API
The old POST-based search endpoint (search.htv-services.com) returns
403 Forbidden for all requests. Migrated to the new GET-based v10 API
at cached.freeanimehentai.net/api/v10/search_hvs with signature headers.
Key changes:
- Replace POST search with GET /api/v10/search_hvs + signature headers
- HAnimeResponse.hits changed from JSON-in-JSON strings to direct List<HitsModel>
- HitsModel updated with new v10 fields (searchTitles, createdAtUnix, etc.)
- HentaiFranchiseHentaiVideo.id changed from Long? to String?
- Added client-side search: fetchSearchHits() caches full API response,
paginateHits() handles filtering (text, tags, brands, blacklist) and sorting
- Removed old SearchRequest/SearchParameters data classes and request builders
- Added SearchParameters as private inner class for client-side filtering
* Update integration tests for v10 search API migration
- Changed SEARCH_URL to new v10 endpoint (cached.freeanimehentai.net)
- Replaced POST-based search with GET + signature headers
- Simplified parseHits() - hits are now direct JSON objects (no double-parse)
- Added client-side filter helpers (filterHitsByKeyword, filterHitsByTag)
- Removed nbPages assertions (no server-side pagination)
- Updated pagination test for client-side slicing
- Video/manifest/franchise tests unchanged (those endpoints still work)
* Use slug instead of numeric ID in episode URLs
Episode URLs were constructed with numeric IDs (e.g., ?id=5) which is
fragile — the API happens to accept numeric IDs but slugs are the
canonical identifier. Changed episodeListParse() to use it.slug instead
of it.id, and renamed the misleading variable in fetchVideoListPremium()
from `id` to `slug` since it now holds a slug. This also fixes the
premium path which constructs web page URLs like /videos/hentai/<slug>
— numeric IDs don't resolve on the web frontend, but slugs do.
* Fix JsonDecodingException: parse v10 search API as array not object
The v10 search API returns a raw JSON array [{...}] at the root level,
not a wrapped object {"hits": [{...}]}. Changed fetchSearchHits() to
parse the response directly as List<HitsModel> instead of HAnimeResponse.
Removed the dead HAnimeResponse wrapper class (its pagination fields were
from the old search.htv-services.com API). Updated integration test to
parse the array response format.
* Fix JsonDecodingException: change id/brandId from String? to Long?
The /api/v8/video endpoint returns id and brand_id as unquoted
JSON numbers (e.g., "id":2948, "brand_id":92), but
HentaiFranchiseHentaiVideo declared id as String? and both data
classes declared brandId as String?. This caused "Expected
quotation mark but had '2' instead" crashes.
Changed:
- HentaiFranchiseHentaiVideo.id: String? → Long?
- HentaiFranchiseHentaiVideo.brandId: String? → Long?
- HentaiVideo.brandId: String? → Long?
All three fields are dead data (never read in Hanime.kt) so no
downstream code changes were needed.
* Fix video playback: height type mismatch, missing CDN headers, guest filtering
Root cause: Stream.height was declared as String but the API returns an
integer (e.g., "height":720), causing SerializationException on every
video response parse. Changed height to Int? in all three Stream data
classes (Stream, ManifestStream, WindowNuxt.Stream).
Additional fixes:
- Added Referer/Origin headers to Video constructor for CDN playback
(servers return 403 without proper Referer)
- Added isGuestAllowed filter in parseVideoModelStreams() so non-premium
users only get streams they can actually play
- Removed content-type and accept from SignatureHeaders.build()
(content-type is invalid on GET requests, accept was duplicated with
base headers)
- Wrapped fallback parseVideoModelStreams() in try-catch to prevent
double-exception crash when both manifest and direct parsing fail
- Updated tests for removed signature headers
* feat(en/hanime): rewrite video flow with PlaylistUtils and bypass decoy manifest
- Add PlaylistUtils dependency for proper HLS master playlist parsing
- Rewrite fetchVideoListWithSignature() to clearly document 2-step flow: 1) Get hvId from /api/v8/video, 2) Fetch real streams from guest manifest
- Replace parseManifestResponse() with parseManifestStreams() using PlaylistUtils.extractFromHls() for proper multi-quality HLS handling
- Add playerVideoHeaders() with correct Referer: https://player.hanime.tv/ (required for m3u8, AES key, and segment CDN requests)
- Add tryParseDecoyStreams() as last-resort fallback for decoy manifest
- Rewrite fetchVideoListPremium() with PlaylistUtils.extractFromHls()
- Update parseVideoModelStreams() to use playerVideoHeaders()
- Add name field to WindowNuxt.Server for server identification
- Add signing key and Windows artifacts to .gitignore
* chore(en/hanime): remove outdated docs and wasm dump files
* fix(en/hanime): replace decoy fallback with empty list, use CDN manifest for premium
- tryParseDecoyStreams() now returns emptyList() instead of broken streamable.cloud URLs that never play (clean failure > broken videos)
- fetchVideoListPremium() now tries CDN guest manifest first (real Golem streams), falls back to __NUXT__ manifest only if CDN fails
- Added hentai_video field to WindowNuxt.Data.DataVideo for hvId extraction
- Fallback catch in premium path returns emptyList() instead of broken Video
* fix(hanime): label video streams with proper quality
Replace generic "Video" fallback in videoNameGen lambdas with actual stream height (e.g., 720p, 1080p) so streams display as "Golem - 720p" instead of "Golem - Video".
* fix(hanime): filter franchise episodes to current series only
The API's hentai_franchise_hentai_videos returns ALL videos in a franchise (broader than a single series). This caused episodes from different sub-series to appear mixed together (e.g., Immoral Sisters 2 episodes showing under Immoral Sisters 1).
- Filter franchise videos by matching series name via getTitle()
- Use actual episode names from API instead of generic 'Episode N'
- Fix IndexOutOfBoundsException in getTitle() for single-word numeric titles
- Replace .ifEmpty fallback with safe single-episode fallback
- Embed hvid in episode URLs and use it to skip /api/v8/video resolution
- Add extractHvIdFromUrl() helper for direct manifest lookup
* fix(hanime): add unfiltered stream fallback for multi-episode video playback
When fetchManifestVideos fails (CDN guest endpoint returns empty or non-guest-allowed streams), the signature path had no working fallback. Only premium users (with auth cookie from webview) could play videos via the HTML/__NUXT__ fallback.
- Add parseVideoModelStreamsUnfiltered() that parses VideoModel manifest streams without the isGuestAllowed filter (only filters premium_alert and requires .m3u8)
- Update fetchVideoListWithSignature to use this as final fallback instead of tryParseDecoyStreams which always returned empty
- Update fetchVideoListPremium to share the same unfiltered fallback
- Both paths now have consistent 3-level fallback: direct manifest → slug-resolved manifest → unfiltered API streams
* Harden signature generation system - Fix ChicoryGlue.reset() destroying WASM event listener registrations (root cause of intermittent playback failure) - Add ChicoryGlue.fullReset() for complete teardown on re-init - Fix Signature TTL: reduce from 4min to 90s (CDN s-maxage=120) - Add Signature.validate() for format + timestamp verification - Add malloc null-pointer validation in ChicorySignatureProvider - Fix close() deadlock: remove runBlocking from ChicorySignatureProvider - Add reinitialize() + retry logic in ChicorySignatureProvider - Extract generateSignature() method for retry support - Harden WASM binary extraction: retry logic, timeouts, fallback regex - Remove SignatureCache layer (no value — WASM execution is fast) - Replace isStale/markStale with signatureProviderMode comparison - Escalating 401 recovery: fresh sig -> provider recreate - Add search hits cache TTL (10 min) - Preference change forces provider recreation - Add @Volatile to signatureProvider and signatureProviderMode - Capture response code inside use{} block - Extract Regex to companion constant in Signature.validate() - Add unit tests for validate(), fullReset(), reset preserves eventTypes - 66/66 tests pass
* Add comprehensive debug logging to hanime extension for 401 playback diagnosis
* fix: add content-type and sec-ch-ua headers to fix 401 on manifest requests for some videos
* fix: implement __tzset_js to write timezone data to WASM memory for valid signature generation
* Fix WebView signature provider: switch default from WASM to WebView
The Chicory JVM WASM interpreter produces invalid cryptographic signatures because it stubs the embind/emval system (returns 0 for all JS object interactions), causing WASM internal state to diverge from a real browser. The server rejects these with HTTP 401.
Changes:
- Switch default signature provider from wasm to webview
- Update preference labels: WebView (Recommended), Chicory WASM (Experimental)
- Add Mutex to serialize getSignature() calls — prevents concurrent WebView creation that caused Already resumed IllegalStateException crash
- Add AtomicBoolean double-resume guard replacing non-thread-safe var boolean
- Guard onPageFinished against duplicate poll chains from redirects
- Add signature caching with 2-minute TTL — first video ~5-15s, subsequent instant
- Fix ktlint error: replace $JS_INTERFACE_NAME template in raw string with placeholder + runtime replace
- Replace Unicode characters with ASCII equivalents for ktlint compliance
* Delete stacktrace.txt
* feat(hanime): robustness and efficiency improvements
Thread safety:
- Fix race condition in ensureSignatureProvider() double-check locking
- Add mutex for search cache to prevent redundant API fetches
- Make ChicoryGlue.asmConsts thread-safe (ConcurrentHashMap)
Feature fixes:
- Implement AND tag inclusion mode (was collected but ignored)
- Add alphabetical sort support (title_sortable was silently broken)
Code cleanup:
- Remove dead code: searchApiRequest, tryParseDecoyStreams, videoListParse, parseVideoModelStreams
- Consolidate slug extraction into extractSlugFromUrl() helper
- Fix getTitle() to not strip legitimate trailing numbers
- Remove all signature value logging (security concern)
- Reduce excessive debug logging across all source files
- Add blank body check in HanimeWasmBinary
Repository cleanup:
- Remove stray nul file, .class files, __pycache__, empty dirs
* feat(hanime): add native signature provider and fix WASM JS environment mock
- Add NativeSignatureProvider: direct SHA-256 signature computation
Algorithm: SHA256(,Xkdi29,https://hanime.tv,mn2,)
Zero WASM dependency, instant init, works on all API levels
Overridable timestampProvider for deterministic testing
- Fix ChicoryGlue emval environment mock:
Add EmvalHandleManager with full JS environment simulation
window.top.location.origin -> https://hanime.tv (was returning undefined)
Real __emval_get_global, __emval_get_property, __emval_new_cstring
Real __emval_decref with handle release
Thread-safe ConcurrentHashMap for JsObject properties
Transient handle tracking to prevent .length handle leaks
- Update Hanime.kt:
Add native as default signature provider option
Fix race condition in ensureSignatureProvider()
- Add comprehensive tests:
NativeSignatureProviderTest with known-answer vectors
18 new ChicoryGlueTest emval tests (36 total)
Root cause: WASM reads window.location.origin via emval chain during
signature computation. Stub emval functions returning 0 (undefined)
caused different SHA-256 output than the browser.
* Delete leftovers
* feat(hanime): add QoL extension preferences
Add 4 new user-configurable preferences:
- Censored Content Filter (Show All/Uncensored Only/Censored Only)
- Include Premium Streams toggle
- Search Cache Duration (1/5/10/30 minutes, was hardcoded 10 min)
- Custom CDN Domain override with URL validation
Also migrate existing preferences (Preferred Quality, Signature Provider)
from verbose ListPreference pattern to keiyoushi.utils extension functions.
* clean up
* use helper utilities to close resource
* optimize stream extraction using parallel processing
* clean up test & project files
* fix episode URL & un-used methods
* do not consume the resume guard on `isResumable`
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Amnesia <amnesia@example.com>
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0adb6c11a9
commit
7b41726584
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,3 +12,5 @@ gen
|
||||
generated-src/
|
||||
.kotlin
|
||||
.history
|
||||
signingkey.jks
|
||||
nul
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
ext {
|
||||
extName = 'hanime.tv'
|
||||
extClass = '.Hanime'
|
||||
extVersionCode = 18
|
||||
extVersionCode = 19
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
android {
|
||||
// Chicory WASM runtime uses MethodHandle.invoke/invokeExact which requires API 26+;
|
||||
// D8 cannot desugar these bytecode patterns. Override the project-wide minSdk (21).
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlistutils"))
|
||||
//noinspection UseTomlInstead
|
||||
implementation("com.dylibso.chicory:runtime:1.7.5")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64 Helper — Platform-aware Base64 delegation
|
||||
// ---------------------------------------------------------------------------
|
||||
// android.util.Base64 is unavailable in JVM unit tests. This thin wrapper
|
||||
// delegates to the Android implementation in production and java.util.Base64
|
||||
// in test environments, keeping the contract identical: decode a Base64
|
||||
// string to raw bytes using DEFAULT (standard) encoding.
|
||||
//
|
||||
// Design: "Parse, Don't Validate" — the caller receives decoded bytes or an
|
||||
// exception. No silent fallbacks. The platform switch is a single top-level
|
||||
// function, so every consumer stays agnostic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decodes a Base64-encoded string into raw bytes.
|
||||
*
|
||||
* Uses `android.util.Base64` on Android and `java.util.Base64` on the
|
||||
* JVM (unit tests). The behavior matches `Base64.DEFAULT` on Android,
|
||||
* which is standard Base64 with line-break tolerance.
|
||||
*
|
||||
* @param input The Base64-encoded string.
|
||||
* @return The decoded byte array.
|
||||
* @throws IllegalArgumentException if [input] is not valid Base64.
|
||||
*/
|
||||
fun decodeBase64(input: String): ByteArray = Base64Provider.decode(input)
|
||||
|
||||
/**
|
||||
* Encodes a byte array into a Base64 string.
|
||||
*
|
||||
* Uses `android.util.Base64` on Android and `java.util.Base64` on the
|
||||
* JVM (unit tests). The behavior matches `Base64.DEFAULT` on Android,
|
||||
* which includes line breaks every 76 characters.
|
||||
*
|
||||
* @param input The bytes to encode.
|
||||
* @return The Base64-encoded string.
|
||||
*/
|
||||
fun encodeBase64(input: ByteArray): String = Base64Provider.encode(input)
|
||||
|
||||
/**
|
||||
* Strategy interface for platform-specific Base64 codecs.
|
||||
*
|
||||
* Production code sets [Base64Provider.instance] to [AndroidBase64]
|
||||
* at class-load time. Test code can replace it with [JvmBase64]
|
||||
* before any decode/encode calls occur.
|
||||
*/
|
||||
internal object Base64Provider {
|
||||
|
||||
var instance: Base64Codec = AndroidBase64
|
||||
|
||||
fun decode(input: String): ByteArray = instance.decode(input)
|
||||
|
||||
fun encode(input: ByteArray): String = instance.encode(input)
|
||||
}
|
||||
|
||||
/** Abstraction over platform-specific Base64 implementations. */
|
||||
internal interface Base64Codec {
|
||||
fun decode(input: String): ByteArray
|
||||
fun encode(input: ByteArray): String
|
||||
}
|
||||
|
||||
/** Android implementation using android.util.Base64. */
|
||||
private object AndroidBase64 : Base64Codec {
|
||||
override fun decode(input: String): ByteArray = android.util.Base64.decode(input, android.util.Base64.DEFAULT)
|
||||
|
||||
override fun encode(input: ByteArray): String = android.util.Base64.encodeToString(input, android.util.Base64.DEFAULT)
|
||||
}
|
||||
|
||||
/** JVM implementation using java.util.Base64 (available since API 26 / Java 8).
|
||||
*
|
||||
* This implementation is **only** used in JVM unit tests where Java 8+ is
|
||||
* guaranteed. It is never instantiated on Android devices; production code
|
||||
* uses [AndroidBase64] instead. The @SuppressLint("NewApi") annotation
|
||||
* suppresses lint's API-level check because this code path is unreachable
|
||||
* on Android runtimes below API 26.
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
object JvmBase64 : Base64Codec {
|
||||
override fun decode(input: String): ByteArray = java.util.Base64.getMimeDecoder().decode(input)
|
||||
|
||||
override fun encode(input: ByteArray): String = java.util.Base64.getMimeEncoder().encodeToString(input)
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
|
||||
import android.util.Log
|
||||
import com.dylibso.chicory.runtime.HostFunction
|
||||
import com.dylibso.chicory.runtime.Instance
|
||||
import com.dylibso.chicory.wasm.types.FunctionType
|
||||
import com.dylibso.chicory.wasm.types.ValType
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Chicory-based WASM glue layer for hanime.tv signature generation.
|
||||
*
|
||||
* Provides 25 host function imports that the emscripten-compiled WASM binary
|
||||
* expects from module "a". Each import becomes a Chicory [HostFunction] that
|
||||
* either no-ops, stubs, or performs real work (ASM_CONSTS bridge, heap resize,
|
||||
* event listener registration, emval environment mock).
|
||||
*
|
||||
* The mission-critical imports are:
|
||||
* - **g** (_emscripten_asm_const_int): the JS↔WASM bridge through which the
|
||||
* binary communicates signatures and timestamps via the ASM_CONSTS table.
|
||||
* - **y** (window_on): registers event listeners; the interpreter dispatches
|
||||
* events to trigger signature computation.
|
||||
* - **p** (__emval_get_global): resolves global names to emval handles.
|
||||
* - **l** (__emval_get_property): resolves object properties by name.
|
||||
* - **m** (__emval_new_cstring): creates emval string handles from C strings.
|
||||
* - **c** (__emval_decref): releases emval handle references.
|
||||
*
|
||||
* The emval environment mock provides the WASM binary with a simulated
|
||||
* `window` object graph (window → location → origin = "https://hanime.tv")
|
||||
* that the signature computation depends on.
|
||||
*/
|
||||
class ChicoryGlue {
|
||||
|
||||
@Volatile var capturedSignature: String? = null
|
||||
private set
|
||||
|
||||
@Volatile var capturedTimestamp: Long? = null
|
||||
private set
|
||||
|
||||
private val registeredEventTypes: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
/** Immutable snapshot of event types registered via import "y" (window_on). */
|
||||
val eventTypes: Set<String> get() = registeredEventTypes.toSet()
|
||||
|
||||
/** Emval handle manager — provides the mock JS environment for the WASM binary. */
|
||||
val emvalManager = EmvalHandleManager()
|
||||
|
||||
// ASM_CONSTS dispatch table — maps integer IDs to handler lambdas.
|
||||
// Each handler receives a LongArray of parsed arguments (matching JS readEmAsmArgs)
|
||||
// and the Instance, returning a Long result.
|
||||
private val asmConsts = ConcurrentHashMap<Int, (LongArray, Instance) -> Long>()
|
||||
|
||||
init {
|
||||
// ID 17392: Returns current Unix timestamp in seconds
|
||||
// JS: () => parseInt(new Date().getTime() / 1e3)
|
||||
asmConsts[ASM_CONST_TIMESTAMP] = { _, _ ->
|
||||
System.currentTimeMillis() / 1000L
|
||||
}
|
||||
|
||||
// ID 17442: Captures signature string + timestamp from WASM
|
||||
// JS: ($0, $1) => { window.ssignature = UTF8ToString($0); window.stime = $1; }
|
||||
// $0 = pointer to signature string, $1 = timestamp integer
|
||||
asmConsts[ASM_CONST_SIGNATURE] = { args, instance ->
|
||||
val sigPtr = args[0].toInt()
|
||||
val timestamp = if (args.size > 1) args[1] else 0L
|
||||
capturedSignature = instance.memory().readCString(sigPtr)
|
||||
capturedTimestamp = timestamp
|
||||
Log.d(TAG, "ASM_CONST_SIGNATURE: captured signature (length=${capturedSignature?.length ?: 0}), timestamp=$timestamp")
|
||||
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emval (Emscripten value) handle manager that simulates the JS environment
|
||||
* the WASM binary expects during signature computation.
|
||||
*
|
||||
* Emscripten's emval system maps JS values to integer handles that WASM code
|
||||
* can pass around. This manager provides a simulated window/location/document
|
||||
* object graph so that `window.location.origin` resolves to "https://hanime.tv".
|
||||
*
|
||||
* Handle conventions follow emscripten's protocol:
|
||||
* - Handle 0 = undefined (by emscripten convention)
|
||||
* - Handle 1+ = allocated values
|
||||
*
|
||||
* Thread-safe via [ConcurrentHashMap] and [AtomicInteger].
|
||||
*/
|
||||
class EmvalHandleManager {
|
||||
|
||||
/** Sealed representation of a JS-like value in the emval system. */
|
||||
sealed class EmvalValue {
|
||||
/** A JS object with named properties (each property maps to an emval handle). */
|
||||
data class JsObject(val properties: MutableMap<String, Int> = ConcurrentHashMap()) : EmvalValue()
|
||||
|
||||
/** A JS string value. */
|
||||
data class JsString(val value: String) : EmvalValue()
|
||||
|
||||
/** A JS number value (integer or floating-point). */
|
||||
data class JsNumber(val value: Double) : EmvalValue()
|
||||
|
||||
/** A JS boolean value. */
|
||||
data class JsBoolean(val value: Boolean) : EmvalValue()
|
||||
|
||||
/** JS undefined singleton. */
|
||||
object JsUndefined : EmvalValue()
|
||||
|
||||
/** JS null singleton. */
|
||||
object JsNull : EmvalValue()
|
||||
|
||||
/** A JS function placeholder. */
|
||||
data class JsFunction(val name: String, val invoker: ((List<Long>) -> Long)? = null) : EmvalValue()
|
||||
}
|
||||
|
||||
private val handles = ConcurrentHashMap<Int, EmvalValue>()
|
||||
private val nextHandle = AtomicInteger(10) // Reserve 0–9 for special values
|
||||
|
||||
/** Handles allocated for short-lived lookups (e.g. `.length` JsNumber) that must be released on reset. */
|
||||
private val transientHandles: MutableSet<Int> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
/** Mark a handle as transient — it will be released by [releaseTransients] without clearing the entire environment. */
|
||||
fun markTransient(handle: Int) {
|
||||
transientHandles.add(handle)
|
||||
}
|
||||
|
||||
/** Release all transient handles and clear the transient set. */
|
||||
fun releaseTransients() {
|
||||
transientHandles.forEach { handles.remove(it) }
|
||||
transientHandles.clear()
|
||||
}
|
||||
|
||||
/** Pre-allocated handles for well-known JS objects. */
|
||||
var windowHandle: Int = 0
|
||||
private set
|
||||
var documentHandle: Int = 0
|
||||
private set
|
||||
var navigatorHandle: Int = 0
|
||||
private set
|
||||
var consoleHandle: Int = 0
|
||||
private set
|
||||
var locationHandle: Int = 0
|
||||
private set
|
||||
|
||||
init {
|
||||
initializeMockEnvironment()
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a new handle for the given [EmvalValue].
|
||||
* @return The integer handle assigned to this value.
|
||||
*/
|
||||
fun allocate(value: EmvalValue): Int {
|
||||
if (value is EmvalValue.JsUndefined) return 0
|
||||
val handle = nextHandle.getAndIncrement()
|
||||
handles[handle] = value
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Retrieve the [EmvalValue] for a given handle, or null if not found. */
|
||||
fun get(handle: Int): EmvalValue? {
|
||||
if (handle == 0) return EmvalValue.JsUndefined
|
||||
return handles[handle]
|
||||
}
|
||||
|
||||
/** Release a handle, removing it from the table. */
|
||||
fun release(handle: Int) {
|
||||
handles.remove(handle)
|
||||
}
|
||||
|
||||
/** Clear all handles and re-initialize the mock environment. */
|
||||
fun clear() {
|
||||
handles.clear()
|
||||
nextHandle.set(10)
|
||||
initializeMockEnvironment()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the mock JS environment that the WASM binary queries
|
||||
* during signature computation.
|
||||
*
|
||||
* The critical call chain is:
|
||||
* `__emval_get_global("window")` → `__emval_get_property(handle, "top")`
|
||||
* → `__emval_get_property(handle, "location")` → `__emval_get_property(handle, "origin")`
|
||||
* → expects "https://hanime.tv"
|
||||
*
|
||||
* Self-referencing window.top === window requires careful ordering:
|
||||
* 1. Allocate the window JsObject first (with an empty property map)
|
||||
* 2. Then set the "top" property to point back to its own handle
|
||||
*/
|
||||
private fun initializeMockEnvironment() {
|
||||
// ── String values ──────────────────────────────────────────
|
||||
val originHandle = allocate(EmvalValue.JsString("https://hanime.tv"))
|
||||
val hrefHandle = allocate(EmvalValue.JsString("https://hanime.tv/"))
|
||||
val hostnameHandle = allocate(EmvalValue.JsString("hanime.tv"))
|
||||
val protocolHandle = allocate(EmvalValue.JsString("https:"))
|
||||
|
||||
// ── Document string properties ─────────────────────────────
|
||||
val domainHandle = allocate(EmvalValue.JsString("hanime.tv"))
|
||||
val referrerHandle = allocate(EmvalValue.JsString(""))
|
||||
val cookieHandle = allocate(EmvalValue.JsString(""))
|
||||
val titleHandle = allocate(EmvalValue.JsString("hanime.tv"))
|
||||
|
||||
// ── Navigator string properties ────────────────────────────
|
||||
val navigatorUserAgent = allocate(
|
||||
EmvalValue.JsString(
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
|
||||
),
|
||||
)
|
||||
val navigatorPlatform = allocate(EmvalValue.JsString("Linux armv8l"))
|
||||
val navigatorLanguage = allocate(EmvalValue.JsString("en-US"))
|
||||
val navigatorAppVersion = allocate(
|
||||
EmvalValue.JsString(
|
||||
"5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
|
||||
),
|
||||
)
|
||||
|
||||
// ── Function stubs ─────────────────────────────────────────
|
||||
val addEventListenerHandle = allocate(EmvalValue.JsFunction("addEventListener"))
|
||||
val dispatchEventHandle = allocate(EmvalValue.JsFunction("dispatchEvent"))
|
||||
val wasmExportsHandle = allocate(EmvalValue.JsObject())
|
||||
|
||||
// ── Location object ────────────────────────────────────────
|
||||
// Create with known handles — location properties already allocated above
|
||||
locationHandle = allocate(
|
||||
EmvalValue.JsObject(
|
||||
ConcurrentHashMap(
|
||||
mapOf(
|
||||
"origin" to originHandle,
|
||||
"href" to hrefHandle,
|
||||
"hostname" to hostnameHandle,
|
||||
"protocol" to protocolHandle,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// ── Console object (no properties needed) ──────────────────
|
||||
consoleHandle = allocate(EmvalValue.JsObject())
|
||||
|
||||
// ── Navigator object ───────────────────────────────────────
|
||||
navigatorHandle = allocate(
|
||||
EmvalValue.JsObject(
|
||||
ConcurrentHashMap(
|
||||
mapOf(
|
||||
"userAgent" to navigatorUserAgent,
|
||||
"platform" to navigatorPlatform,
|
||||
"language" to navigatorLanguage,
|
||||
"appVersion" to navigatorAppVersion,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// ── Document object ────────────────────────────────────────
|
||||
documentHandle = allocate(
|
||||
EmvalValue.JsObject(
|
||||
ConcurrentHashMap(
|
||||
mapOf(
|
||||
"domain" to domainHandle,
|
||||
"referrer" to referrerHandle,
|
||||
"cookie" to cookieHandle,
|
||||
"title" to titleHandle,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// ── Window object (self-referencing: window.top === window) ─
|
||||
// Create the JsObject with a mutable map, allocate to get a handle,
|
||||
// then add the "top" self-reference after allocation.
|
||||
val windowProps = ConcurrentHashMap(
|
||||
mapOf(
|
||||
"location" to locationHandle,
|
||||
"ssignature" to 0, // initially undefined
|
||||
"stime" to 0, // initially undefined
|
||||
"addEventListener" to addEventListenerHandle,
|
||||
"dispatchEvent" to dispatchEventHandle,
|
||||
"wasmExports" to wasmExportsHandle,
|
||||
),
|
||||
)
|
||||
windowHandle = allocate(EmvalValue.JsObject(windowProps))
|
||||
// Self-reference: window.top === window
|
||||
windowProps["top"] = windowHandle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the emscripten ASM_CONSTS argument buffer, mirroring the JS `readEmAsmArgs`.
|
||||
*
|
||||
* Signature characters:
|
||||
* - 'p' = pointer (I32, 4 bytes, aligned to 4)
|
||||
* - 'i' = int (I32, 4 bytes, aligned to 4)
|
||||
* - 'j' = i64 (I64, 8 bytes, aligned to 8)
|
||||
* - 'd' = double (F64, 8 bytes, aligned to 8)
|
||||
*
|
||||
* Non-'p'/'i' chars are "wide" (8 bytes) and get 8-byte alignment.
|
||||
* 'p' and 'i' are narrow (4 bytes) with 4-byte alignment.
|
||||
*/
|
||||
private fun readEmAsmArgs(sigPtr: Int, argBuf: Int, instance: Instance): LongArray {
|
||||
val result = mutableListOf<Long>()
|
||||
val mem = instance.memory()
|
||||
var buf = argBuf
|
||||
var offset = 0
|
||||
|
||||
var iterations = 0
|
||||
while (iterations < MAX_SIGNATURE_LENGTH) {
|
||||
val ch = mem.readU8(sigPtr + offset).toInt().toChar()
|
||||
if (ch == '\u0000') break // null terminator
|
||||
iterations++
|
||||
offset++
|
||||
|
||||
val wide = ch != 'i' && ch != 'p'
|
||||
// Align to 8 for wide types
|
||||
if (wide && buf % 8 != 0) {
|
||||
buf += 4
|
||||
}
|
||||
|
||||
result.add(
|
||||
when (ch) {
|
||||
'p' -> mem.readI32(buf) and 0xFFFFFFFFL // unsigned I32 pointer
|
||||
'i' -> mem.readI32(buf) // signed I32
|
||||
'j' -> mem.readI64(buf) // I64
|
||||
'd' -> {
|
||||
// F64 bits — read raw 8 bytes as Long for bit-exact representation
|
||||
val low = mem.readI32(buf) and 0xFFFFFFFFL
|
||||
val high = mem.readI32(buf + 4) and 0xFFFFFFFFL
|
||||
(high shl 32) or low
|
||||
}
|
||||
else -> 0L // unknown type char — skip with 0
|
||||
},
|
||||
)
|
||||
buf += if (wide) 8 else 4
|
||||
}
|
||||
|
||||
return result.toLongArray()
|
||||
}
|
||||
|
||||
/** Reset captured state before a new signature generation cycle. */
|
||||
fun reset() {
|
||||
capturedSignature = null
|
||||
capturedTimestamp = null
|
||||
emvalManager.releaseTransients()
|
||||
// NOTE: registeredEventTypes is NOT cleared here.
|
||||
// Event type registrations are one-time setup performed during _main()
|
||||
// and must persist for the lifetime of the WASM instance.
|
||||
// Only captured output values need clearing before each new computation.
|
||||
}
|
||||
|
||||
/** Full reset including event type registrations and emval handles — use only when re-initializing the WASM instance. */
|
||||
fun fullReset() {
|
||||
capturedSignature = null
|
||||
capturedTimestamp = null
|
||||
registeredEventTypes.clear()
|
||||
emvalManager.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all 25 [HostFunction] imports for module "a".
|
||||
*
|
||||
* IMPORTANT: The [FunctionType] for each binding MUST exactly match what the
|
||||
* WASM binary declares in its import section. If there's a mismatch, Chicory
|
||||
* will throw at instantiation time with a clear error message — adjust the
|
||||
* FunctionType accordingly.
|
||||
*/
|
||||
fun buildHostFunctions(): List<HostFunction> {
|
||||
val module = "a"
|
||||
val functions = mutableListOf<HostFunction>()
|
||||
|
||||
// ═══ a = embind type registration (i32, i32, i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"a",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ b = embind type registration (i32, i32, i32, i32, i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"b",
|
||||
FunctionType.of(
|
||||
listOf(ValType.I32, ValType.I32, ValType.I32, ValType.I32, ValType.I32),
|
||||
emptyList(),
|
||||
),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ c = __emval_decref (i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"c",
|
||||
FunctionType.of(listOf(ValType.I32), emptyList()),
|
||||
) { _, args ->
|
||||
val handle = args[0].toInt()
|
||||
if (handle > 0) {
|
||||
emvalManager.release(handle)
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
// ═══ d = embind type registration (i32, i32, i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"d",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ e = embind type registration (i32, i32, i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"e",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ f = embind type registration (i32, i32, i32, i64, i64) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"f",
|
||||
FunctionType.of(
|
||||
listOf(ValType.I32, ValType.I32, ValType.I32, ValType.I64, ValType.I64),
|
||||
emptyList(),
|
||||
),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ g = _emscripten_asm_const_int — CRITICAL ASM_CONSTS BRIDGE ═══
|
||||
// This is how the WASM binary communicates with the JS world.
|
||||
// Signature: (i32, i32, i32) -> (i32)
|
||||
// args[0] = code — ASM_CONSTS integer ID
|
||||
// args[1] = sigPtr — pointer to signature string (e.g. "pi" = pointer + int)
|
||||
// args[2] = argbuf — pointer to argument buffer in WASM memory
|
||||
//
|
||||
// Mirrors the JS: _emscripten_asm_const_int = (code, sigPtr, argbuf) =>
|
||||
// runEmAsmFunction(code, sigPtr, argbuf)
|
||||
// where runEmAsmFunction reads the signature, parses argbuf, then
|
||||
// dispatches to ASM_CONSTS[code](...parsedArgs).
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"g",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32), listOf(ValType.I32)),
|
||||
) { instance, args ->
|
||||
val code = args[0].toInt()
|
||||
val sigPtr = args[1].toInt()
|
||||
val argBuf = args[2].toInt()
|
||||
|
||||
val handler = asmConsts[code]
|
||||
val result = if (handler != null) {
|
||||
val parsedArgs = readEmAsmArgs(sigPtr, argBuf, instance)
|
||||
handler(parsedArgs, instance)
|
||||
} else {
|
||||
Log.w(TAG, "ASM_CONSTS dispatch: unknown ID=$code — returning 0")
|
||||
0L
|
||||
}
|
||||
longArrayOf(result)
|
||||
}
|
||||
|
||||
// ═══ h = __emval_run_destructors (i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"h",
|
||||
FunctionType.of(listOf(ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ i = __emval_invoke (i32, i32, i32, i32, i32) -> (f64) ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"i",
|
||||
FunctionType.of(
|
||||
listOf(ValType.I32, ValType.I32, ValType.I32, ValType.I32, ValType.I32),
|
||||
listOf(ValType.F64),
|
||||
),
|
||||
) { _, args ->
|
||||
val functionHandle = args[0].toInt()
|
||||
val emval = emvalManager.get(functionHandle)
|
||||
Log.d(TAG, "emval_invoke: handle=$functionHandle, emval=$emval")
|
||||
// Return 0.0 for most invocations (void functions).
|
||||
// The critical path (addEventListener) is handled by import y.
|
||||
longArrayOf(0L)
|
||||
}
|
||||
|
||||
// ═══ j = __emval_incref (i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"j",
|
||||
FunctionType.of(listOf(ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ k = __emval_create_invoker (i32, i32, i32) -> (i32) ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"k",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32), listOf(ValType.I32)),
|
||||
) { _, _ ->
|
||||
val handle = emvalManager.allocate(EmvalHandleManager.EmvalValue.JsFunction("invoker"))
|
||||
longArrayOf(handle.toLong())
|
||||
}
|
||||
|
||||
// ═══ l = __emval_get_property (i32, i32) -> (i32) ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"l",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32), listOf(ValType.I32)),
|
||||
) { instance, args ->
|
||||
val objectHandle = args[0].toInt()
|
||||
val propNamePtr = args[1].toInt()
|
||||
val propName = instance.memory().readCString(propNamePtr)
|
||||
val resultHandle = when (val emval = emvalManager.get(objectHandle)) {
|
||||
is EmvalHandleManager.EmvalValue.JsObject -> {
|
||||
emval.properties[propName] ?: 0 // undefined if not found
|
||||
}
|
||||
is EmvalHandleManager.EmvalValue.JsString -> {
|
||||
when (propName) {
|
||||
"length" -> {
|
||||
val handle = emvalManager.allocate(
|
||||
EmvalHandleManager.EmvalValue.JsNumber(emval.value.length.toDouble()),
|
||||
)
|
||||
emvalManager.markTransient(handle)
|
||||
handle
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "emval_get_property: handle=$objectHandle ($emval) has no property '$propName' — returning undefined")
|
||||
0 // undefined
|
||||
}
|
||||
}
|
||||
longArrayOf(resultHandle.toLong())
|
||||
}
|
||||
|
||||
// ═══ m = __emval_new_cstring (i32) -> (i32) ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"m",
|
||||
FunctionType.of(listOf(ValType.I32), listOf(ValType.I32)),
|
||||
) { instance, args ->
|
||||
val strPtr = args[0].toInt()
|
||||
val str = instance.memory().readCString(strPtr)
|
||||
val handle = emvalManager.allocate(EmvalHandleManager.EmvalValue.JsString(str))
|
||||
longArrayOf(handle.toLong())
|
||||
}
|
||||
|
||||
// ═══ n = embind destructor (i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"n",
|
||||
FunctionType.of(listOf(ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ o = embind type registration (i32, i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"o",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ p = __emval_get_global (i32) -> (i32) ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"p",
|
||||
FunctionType.of(listOf(ValType.I32), listOf(ValType.I32)),
|
||||
) { instance, args ->
|
||||
val globalNamePtr = args[0].toInt()
|
||||
val globalName = instance.memory().readCString(globalNamePtr)
|
||||
val handle = when (globalName) {
|
||||
"window", "self", "globalThis", "top" -> emvalManager.windowHandle
|
||||
"document" -> emvalManager.documentHandle
|
||||
"navigator" -> emvalManager.navigatorHandle
|
||||
"location" -> emvalManager.locationHandle
|
||||
"console" -> emvalManager.consoleHandle
|
||||
else -> {
|
||||
Log.d(TAG, "emval_get_global: unknown global '$globalName' — returning undefined")
|
||||
0 // undefined handle
|
||||
}
|
||||
}
|
||||
longArrayOf(handle.toLong())
|
||||
}
|
||||
|
||||
// ═══ q = embind type registration (i32, i32, i32, i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"q",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32, ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ r = embind type registration (i32, i32) -> () ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"r",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32), emptyList()),
|
||||
) { _, _ -> null }
|
||||
|
||||
// ═══ ENVIRONMENT / TIMEZONE ═══
|
||||
|
||||
// s = __tzset_js (i32, i32, i32, i32) -> () — writes timezone data to WASM memory
|
||||
// timezonePtr: pointer to write current timezone UTC offset (minutes, i32)
|
||||
// dstPtr: pointer to write DST offset (minutes, i32)
|
||||
// tznamePtr: pointer to write timezone name string (null-terminated)
|
||||
// tznameLen: max length of timezone name string buffer
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"s",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32, ValType.I32), emptyList()),
|
||||
) { instance, args ->
|
||||
val timezonePtr = args[0].toInt()
|
||||
val dstPtr = args[1].toInt()
|
||||
val tznamePtr = args[2].toInt()
|
||||
val tznameLen = args[3].toInt()
|
||||
|
||||
// Java ZONE_OFFSET sign convention is opposite to JS getTimezoneOffset():
|
||||
// JS: UTC-5 → getTimezoneOffset() returns 300 (positive = west of UTC)
|
||||
// Java: UTC-5 → ZONE_OFFSET returns -18000000ms (negative = west of UTC)
|
||||
// So we negate to match the JS convention the WASM binary expects.
|
||||
val rawOffsetMs = java.util.Calendar.getInstance().get(java.util.Calendar.ZONE_OFFSET)
|
||||
val jsOffsetMinutes = -(rawOffsetMs / 60000)
|
||||
|
||||
instance.memory().writeI32(timezonePtr, jsOffsetMinutes)
|
||||
instance.memory().writeI32(dstPtr, jsOffsetMinutes)
|
||||
instance.memory().writeCString(tznamePtr, java.util.TimeZone.getDefault().id ?: "UTC")
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
// t = _environ_get (i32, i32) -> (i32) — returns 0 (success)
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"t",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32), listOf(ValType.I32)),
|
||||
) { _, _ -> longArrayOf(0L) }
|
||||
|
||||
// u = _environ_sizes_get (i32, i32) -> (i32) — writes 0 to both pointers
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"u",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32), listOf(ValType.I32)),
|
||||
) { instance, args ->
|
||||
instance.memory().writeI32(args[0].toInt(), 0) // 0 env vars
|
||||
instance.memory().writeI32(args[1].toInt(), 0) // 0 buffer size
|
||||
longArrayOf(0L)
|
||||
}
|
||||
|
||||
// ═══ ERROR STUBS — throw on invocation ═══
|
||||
|
||||
// v = __abort_js () -> ()
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"v",
|
||||
FunctionType.of(emptyList(), emptyList()),
|
||||
) { _, _ ->
|
||||
Log.e(TAG, "Error stub 'v' (__abort_js) called — WASM execution error")
|
||||
throw RuntimeException(
|
||||
"Emscripten error stub 'v' (__abort_js) called — WASM execution error",
|
||||
)
|
||||
}
|
||||
|
||||
// ═══ HEAP MANAGEMENT ═══
|
||||
|
||||
// w = _emscripten_resize_heap (i32) -> (i32) — grow memory if needed
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"w",
|
||||
FunctionType.of(listOf(ValType.I32), listOf(ValType.I32)),
|
||||
) { instance, args ->
|
||||
val requestedSize = args[0].toInt()
|
||||
val currentPages = instance.memory().pages()
|
||||
val neededPages = (requestedSize + 65535) / 65536
|
||||
if (neededPages <= currentPages) {
|
||||
longArrayOf(1L) // already enough memory
|
||||
} else {
|
||||
val delta = neededPages - currentPages
|
||||
val result = instance.memory().grow(delta)
|
||||
longArrayOf(if (result >= 0) 1L else 0L) // 1 = success, 0 = failure
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ x = ___cxa_throw (i32, i32, i32) -> () — error stub ═══
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"x",
|
||||
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32), emptyList()),
|
||||
) { _, args ->
|
||||
Log.e(TAG, "Error stub 'x' (___cxa_throw) called — WASM execution error. Args: ${args.toList()}")
|
||||
throw RuntimeException(
|
||||
"Emscripten error stub 'x' (___cxa_throw) called — WASM execution error. Args: ${args.toList()}",
|
||||
)
|
||||
}
|
||||
|
||||
// ═══ EVENT LISTENER REGISTRATION ═══
|
||||
|
||||
// y = window_on (i32) -> () — reads event type string, records it
|
||||
functions += HostFunction(
|
||||
module,
|
||||
"y",
|
||||
FunctionType.of(listOf(ValType.I32), emptyList()),
|
||||
) { instance, args ->
|
||||
val eventTypePtr = args[0].toInt()
|
||||
val eventType = instance.memory().readCString(eventTypePtr)
|
||||
registeredEventTypes.add(eventType)
|
||||
null
|
||||
}
|
||||
|
||||
return functions
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChicoryGlue"
|
||||
const val ASM_CONST_TIMESTAMP = 17392
|
||||
const val ASM_CONST_SIGNATURE = 17442
|
||||
|
||||
/** Maximum signature string length to prevent infinite loops on corrupted memory. */
|
||||
private const val MAX_SIGNATURE_LENGTH = 32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
|
||||
import android.util.Log
|
||||
import com.dylibso.chicory.runtime.ByteBufferMemory
|
||||
import com.dylibso.chicory.runtime.ImportValues
|
||||
import com.dylibso.chicory.runtime.Instance
|
||||
import com.dylibso.chicory.wasm.ChicoryException
|
||||
import com.dylibso.chicory.wasm.Parser
|
||||
import com.dylibso.chicory.wasm.types.MemoryLimits
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Signature provider that uses the Chicory WASM runtime to execute
|
||||
* the hanime.tv emscripten-compiled WASM binary for signature generation.
|
||||
*
|
||||
* Replaces `WasmSignatureProvider` with the production-grade Chicory runtime,
|
||||
* eliminating the custom ~5600-line WASM interpreter.
|
||||
*/
|
||||
class ChicorySignatureProvider(
|
||||
private val wasmBinary: ByteArray,
|
||||
) : SignatureProvider {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChicorySigProvider"
|
||||
}
|
||||
|
||||
override val name: String = "ChicoryInterpreter"
|
||||
|
||||
private var instance: Instance? = null
|
||||
private var glue: ChicoryGlue? = null
|
||||
|
||||
@Volatile
|
||||
private var isInitialized = false
|
||||
|
||||
@Volatile
|
||||
private var isClosed = false
|
||||
|
||||
/** Guards concurrent initialization attempts in [getSignature]. */
|
||||
private val initMutex = Mutex()
|
||||
|
||||
/** Initialize the WASM runtime. Called internally by [getSignature]. */
|
||||
private fun initialize() {
|
||||
if (isInitialized) return
|
||||
|
||||
Log.d(TAG, "initialize() — starting WASM runtime initialization")
|
||||
try {
|
||||
// 1. Parse the WASM binary
|
||||
Log.d(TAG, "initialize() — parsing WASM binary, size=${wasmBinary.size} bytes")
|
||||
val module = Parser.parse(wasmBinary)
|
||||
Log.d(TAG, "initialize() — WASM module parsed successfully")
|
||||
|
||||
// 2. Create the glue layer with host function bindings
|
||||
glue = ChicoryGlue()
|
||||
val hostFunctions = glue!!.buildHostFunctions()
|
||||
Log.d(TAG, "initialize() — host functions built: count=${hostFunctions.size}")
|
||||
|
||||
// 3. Build import values
|
||||
val imports = ImportValues.builder()
|
||||
.addFunction(*hostFunctions.toTypedArray())
|
||||
.build()
|
||||
|
||||
// 4. Instantiate the WASM module
|
||||
// - ByteBufferMemory (Android-safe, pure Java NIO)
|
||||
// - Initialize globals, data segments, element segments
|
||||
// - Do NOT auto-call _start (we call exports manually)
|
||||
Log.d(TAG, "initialize() — building instance: memoryLimits=min=258,max=65536, init=true, start=false")
|
||||
instance = Instance.builder(module)
|
||||
.withImportValues(imports)
|
||||
.withInitialize(true)
|
||||
.withStart(false)
|
||||
.withMemoryLimits(MemoryLimits(258, 65536))
|
||||
.withMemoryFactory { ByteBufferMemory(it) }
|
||||
.build()
|
||||
Log.d(TAG, "initialize() — WASM instance created successfully")
|
||||
|
||||
// 5. Memory growth is handled by the WASM binary's _emscripten_resize_heap
|
||||
// import, which delegates to instance.memory().grow() as needed.
|
||||
|
||||
// 6. Call initRuntime() — export "A"
|
||||
try {
|
||||
Log.d(TAG, "initialize() — calling initRuntime (export \"A\")")
|
||||
instance!!.export("A").apply()
|
||||
Log.d(TAG, "initialize() — initRuntime (export \"A\") completed successfully")
|
||||
} catch (e: ChicoryException) {
|
||||
Log.e(TAG, "initialize() — WASM trap during initRuntime (export \"A\"): ${e.message}", e)
|
||||
throw SignatureException("WASM trap during initRuntime (export A): ${e.message}", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "initialize() — initRuntime (export \"A\") not available or failed (non-fatal): ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
|
||||
// 7. Call _main() — export "C"
|
||||
// This registers the "e" event listener via import "y" (window_on)
|
||||
try {
|
||||
Log.d(TAG, "initialize() — calling _main (export \"C\") with argc=0, argv=0")
|
||||
instance!!.export("C").apply(0L, 0L) // argc=0, argv=0
|
||||
Log.d(TAG, "initialize() — _main (export \"C\") completed successfully")
|
||||
} catch (e: ChicoryException) {
|
||||
Log.e(TAG, "initialize() — WASM trap during _main (export \"C\"): ${e.message}", e)
|
||||
throw SignatureException("WASM trap during _main (export C): ${e.message}", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "initialize() — _main (export \"C\") not available or failed (non-fatal): ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
|
||||
isInitialized = true
|
||||
Log.d(TAG, "initialize() — initialization complete, isInitialized=true")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "initialize() — FAILED: ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
close()
|
||||
throw SignatureException("Failed to initialize Chicory WASM runtime: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to re-initialize the WASM runtime after a failure.
|
||||
* Tears down the current instance and rebuilds from scratch.
|
||||
*/
|
||||
private suspend fun reinitialize() {
|
||||
Log.d(TAG, "reinitialize() — called, will tear down and rebuild WASM instance")
|
||||
withContext(Dispatchers.Default) {
|
||||
initMutex.withLock {
|
||||
// Tear down existing instance
|
||||
Log.d(TAG, "reinitialize() — tearing down existing instance: isInitialized=$isInitialized, instance=${instance != null}, glue=${glue != null}")
|
||||
isInitialized = false
|
||||
instance = null
|
||||
glue?.fullReset()
|
||||
Log.d(TAG, "reinitialize() — glue fullReset done, isClosed=$isClosed")
|
||||
glue = null
|
||||
if (!isClosed) {
|
||||
Log.d(TAG, "reinitialize() — attempting fresh initialization")
|
||||
initialize()
|
||||
} else {
|
||||
Log.w(TAG, "reinitialize() — provider is closed, skipping re-initialization")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSignature(): Signature {
|
||||
Log.d(TAG, "getSignature() — entry, isClosed=$isClosed, isInitialized=$isInitialized")
|
||||
if (isClosed) {
|
||||
Log.e(TAG, "getSignature() — provider is closed, cannot generate signature")
|
||||
throw SignatureException("Cannot generate signature — provider has been closed")
|
||||
}
|
||||
initMutex.withLock {
|
||||
if (!isInitialized) {
|
||||
Log.d(TAG, "getSignature() — initialization needed, calling initialize()")
|
||||
withContext(Dispatchers.Default) { initialize() }
|
||||
} else {
|
||||
Log.d(TAG, "getSignature() — already initialized, proceeding")
|
||||
}
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.Default) {
|
||||
val currentInstance = instance ?: throw SignatureException("WASM instance unavailable — provider may have been closed")
|
||||
val currentGlue = glue ?: throw SignatureException("WASM glue unavailable — provider may have been closed")
|
||||
Log.d(TAG, "getSignature() — generating signature")
|
||||
|
||||
try {
|
||||
val sig = generateSignature(currentInstance, currentGlue)
|
||||
Log.d(TAG, "getSignature() — signature generated (length=${sig.signature.length}), time=${sig.time}")
|
||||
sig
|
||||
} catch (e: SignatureException) {
|
||||
Log.w(TAG, "getSignature() — SignatureException: ${e.message}, will attempt re-initialization")
|
||||
// If the WASM instance may be in a bad state, try re-initializing once
|
||||
if (isClosed) throw e
|
||||
try {
|
||||
Log.d(TAG, "getSignature() — attempting re-initialization after failure")
|
||||
reinitialize()
|
||||
val newInstance = instance
|
||||
?: throw SignatureException("WASM instance unavailable after re-initialization")
|
||||
val newGlue = glue
|
||||
?: throw SignatureException("WASM glue unavailable after re-initialization")
|
||||
val retrySig = generateSignature(newInstance, newGlue)
|
||||
Log.d(TAG, "getSignature() — retry signature generated (length=${retrySig.signature.length}), time=${retrySig.time}")
|
||||
retrySig
|
||||
} catch (retryEx: SignatureException) {
|
||||
Log.e(TAG, "getSignature() — retry FAILED after re-initialization: ${retryEx.message}", retryEx)
|
||||
throw SignatureException(
|
||||
"Signature generation failed after re-initialization: ${retryEx.message}",
|
||||
retryEx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a signature using the given WASM instance and glue layer.
|
||||
* This is the core computation extracted for retry support.
|
||||
*/
|
||||
private fun generateSignature(currentInstance: Instance, currentGlue: ChicoryGlue): Signature {
|
||||
Log.d(TAG, "generateSignature() — entry")
|
||||
try {
|
||||
currentGlue.reset()
|
||||
Log.d(TAG, "generateSignature() — glue reset complete")
|
||||
val memory = currentInstance.memory()
|
||||
|
||||
// Allocate strings in WASM memory using the binary's own malloc (export "E")
|
||||
var eventTypePtr: Int
|
||||
var eventJsonPtr: Int
|
||||
|
||||
try {
|
||||
val malloc = currentInstance.export("E")
|
||||
eventTypePtr = malloc.apply(2L)[0].toInt()
|
||||
Log.d(TAG, "generateSignature() — malloc for event type returned ptr=$eventTypePtr")
|
||||
if (eventTypePtr == 0) {
|
||||
throw SignatureException("WASM malloc returned null pointer for event type string")
|
||||
}
|
||||
eventJsonPtr = malloc.apply(3L)[0].toInt()
|
||||
Log.d(TAG, "generateSignature() — malloc for event JSON returned ptr=$eventJsonPtr")
|
||||
if (eventJsonPtr == 0) {
|
||||
throw SignatureException("WASM malloc returned null pointer for event JSON string")
|
||||
}
|
||||
} catch (e: SignatureException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw SignatureException("WASM module does not export required malloc function (export E): ${e.message}", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// Write the event type and JSON into WASM memory
|
||||
Log.d(TAG, "generateSignature() — writing to WASM memory: eventType=\"e\" at ptr=$eventTypePtr, eventJson=\"{}\" at ptr=$eventJsonPtr")
|
||||
memory.writeCString(eventTypePtr, "e")
|
||||
memory.writeCString(eventJsonPtr, "{}")
|
||||
|
||||
// Call _on_window_event(eventTypePtr, eventJsonPtr) — export "B"
|
||||
Log.d(TAG, "generateSignature() — calling _on_window_event (export \"B\") with eventTypePtr=$eventTypePtr, eventJsonPtr=$eventJsonPtr")
|
||||
currentInstance.export("B").apply(
|
||||
eventTypePtr.toLong(),
|
||||
eventJsonPtr.toLong(),
|
||||
)
|
||||
Log.d(TAG, "generateSignature() — _on_window_event (export \"B\") returned successfully")
|
||||
} finally {
|
||||
// Free allocated memory if we used malloc
|
||||
try {
|
||||
val free = currentInstance.export("F")
|
||||
free.apply(eventTypePtr.toLong())
|
||||
free.apply(eventJsonPtr.toLong())
|
||||
Log.d(TAG, "generateSignature() — freed malloc memory: eventTypePtr=$eventTypePtr, eventJsonPtr=$eventJsonPtr")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "generateSignature() — free failed (non-fatal): ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Read captured signature and timestamp from the glue layer
|
||||
val signature = currentGlue.capturedSignature
|
||||
?: throw SignatureException("WASM execution did not produce a signature")
|
||||
val timestamp = currentGlue.capturedTimestamp
|
||||
?: throw SignatureException("WASM execution did not produce a timestamp")
|
||||
Log.d(TAG, "generateSignature() — captured signature (length=${signature.length}), timestamp=$timestamp")
|
||||
|
||||
// Validate the signature before returning — a corrupted or stale
|
||||
// signature would cause 401 errors on the manifest endpoint.
|
||||
val result = Signature(signature, timestamp.toString()).also { it.validate() }
|
||||
Log.d(TAG, "generateSignature() — signature validated OK, returning Signature(length=${result.signature.length}, time=${result.time})")
|
||||
return result
|
||||
} catch (e: SignatureException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw SignatureException("WASM signature generation failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
Log.d(TAG, "close() — called, isClosed=$isClosed, isInitialized=$isInitialized")
|
||||
// Mark closed first to prevent new getSignature() calls from proceeding.
|
||||
// This must happen-before the field nulling below.
|
||||
isClosed = true
|
||||
isInitialized = false
|
||||
|
||||
// Null out heavy resources without acquiring the mutex.
|
||||
// If a getSignature() call is in progress, it holds its own references
|
||||
// on the stack and will complete (or fail on next attempt).
|
||||
instance = null
|
||||
Log.d(TAG, "close() — calling glue.fullReset()")
|
||||
glue?.fullReset()
|
||||
glue = null
|
||||
Log.d(TAG, "close() — complete")
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,12 @@ package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
data class SearchParameters(
|
||||
val includedTags: ArrayList<String>,
|
||||
val blackListedTags: ArrayList<String>,
|
||||
val brands: ArrayList<String>,
|
||||
val tagsMode: String,
|
||||
val orderBy: String,
|
||||
val ordering: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HAnimeResponse(
|
||||
val page: Long,
|
||||
val nbPages: Long,
|
||||
val nbHits: Long,
|
||||
val hitsPerPage: Long,
|
||||
val hits: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HitsModel(
|
||||
val id: Long? = null,
|
||||
val name: String,
|
||||
val titles: List<String> = emptyList(),
|
||||
val name: String = "",
|
||||
@SerialName("search_titles")
|
||||
val searchTitles: String? = null,
|
||||
val slug: String? = null,
|
||||
val description: String? = null,
|
||||
val views: Long? = null,
|
||||
@@ -49,9 +32,14 @@ data class HitsModel(
|
||||
val monthlyRank: Long? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
@SerialName("created_at")
|
||||
val createdAt: Long? = null,
|
||||
val createdAt: String? = null,
|
||||
@SerialName("released_at")
|
||||
val releasedAt: Long? = null,
|
||||
val releasedAt: String? = null,
|
||||
@SerialName("created_at_unix")
|
||||
val createdAtUnix: Long? = null,
|
||||
@SerialName("released_at_unix")
|
||||
val releasedAtUnix: Long? = null,
|
||||
val score: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -100,7 +88,7 @@ data class HentaiVideo(
|
||||
@SerialName("monthly_rank")
|
||||
val monthlyRank: Long? = null,
|
||||
@SerialName("brand_id")
|
||||
val brandId: String? = null,
|
||||
val brandId: Long? = null,
|
||||
@SerialName("is_banned_in")
|
||||
val isBannedIn: String? = null,
|
||||
@SerialName("created_at_unix")
|
||||
@@ -152,7 +140,7 @@ data class HentaiFranchiseHentaiVideo(
|
||||
@SerialName("monthly_rank")
|
||||
val monthlyRank: Long? = null,
|
||||
@SerialName("brand_id")
|
||||
val brandId: String? = null,
|
||||
val brandId: Long? = null,
|
||||
@SerialName("is_banned_in")
|
||||
val isBannedIn: String? = null,
|
||||
@SerialName("created_at_unix")
|
||||
@@ -194,7 +182,7 @@ data class Stream(
|
||||
@SerialName("mime_type")
|
||||
val mimeType: String? = null,
|
||||
val width: Long? = null,
|
||||
val height: String,
|
||||
val height: Int? = null,
|
||||
@SerialName("duration_in_ms")
|
||||
val durationInMs: Long? = null,
|
||||
@SerialName("filesize_mbs")
|
||||
@@ -232,7 +220,9 @@ data class WindowNuxt(
|
||||
) {
|
||||
@Serializable
|
||||
data class DataVideo(
|
||||
val videos_manifest: VideosManifest,
|
||||
@SerialName("hentai_video")
|
||||
val hentaiVideo: HentaiVideo? = null,
|
||||
val videosManifest: VideosManifest,
|
||||
) {
|
||||
@Serializable
|
||||
data class VideosManifest(
|
||||
@@ -240,11 +230,12 @@ data class WindowNuxt(
|
||||
) {
|
||||
@Serializable
|
||||
data class Server(
|
||||
val name: String? = null,
|
||||
val streams: List<Stream>,
|
||||
) {
|
||||
@Serializable
|
||||
data class Stream(
|
||||
val height: String,
|
||||
val height: Int? = null,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
@@ -253,3 +244,53 @@ data class WindowNuxt(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the manifest endpoint: GET /api/v8/guest/videos/{hv_id}/manifest
|
||||
* The CDN wraps the manifest in a `videos_manifest` key.
|
||||
*/
|
||||
@Serializable
|
||||
data class ManifestWrapper(
|
||||
@SerialName("videos_manifest")
|
||||
val videosManifest: ManifestResponse = ManifestResponse(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ManifestResponse(
|
||||
val servers: List<ManifestServer> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ManifestServer(
|
||||
val id: Long? = null,
|
||||
val name: String? = null,
|
||||
val slug: String? = null,
|
||||
@SerialName("is_permanent")
|
||||
val isPermanent: Boolean? = false,
|
||||
val streams: List<ManifestStream> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ManifestStream(
|
||||
val id: Long? = null,
|
||||
val kind: String? = null,
|
||||
val height: Int? = null,
|
||||
val url: String = "",
|
||||
@SerialName("is_guest_allowed")
|
||||
val isGuestAllowed: Boolean? = false,
|
||||
@SerialName("is_member_allowed")
|
||||
val isMemberAllowed: Boolean? = false,
|
||||
@SerialName("is_premium_allowed")
|
||||
val isPremiumAllowed: Boolean? = false,
|
||||
@SerialName("duration_in_ms")
|
||||
val durationInMs: Long? = null,
|
||||
@SerialName("filesize_mbs")
|
||||
val filesizeMbs: Long? = null,
|
||||
val filename: String? = null,
|
||||
val extension: String? = null,
|
||||
@SerialName("mime_type")
|
||||
val mimeType: String? = null,
|
||||
val width: Long? = null,
|
||||
@SerialName("hv_id")
|
||||
val hvId: Long? = null,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WASM Binary Extractor — Fetches and decodes the inline WASM from vendor.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// hanime.tv embeds its signature WASM as a base64-encoded string inside the
|
||||
// vendor.js bundle. This object discovers the vendor.js URL from the homepage,
|
||||
// fetches it, scans for base64 blobs, and returns the one that decodes to a
|
||||
// valid WASM binary (identified by the magic number \\0asm).
|
||||
//
|
||||
// Design: "Parse, Don't Validate" — each base64 candidate is decoded at the
|
||||
// boundary and checked against the WASM magic number. Only a confirmed match
|
||||
// is returned; everything else is discarded. Callers receive a trusted byte
|
||||
// array that is guaranteed to be a valid WASM module header.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extracts the WASM binary from hanime.tv's vendor.js JavaScript bundle.
|
||||
*
|
||||
* The WASM binary is NOT fetched from a URL — it is base64-encoded inline
|
||||
* in the vendor.js file and decoded at runtime by the site's JavaScript
|
||||
* via `WebAssembly.compile(Uint8Array.from(atob("…")))` or similar patterns.
|
||||
*
|
||||
* This object replicates that extraction in Kotlin: it fetches the homepage
|
||||
* to discover the current vendor.js URL, downloads the JS bundle, scans for
|
||||
* base64 strings, and returns the one whose decoded bytes start with the
|
||||
* WASM magic number (`\0asm`).
|
||||
*/
|
||||
object HanimeWasmBinary {
|
||||
|
||||
/** The WASM magic number: `\0asm` (0x00 0x61 0x73 0x6D). */
|
||||
private val WASM_MAGIC = byteArrayOf(0x00, 0x61, 0x73, 0x6D)
|
||||
|
||||
/** hanime.tv homepage — used to discover the vendor.js script URL. */
|
||||
private const val HANIME_HOME = "https://hanime.tv"
|
||||
|
||||
/** Maximum number of retry attempts for fetching the WASM binary. */
|
||||
private const val MAX_FETCH_RETRIES = 2
|
||||
|
||||
/** Log tag for debug output. */
|
||||
private const val TAG = "HanimeWasmBinary"
|
||||
|
||||
/**
|
||||
* Fetch and extract the WASM binary from hanime.tv's vendor.js bundle.
|
||||
*
|
||||
* The process is:
|
||||
* 1. Fetch the hanime.tv homepage HTML.
|
||||
* 2. Extract the vendor.js URL from `<script>` tags.
|
||||
* 3. Fetch the vendor.js content.
|
||||
* 4. Scan for base64 strings and return the one that decodes to a WASM binary.
|
||||
*
|
||||
* Retries up to [MAX_FETCH_RETRIES] times on failure, with an increasing
|
||||
* delay between attempts to allow transient network issues to resolve.
|
||||
*
|
||||
* @param client OkHttp client to use for HTTP requests.
|
||||
* @return The raw WASM binary bytes.
|
||||
* @throws WasmExtractionException if the binary cannot be extracted after all retries.
|
||||
*/
|
||||
suspend fun fetchWasmBinary(client: OkHttpClient): ByteArray {
|
||||
var lastException: Exception? = null
|
||||
repeat(MAX_FETCH_RETRIES) { attempt ->
|
||||
val attemptNum = attempt + 1
|
||||
Log.d(TAG, "fetchWasmBinary: attempt $attemptNum of $MAX_FETCH_RETRIES")
|
||||
try {
|
||||
return withContext(Dispatchers.IO) {
|
||||
Log.d(TAG, "fetchWasmBinary: fetching homepage $HANIME_HOME")
|
||||
val html = fetchPage(client, HANIME_HOME)
|
||||
val vendorJsUrl = extractVendorJsUrl(html)
|
||||
?: throw WasmExtractionException("Could not find vendor.js URL in hanime.tv HTML")
|
||||
Log.d(TAG, "fetchWasmBinary: resolved vendor.js URL: $vendorJsUrl")
|
||||
val vendorJs = fetchPage(client, vendorJsUrl)
|
||||
extractWasmFromVendorJs(vendorJs)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
Log.e(TAG, "fetchWasmBinary: attempt $attemptNum failed — ${e.javaClass.simpleName}: ${e.message}")
|
||||
if (attempt < MAX_FETCH_RETRIES - 1) {
|
||||
val delayMs = 1000L * (attempt + 1)
|
||||
Log.d(TAG, "fetchWasmBinary: retrying after ${delayMs}ms delay")
|
||||
// Brief delay before retry to allow transient failures to resolve
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(TAG, "fetchWasmBinary: all $MAX_FETCH_RETRIES attempts exhausted, lastException: ${lastException?.message}")
|
||||
throw WasmExtractionException("Failed to fetch WASM binary after $MAX_FETCH_RETRIES attempts: ${lastException?.message}", lastException)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the WASM binary from a vendor.js string.
|
||||
*
|
||||
* Scans for base64-encoded strings (minimum 100 characters to filter noise),
|
||||
* decodes each candidate, and checks whether the decoded bytes start with the
|
||||
* WASM magic number (`\0asm`). Returns the first match.
|
||||
*
|
||||
* Recognised JavaScript patterns:
|
||||
* - `WebAssembly.compile(Uint8Array.from(atob("AGFzbQE…")))`
|
||||
* - `new Uint8Array(Base64.decode("AGFzbQE…"))`
|
||||
* - Any large quoted base64 string that decodes to valid WASM.
|
||||
*
|
||||
* @param vendorJs The full text content of the vendor.js bundle.
|
||||
* @return The decoded WASM binary bytes.
|
||||
* @throws WasmExtractionException if no WASM binary is found.
|
||||
*/
|
||||
fun extractWasmFromVendorJs(vendorJs: String): ByteArray {
|
||||
Log.d(TAG, "extractWasmFromVendorJs: vendor.js content length = ${vendorJs.length} chars")
|
||||
|
||||
val base64Pattern = Regex("""["']([A-Za-z0-9+/=]{100,})["']""")
|
||||
|
||||
val matches = base64Pattern.findAll(vendorJs).toList()
|
||||
Log.d(TAG, "extractWasmFromVendorJs: found ${matches.size} base64 candidate(s) in vendor.js")
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
throw WasmExtractionException("No base64 strings found in vendor.js")
|
||||
}
|
||||
|
||||
for ((index, match) in matches.withIndex()) {
|
||||
val base64Str = match.groupValues[1]
|
||||
Log.d(TAG, "extractWasmFromVendorJs: candidate #${index + 1} length = ${base64Str.length} chars")
|
||||
|
||||
val decoded = try {
|
||||
decodeBase64(base64Str)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "extractWasmFromVendorJs: candidate #${index + 1} decode failed — ${e.javaClass.simpleName}: ${e.message}")
|
||||
// Not valid base64 — skip
|
||||
continue
|
||||
}
|
||||
|
||||
val hasWasmMagic = decoded.size >= WASM_MAGIC.size &&
|
||||
decoded.sliceArray(0 until WASM_MAGIC.size).contentEquals(WASM_MAGIC)
|
||||
Log.d(TAG, "extractWasmFromVendorJs: candidate #${index + 1} decoded to ${decoded.size} bytes, WASM magic match = $hasWasmMagic")
|
||||
|
||||
if (hasWasmMagic) {
|
||||
Log.d(TAG, "extractWasmFromVendorJs: valid WASM binary found — ${decoded.size} bytes")
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "extractWasmFromVendorJs: all ${matches.size} base64 candidates exhausted, none matched WASM magic")
|
||||
throw WasmExtractionException("Could not find WASM binary in vendor.js")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the vendor.js URL from the hanime.tv HTML.
|
||||
*
|
||||
* Looks for `<script src="…vendor….js">` tags and returns the matching URL.
|
||||
* Relative paths are resolved against `https://hanime.tv`.
|
||||
*
|
||||
* @param html The hanime.tv homepage HTML.
|
||||
* @return The fully-qualified vendor.js URL, or `null` if not found.
|
||||
*/
|
||||
fun extractVendorJsUrl(html: String): String? {
|
||||
Log.d(TAG, "extractVendorJsUrl: HTML content length = ${html.length} chars")
|
||||
|
||||
// Primary pattern: look for vendor.js in script src attributes
|
||||
val primaryPattern = Regex("""src=["']([^"']*vendor[^"']*\.js)["']""")
|
||||
val primaryMatch = primaryPattern.find(html)
|
||||
if (primaryMatch != null) {
|
||||
val path = primaryMatch.groupValues[1]
|
||||
val resolved = resolveUrl(path)
|
||||
Log.d(TAG, "extractVendorJsUrl: primary pattern matched path: $path, resolved: $resolved")
|
||||
return resolved
|
||||
}
|
||||
Log.d(TAG, "extractVendorJsUrl: primary vendor.js pattern found no match, trying fallback")
|
||||
|
||||
// Fallback pattern: any JS bundle that might contain the WASM binary
|
||||
// Sites sometimes rename bundles — look for large app/build bundles
|
||||
val fallbackPattern = Regex("""src=["']([^"']*\d{8,}[^"']*\.js)["']""")
|
||||
val fallbackMatches = fallbackPattern.findAll(html).toList()
|
||||
Log.d(TAG, "extractVendorJsUrl: fallback pattern found ${fallbackMatches.size} match(es)")
|
||||
for (match in fallbackMatches) {
|
||||
val path = match.groupValues[1]
|
||||
val resolved = resolveUrl(path) ?: continue
|
||||
Log.d(TAG, "extractVendorJsUrl: trying fallback URL: $resolved")
|
||||
return resolved
|
||||
}
|
||||
|
||||
Log.w(TAG, "extractVendorJsUrl: no vendor.js URL found (both primary and fallback patterns exhausted)")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun resolveUrl(path: String): String? = if (path.startsWith("http")) {
|
||||
path
|
||||
} else {
|
||||
try {
|
||||
HANIME_HOME.toHttpUrl().resolve(path)?.toString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page's text content via HTTP GET.
|
||||
*
|
||||
* @param client OkHttp client to use.
|
||||
* @param url The URL to fetch.
|
||||
* @return The response body as a string.
|
||||
* @throws WasmExtractionException on HTTP failure or empty body.
|
||||
*/
|
||||
private fun fetchPage(client: OkHttpClient, url: String): String {
|
||||
Log.d(TAG, "fetchPage: requesting URL: $url")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36")
|
||||
.header("Accept", "text/html,application/javascript,*/*")
|
||||
.build()
|
||||
|
||||
val timeoutClient = client.newBuilder()
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val response = timeoutClient.newCall(request).execute()
|
||||
|
||||
return response.use {
|
||||
Log.d(TAG, "fetchPage: HTTP ${it.code} for $url")
|
||||
if (!it.isSuccessful) {
|
||||
Log.e(TAG, "fetchPage: HTTP ${it.code} fetching $url — request failed")
|
||||
throw WasmExtractionException("HTTP ${it.code} fetching $url")
|
||||
}
|
||||
val body = it.body.string()
|
||||
if (body.isBlank()) throw WasmExtractionException("Empty vendor.js body for $url")
|
||||
Log.d(TAG, "fetchPage: response body length = ${body.length} chars for $url")
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the WASM binary cannot be extracted from the vendor.js bundle.
|
||||
*/
|
||||
class WasmExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Signature provider that computes the hanime.tv signature natively via
|
||||
* direct SHA-256 hashing, replacing WASM execution and WebView extraction.
|
||||
*
|
||||
* ## Algorithm
|
||||
*
|
||||
* The hanime.tv API signature is computed as:
|
||||
* ```
|
||||
* SHA256("${timestamp},Xkdi29,https://hanime.tv,mn2,${timestamp}")
|
||||
* ```
|
||||
*
|
||||
* Where:
|
||||
* - `timestamp` = `System.currentTimeMillis() / 1000L` (Unix seconds)
|
||||
* - `Xkdi29` = static salt embedded in the site's JavaScript
|
||||
* - `https://hanime.tv` = the origin/origin parameter
|
||||
* - `mn2` = static salt for the signature version
|
||||
*
|
||||
* The resulting 32-byte hash is formatted as a 64-character lowercase
|
||||
* hexadecimal string and sent as the `x-signature` header alongside
|
||||
* the timestamp in the `x-time` header.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* The original WASM binary (emscripten-compiled) computes this same hash,
|
||||
* but the Chicory WASM runtime stubs several JS environment functions
|
||||
* (`crypto.getRandomValues`, `performance.now`, etc.), causing the
|
||||
* WASM code to produce incorrect signatures. This provider bypasses
|
||||
* WASM entirely by computing the SHA-256 directly in the JVM.
|
||||
*
|
||||
* ## Thread safety
|
||||
*
|
||||
* This provider is thread-safe. [MessageDigest] is created fresh per
|
||||
* [getSignature] call, so no mutable shared state exists.
|
||||
*/
|
||||
open class NativeSignatureProvider : SignatureProvider {
|
||||
|
||||
companion object {
|
||||
/** First salt embedded in the hanime.tv signature algorithm. */
|
||||
private const val SALT_1 = "Xkdi29"
|
||||
|
||||
/** The origin value used in the signature input. */
|
||||
private const val ORIGIN = "https://hanime.tv"
|
||||
|
||||
/** Second salt embedded in the hanime.tv signature algorithm. */
|
||||
private const val SALT_2 = "mn2"
|
||||
}
|
||||
|
||||
override val name: String = "native"
|
||||
|
||||
/**
|
||||
* Timestamp source — returns current Unix time in seconds.
|
||||
* Overridable in tests to pin a fixed timestamp for known-answer verification.
|
||||
*/
|
||||
protected open val timestampProvider: () -> Long = { System.currentTimeMillis() / 1000L }
|
||||
|
||||
/**
|
||||
* Compute a fresh signature by hashing the current timestamp.
|
||||
*
|
||||
* The input format is: `{timestamp},{SALT_1},{ORIGIN},{SALT_2},{timestamp}`
|
||||
* The hash is SHA-256, formatted as 64 lowercase hex characters.
|
||||
*/
|
||||
override suspend fun getSignature(): Signature {
|
||||
val timestamp = timestampProvider()
|
||||
val input = "$timestamp,$SALT_1,$ORIGIN,$SALT_2,$timestamp"
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
|
||||
val hex = hashBytes.joinToString("") { "%02x".format(it) }
|
||||
return Signature(signature = hex, time = timestamp.toString())
|
||||
}
|
||||
|
||||
/** No resources to release — this is a no-op. */
|
||||
override fun close() {}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
|
||||
import android.util.Log
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Signature data model
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Represents a single hanime.tv API signature bundle.
|
||||
*
|
||||
* @property signature 64-character hex string sent as the `x-signature` header.
|
||||
* @property time Unix timestamp string sent as the `x-time` header.
|
||||
* @property createdAt Epoch-millis timestamp of when this signature was obtained,
|
||||
* used to determine expiry.
|
||||
*/
|
||||
data class Signature(
|
||||
val signature: String,
|
||||
val time: String,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
/**
|
||||
* Returns `true` when this signature is older than [ttlMs] milliseconds.
|
||||
*/
|
||||
fun isExpired(ttlMs: Long): Boolean = System.currentTimeMillis() - createdAt > ttlMs
|
||||
|
||||
/**
|
||||
* Validates that this signature has the expected format:
|
||||
* - `signature` is exactly 64 lowercase hexadecimal characters
|
||||
* - `time` is a valid Unix timestamp within a reasonable window
|
||||
*
|
||||
* @param maxAgeMs Maximum age in milliseconds for the timestamp to be considered valid.
|
||||
* @throws SignatureException if validation fails.
|
||||
*/
|
||||
fun validate(maxAgeMs: Long = 5 * 60 * 1000L) {
|
||||
if (!signature.matches(SIGNATURE_PATTERN)) {
|
||||
Log.e("SignatureProvider", "validate FAILED: signature format invalid (length=${signature.length})")
|
||||
throw SignatureException("Invalid signature format: expected 64 lowercase hex chars, got length=${signature.length}")
|
||||
}
|
||||
|
||||
val timeValue = time.toLongOrNull()
|
||||
if (timeValue == null) {
|
||||
Log.e("SignatureProvider", "validate FAILED: timestamp '$time' is not a valid number")
|
||||
throw SignatureException("Invalid timestamp: '$time' is not a valid number")
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis() / 1000L
|
||||
val ageMs = (now - timeValue) * 1000L
|
||||
|
||||
if (ageMs > maxAgeMs || ageMs < -CLOCK_SKEW_TOLERANCE_MS) {
|
||||
Log.e("SignatureProvider", "validate FAILED: timestamp too far from current time (ageMs=$ageMs, maxAgeMs=$maxAgeMs)")
|
||||
throw SignatureException("Timestamp $timeValue is too far from current time (age=${ageMs}ms, maxAge=${maxAgeMs}ms)")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Regex pattern for validating signature format: exactly 64 lowercase hex characters. */
|
||||
private val SIGNATURE_PATTERN = Regex("^[0-9a-f]{64}$")
|
||||
|
||||
/** Tolerance for clock skew between client and server (60 seconds). */
|
||||
private const val CLOCK_SKEW_TOLERANCE_MS = 60_000L
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Signature provider interface
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Supplies fresh [Signature] instances for authenticating hanime.tv API requests.
|
||||
*
|
||||
* Implementations may involve heavy work (e.g. WebView load, WASM execution),
|
||||
* so callers should avoid calling on the main thread.
|
||||
*/
|
||||
interface SignatureProvider {
|
||||
|
||||
/**
|
||||
* Obtain a fresh signature. May involve heavy work such as loading a
|
||||
* WebView or executing WASM — avoid calling on the main thread.
|
||||
*/
|
||||
suspend fun getSignature(): Signature
|
||||
|
||||
/** Human-readable label for this provider (useful in logs / preferences). */
|
||||
val name: String
|
||||
|
||||
/** Release any held resources (WebView, memory, etc.). Default is a no-op. */
|
||||
fun close() {}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Signature headers helper
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds the set of HTTP headers that hanime.tv requires alongside every
|
||||
* authenticated API request.
|
||||
*/
|
||||
object SignatureHeaders {
|
||||
|
||||
/**
|
||||
* Construct the full header map from a [Signature].
|
||||
*
|
||||
* Includes the two dynamic signature headers (`x-signature`, `x-time`)
|
||||
* plus the static headers that the server expects.
|
||||
*/
|
||||
fun build(signature: Signature): Map<String, String> {
|
||||
Log.d("SignatureProvider", "Building signature headers (length=${signature.signature.length})")
|
||||
return mapOf(
|
||||
"x-signature" to signature.signature,
|
||||
"x-time" to signature.time,
|
||||
"x-signature-version" to "web2",
|
||||
"x-session-token" to "",
|
||||
"x-user-license" to "",
|
||||
"x-csrf-token" to "",
|
||||
"x-license" to "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Domain-specific exception
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Thrown when signature extraction fails or times out.
|
||||
*
|
||||
* @param message Human-readable description of the failure.
|
||||
* @param cause The underlying exception, if any.
|
||||
*/
|
||||
class SignatureException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
@@ -0,0 +1,418 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.hanime
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.net.http.SslError
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebView-based signature extraction for hanime.tv
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loads hanime.tv in a headless WebView, waits for the WASM signature
|
||||
// generator to initialise, and extracts `window.ssignature` and
|
||||
// `window.stime` via [WebView.evaluateJavascript]. If the WASM module
|
||||
// has not yet produced a signature, the 'e' DOM event is dispatched to
|
||||
// trigger the generation pipeline, and extraction is retried until a
|
||||
// timeout is reached.
|
||||
//
|
||||
// Thread safety:
|
||||
// - [signatureMutex] serializes [getSignature] calls so only one WebView
|
||||
// is active at a time, preventing the "Already resumed" crash that
|
||||
// occurred when two concurrent calls each created a WebView whose poll
|
||||
// loop tried to resume the same continuation.
|
||||
// - [resumed] AtomicBoolean guards all resume paths (poll success, poll
|
||||
// timeout, SSL error, page error) so only the FIRST path to invoke
|
||||
// [resumeOrDestroy] wins — even if two paths race.
|
||||
// - [cachedSignature] avoids redundant WebView loads when the signature
|
||||
// is still within its TTL.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extracts hanime.tv request signatures by loading the site in a WebView
|
||||
* and reading the `window.ssignature` / `window.stime` values that the
|
||||
* client-side WASM binary produces.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create a [WebView] on the main thread via [Handler].
|
||||
* 2. Navigate to `https://hanime.tv`.
|
||||
* 3. On [WebViewClient.onPageFinished], wait for WASM initialisation.
|
||||
* 4. Poll `window.ssignature` / `window.stime` via [WebView.evaluateJavascript].
|
||||
* 5. If not yet available, dispatch the `'e'` event to trigger WASM
|
||||
* signature generation.
|
||||
* 6. Deliver the [Signature] through the [JavascriptInterface] callback.
|
||||
* 7. Destroy the WebView on the main thread.
|
||||
*/
|
||||
class WebViewSignatureProvider : SignatureProvider {
|
||||
|
||||
override val name: String = "WebView"
|
||||
|
||||
private val context: Application by injectLazy()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** Serializes [getSignature] calls so only one WebView is active at a time. */
|
||||
private val signatureMutex = Mutex()
|
||||
|
||||
/** Active WebView reference for [close] cleanup. */
|
||||
@Volatile
|
||||
private var activeWebView: WebView? = null
|
||||
|
||||
/** Cached signature to avoid redundant WebView loads within the TTL. */
|
||||
@Volatile
|
||||
private var cachedSignature: Signature? = null
|
||||
|
||||
override suspend fun getSignature(): Signature = withTimeoutOrNull(TOTAL_TIMEOUT_MS) {
|
||||
signatureMutex.withLock {
|
||||
// Fast path: return cached signature if still valid
|
||||
cachedSignature?.let { cached ->
|
||||
if (!cached.isExpired(SIGNATURE_CACHE_TTL_MS)) {
|
||||
Log.d(TAG, "getSignature: returning cached signature (age=${System.currentTimeMillis() - cached.createdAt}ms)")
|
||||
return@withLock cached
|
||||
}
|
||||
Log.d(TAG, "getSignature: cached signature expired, generating new one")
|
||||
}
|
||||
|
||||
Log.d(TAG, "getSignature: entry -- timeout = ${TOTAL_TIMEOUT_MS}ms")
|
||||
suspendCancellableCoroutine<Signature> { continuation ->
|
||||
var webView: WebView? = null
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
Log.d(TAG, "getSignature: creating WebView on main thread")
|
||||
val wv = createWebView()
|
||||
webView = wv
|
||||
activeWebView = wv
|
||||
configureWebView(wv, continuation)
|
||||
Log.d(TAG, "getSignature: loading URL https://hanime.tv")
|
||||
wv.loadUrl("https://hanime.tv")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getSignature: failed to create WebView -- ${e.javaClass.simpleName}: ${e.message}")
|
||||
continuation.resumeWithException(
|
||||
SignatureException("Failed to create WebView: ${e.message}", e),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
handler.post {
|
||||
webView?.destroy()
|
||||
if (activeWebView === webView) activeWebView = null
|
||||
}
|
||||
}
|
||||
}.also { signature ->
|
||||
cachedSignature = signature
|
||||
Log.d(TAG, "getSignature: signature cached (length=${signature.signature.length})")
|
||||
}
|
||||
}
|
||||
} ?: throw SignatureException("WebView signature extraction timed out after ${TOTAL_TIMEOUT_MS}ms")
|
||||
|
||||
override fun close() {
|
||||
cachedSignature = null
|
||||
val wv = activeWebView
|
||||
Log.d(TAG, "close: called -- activeWebView present = ${wv != null}")
|
||||
if (wv != null) {
|
||||
handler.post {
|
||||
wv.destroy()
|
||||
if (activeWebView === wv) activeWebView = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new [WebView] with JavaScript and DOM storage enabled.
|
||||
*
|
||||
* The caller is responsible for setting a [WebViewClient] and loading
|
||||
* a URL -- this method only configures the [WebSettings].
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun createWebView(): WebView {
|
||||
val wv = WebView(context)
|
||||
Log.d(TAG, "createWebView: WebView instance created")
|
||||
|
||||
with(wv.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
userAgentString = USER_AGENT
|
||||
}
|
||||
Log.d(TAG, "createWebView: JS enabled, UA = ${USER_AGENT.take(30)}...")
|
||||
|
||||
return wv
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a [WebViewClient] and [JavascriptInterface] to [webView],
|
||||
* then starts the signature extraction pipeline once the page loads.
|
||||
*/
|
||||
private fun configureWebView(
|
||||
webView: WebView,
|
||||
continuation: CancellableContinuation<Signature>,
|
||||
) {
|
||||
val jsInterface = SignatureJsInterface()
|
||||
webView.addJavascriptInterface(jsInterface, JS_INTERFACE_NAME)
|
||||
Log.d(TAG, "configureWebView: JS interface '$JS_INTERFACE_NAME' added")
|
||||
|
||||
// AtomicBoolean guard used by the actual resume path so only the
|
||||
// FIRST code path that resumes the continuation wins — all others
|
||||
// are skipped. This prevents the "Already resumed"
|
||||
// IllegalStateException even when multiple paths race (e.g. poll
|
||||
// finds a signature AND onPageFinished fires again due to a
|
||||
// redirect).
|
||||
val resumed = AtomicBoolean(false)
|
||||
|
||||
// Pure check only: do not consume the resume guard here because
|
||||
// resumeOrDestroy(...) performs the atomic claim itself.
|
||||
fun isResumable() = !continuation.isCancelled && !resumed.get()
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
Log.d(TAG, "configureWebView: onPageFinished -- url: $url")
|
||||
|
||||
// Only schedule the first poll if we haven't already resumed.
|
||||
// onPageFinished fires multiple times (e.g. redirects:
|
||||
// https://hanime.tv -> https://hanime.tv/home), and each
|
||||
// firing would otherwise schedule a new poll chain.
|
||||
if (resumed.get() || continuation.isCancelled) return
|
||||
|
||||
Log.d(TAG, "configureWebView: scheduling first poll after ${WASM_INIT_DELAY_MS}ms WASM init delay")
|
||||
handler.postDelayed(
|
||||
{ pollForSignature(webView, jsInterface, continuation, resumed) },
|
||||
WASM_INIT_DELAY_MS,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
sslHandler: SslErrorHandler?,
|
||||
error: SslError?,
|
||||
) {
|
||||
Log.e(TAG, "configureWebView: SSL error -- ${error?.toString() ?: "unknown"}")
|
||||
sslHandler?.cancel()
|
||||
if (isResumable()) {
|
||||
resumeOrDestroy(webView, continuation, resumed) {
|
||||
continuation.resumeWithException(
|
||||
SignatureException("SSL error loading hanime.tv: ${error?.toString() ?: "unknown"}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
Log.e(TAG, "configureWebView: page load error -- ${error?.description ?: "unknown"} (errorCode=${error?.errorCode}), isForMainFrame=${request?.isForMainFrame}")
|
||||
// Only fast-fail for main frame requests -- subresource errors are non-fatal
|
||||
if (request?.isForMainFrame == true && isResumable()) {
|
||||
resumeOrDestroy(webView, continuation, resumed) {
|
||||
continuation.resumeWithException(
|
||||
SignatureException(
|
||||
"Page load failed: ${error?.description ?: "unknown error"} " +
|
||||
"(errorCode=${error?.errorCode})",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeatedly checks whether `window.ssignature` and `window.stime`
|
||||
* have been set by the WASM module. If the values are not yet
|
||||
* available, dispatches the `'e'` event to trigger signature
|
||||
* generation and retries after a short delay.
|
||||
*/
|
||||
private fun pollForSignature(
|
||||
webView: WebView,
|
||||
jsInterface: SignatureJsInterface,
|
||||
continuation: CancellableContinuation<Signature>,
|
||||
resumed: AtomicBoolean,
|
||||
) {
|
||||
val deadline = System.currentTimeMillis() + SIGNATURE_POLL_TIMEOUT_MS
|
||||
Log.d(TAG, "pollForSignature: starting -- deadline in ${SIGNATURE_POLL_TIMEOUT_MS}ms")
|
||||
|
||||
fun poll() {
|
||||
if (continuation.isCancelled || resumed.get()) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now > deadline) {
|
||||
Log.w(TAG, "pollForSignature: timeout reached -- ${SIGNATURE_POLL_TIMEOUT_MS}ms elapsed, no signature")
|
||||
resumeOrDestroy(webView, continuation, resumed) {
|
||||
continuation.resumeWithException(
|
||||
SignatureException("Signature not available after ${SIGNATURE_POLL_TIMEOUT_MS}ms of polling"),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check result from any PREVIOUS evaluateJavascript call first.
|
||||
// evaluateJavascript is asynchronous -- the JS callback fires on the
|
||||
// next main-thread Looper iteration, so the result of the current
|
||||
// call won't be available until after poll() returns.
|
||||
val result = jsInterface.getResult()
|
||||
if (result != null) {
|
||||
Log.d(TAG, "pollForSignature: result obtained from jsInterface -- signature length=${result.signature.length}")
|
||||
resumeOrDestroy(webView, continuation, resumed) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No result yet -- execute the polling script and schedule a
|
||||
// follow-up check after POLL_INTERVAL_MS. The script's JS callback
|
||||
// will have fired by then.
|
||||
Log.d(TAG, "pollForSignature: no result yet -- calling evaluateJavascript (time remaining: ${deadline - now}ms)")
|
||||
webView.evaluateJavascript(POLL_SCRIPT, null)
|
||||
handler.postDelayed({ poll() }, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
poll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes [action] to resume the continuation, then destroys the
|
||||
* WebView on the main thread to release resources.
|
||||
*
|
||||
* Uses [resumed] [AtomicBoolean] to guarantee that only the FIRST
|
||||
* caller wins — subsequent callers are silently skipped. This prevents
|
||||
* the "Already resumed" [IllegalStateException] when multiple code
|
||||
* paths race (e.g. poll success vs. SSL error).
|
||||
*/
|
||||
private fun resumeOrDestroy(
|
||||
webView: WebView,
|
||||
continuation: CancellableContinuation<Signature>,
|
||||
resumed: AtomicBoolean,
|
||||
action: () -> Unit,
|
||||
) {
|
||||
if (!resumed.compareAndSet(false, true)) {
|
||||
Log.w(TAG, "resumeOrDestroy: already resumed, skipping")
|
||||
return
|
||||
}
|
||||
action()
|
||||
handler.post {
|
||||
webView.destroy()
|
||||
if (activeWebView === webView) activeWebView = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives signature values from the JavaScript environment via
|
||||
* [WebView.addJavascriptInterface].
|
||||
*
|
||||
* The JS polling script calls [onSignatureReady] when both
|
||||
* `window.ssignature` and `window.stime` are available, and
|
||||
* [onSignatureNotReady] when they are not yet set.
|
||||
*/
|
||||
class SignatureJsInterface {
|
||||
|
||||
@Volatile
|
||||
private var signatureResult: Signature? = null
|
||||
|
||||
/** Called from JS when both `window.ssignature` and `window.stime` are set. */
|
||||
@JavascriptInterface
|
||||
fun onSignatureReady(signature: String, time: String) {
|
||||
Log.d(TAG, "SignatureJsInterface: onSignatureReady -- signature length=${signature.length}, time=$time")
|
||||
signatureResult = Signature(signature, time)
|
||||
}
|
||||
|
||||
/** Called from JS when the signature values are not yet available. */
|
||||
@JavascriptInterface
|
||||
fun onSignatureNotReady() {
|
||||
Log.d(TAG, "SignatureJsInterface: onSignatureNotReady -- WASM signature not yet available")
|
||||
// No-op -- the poll loop will retry after POLL_INTERVAL_MS
|
||||
}
|
||||
|
||||
/** Returns the captured signature, or `null` if not yet available. */
|
||||
fun getResult(): Signature? = signatureResult
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WebViewSigProvider"
|
||||
|
||||
/** Maximum time to wait for the page to finish loading. */
|
||||
private const val PAGE_LOAD_TIMEOUT_MS = 30_000L
|
||||
|
||||
/** Maximum time to poll for the signature after the page loads. */
|
||||
private const val SIGNATURE_POLL_TIMEOUT_MS = 15_000L
|
||||
|
||||
/** Interval between signature availability checks. */
|
||||
private const val POLL_INTERVAL_MS = 500L
|
||||
|
||||
/** Delay after onPageFinished before the first poll -- gives WASM time to initialise. */
|
||||
private const val WASM_INIT_DELAY_MS = 2_000L
|
||||
|
||||
/** Combined timeout for the entire operation. */
|
||||
private const val TOTAL_TIMEOUT_MS = PAGE_LOAD_TIMEOUT_MS + SIGNATURE_POLL_TIMEOUT_MS
|
||||
|
||||
/** Time-to-live for cached signatures before a fresh WebView load is required. */
|
||||
private const val SIGNATURE_CACHE_TTL_MS = 120_000L // 2 minutes
|
||||
|
||||
/** Name exposed to JavaScript via `addJavascriptInterface`. */
|
||||
private const val JS_INTERFACE_NAME = "AndroidInterface"
|
||||
|
||||
/**
|
||||
* User agent string mimicking a recent Chrome on Android device.
|
||||
* Must be mobile-class so hanime.tv serves the correct WASM payload.
|
||||
*/
|
||||
private const val USER_AGENT =
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"
|
||||
|
||||
/**
|
||||
* JavaScript executed on each poll iteration.
|
||||
*
|
||||
* 1. If `window.ssignature` and `window.stime` are already set,
|
||||
* delivers them to [SignatureJsInterface.onSignatureReady].
|
||||
* 2. If the WASM exports object exists but no signature yet,
|
||||
* dispatches the `'e'` event to trigger generation, then
|
||||
* polls again after a 1-second delay.
|
||||
* 3. If WASM has not loaded at all, signals
|
||||
* [SignatureJsInterface.onSignatureNotReady] so the Kotlin
|
||||
* side can retry.
|
||||
*/
|
||||
private val POLL_SCRIPT = """
|
||||
(function() {
|
||||
if (window.ssignature && window.stime) {
|
||||
__JS_INTERFACE__.onSignatureReady(
|
||||
window.ssignature,
|
||||
window.stime.toString()
|
||||
);
|
||||
} else if (window.wasmExports) {
|
||||
window.dispatchEvent(new Event('e'));
|
||||
setTimeout(function() {
|
||||
if (window.ssignature && window.stime) {
|
||||
__JS_INTERFACE__.onSignatureReady(
|
||||
window.ssignature,
|
||||
window.stime.toString()
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
__JS_INTERFACE__.onSignatureNotReady();
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("__JS_INTERFACE__", JS_INTERFACE_NAME)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user