Compare commits
6 Commits
feature/an
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5f35b7ee | |||
|
|
6a9375164c | ||
|
|
203fa83e99 | ||
|
|
3fd64824bc | ||
|
|
923ae5fbbc | ||
|
|
e336275363 |
18
AGENTS.md
18
AGENTS.md
@@ -8,8 +8,8 @@ Java/Kotlin Maven multi-module project that provides a JVM runtime for Aniyomi (
|
||||
# Build fat JAR (includes all dependencies)
|
||||
mvn clean package
|
||||
|
||||
# Output: aniyomibridge-core/target/aniyomibridge-core-1.0.0.jar
|
||||
# Main class: dev.k3vinb5.aniyomibridge.Main
|
||||
# Output: aniyomi-bridge/target/aniyomiBridge-1.0.0.jar
|
||||
# Main class: dev.k3vinb5.unyo.core.cli.Main
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@@ -17,14 +17,14 @@ mvn clean package
|
||||
This is a multi-module Maven project with the following modules:
|
||||
|
||||
```
|
||||
aniyomibridge-core/
|
||||
unyo-core/
|
||||
├── pom.xml # Parent POM
|
||||
├── androidcompat/ # Android compatibility layer
|
||||
│ ├── src/main/java/android/ # Custom Android stub implementations
|
||||
│ ├── src/main/java/androidx/ # AndroidX stub implementations
|
||||
│ ├── scripts/get-android.sh # Android SDK JAR download script
|
||||
│ └── pom.xml
|
||||
├── aniyomibridge-core/ # Core bridge module
|
||||
├── aniyomi-bridge/ # Core bridge module
|
||||
│ ├── src/main/java/ # Java sources (bridge core)
|
||||
│ ├── src/main/kotlin/ # Kotlin sources (Tachiyomi/Aniyomi interfaces)
|
||||
│ └── pom.xml
|
||||
@@ -65,7 +65,7 @@ To enable:
|
||||
1. Update `androidcompat/pom.xml` - change `<phase>none</phase>` to `<phase>generate-resources</phase>`
|
||||
2. Or manually download `android.jar` and place in `androidcompat/target/`
|
||||
|
||||
### Module: `aniyomibridge-core`
|
||||
### Module: `aniyomi-bridge`
|
||||
|
||||
Core bridge module that loads and executes extensions.
|
||||
|
||||
@@ -91,7 +91,7 @@ Core bridge module that loads and executes extensions.
|
||||
**Maven Shade Configuration**:
|
||||
- Excludes signing artifacts (SF/DSA/RSA) and module-info
|
||||
- Excludes `Main.class` from the JAR (consumed by external Flutter app)
|
||||
- Manifest points to `dev.k3vinb5.aniyomibridge.cli.Main`
|
||||
- Manifest points to `dev.k3vinb5.unyo.core.cli.Main`
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
@@ -102,7 +102,7 @@ Core bridge module that loads and executes extensions.
|
||||
- `jsoup` - HTML parsing
|
||||
- `quickjs-kt-jvm` - JavaScript execution
|
||||
|
||||
### aniyomibridge-core
|
||||
### aniyomi-bridge
|
||||
- `androidcompat` - Android compatibility layer (this project)
|
||||
- `dex2jar` - Converts Android APK DEX → JAR
|
||||
- `kotlinx-serialization-json` - JSON handling
|
||||
@@ -118,7 +118,7 @@ Core bridge module that loads and executes extensions.
|
||||
| `generate-new-core-mac.sh` | Build + copy JAR to Flutter app assets (Mac path) |
|
||||
| `androidcompat/scripts/get-android.sh` | Download and patch Android SDK JAR |
|
||||
|
||||
Both build scripts copy to a sibling project `k3vinb5_aniyomi_bridge/assets/`.
|
||||
Both build scripts copy to a sibling project `unyo-lib/assets/`.
|
||||
|
||||
## Android Stubs Strategy
|
||||
|
||||
@@ -179,7 +179,7 @@ If you're migrating from the old single-module structure:
|
||||
|
||||
1. Android stubs moved from `src/main/java/android/` to `androidcompat/src/main/java/android/`
|
||||
2. Build command unchanged: `mvn clean package`
|
||||
3. Output location unchanged: `aniyomibridge-core/target/`
|
||||
3. Output location unchanged: `aniyomi-bridge/target/`
|
||||
4. New dependency: Core module now depends on `androidcompat` module
|
||||
|
||||
## Future Improvements
|
||||
|
||||
625
JNI_SPECIFICATION.md
Normal file
625
JNI_SPECIFICATION.md
Normal file
@@ -0,0 +1,625 @@
|
||||
# Unyo Core JNI Specification
|
||||
|
||||
> **Version:** 2.0 (Post-Multi-Module Refactor)
|
||||
> **Target:** Flutter JNI integration for `unyo-core-1.0.0.jar`
|
||||
> **Main Class:** `dev.k3vinb5.unyo.core.AniyomiBridge`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the public Java API exposed by `AniyomiBridge.java` for consumption via JNI from Flutter. It covers every method, its signature, the data models that cross the JNI boundary, and the behavioral changes introduced during the multi-module refactor.
|
||||
|
||||
**Key architectural fact:** `AniyomiBridge` is a **singleton-like stateful object**. It maintains internal maps of loaded anime/manga sources keyed by `packageName`. You create one instance and call methods on it. All source lookup is done internally via these maps.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Initialization](#1-initialization)
|
||||
2. [Extension Lifecycle (Anime)](#2-extension-lifecycle-anime)
|
||||
3. [Extension Lifecycle (Manga)](#3-extension-lifecycle-manga)
|
||||
4. [Querying Loaded Extensions](#4-querying-loaded-extensions)
|
||||
5. [Preferences API](#5-preferences-api)
|
||||
6. [Content Fetching (Anime)](#6-content-fetching-anime)
|
||||
7. [Content Fetching (Manga)](#7-content-fetching-manga)
|
||||
8. [Data Models](#8-data-models)
|
||||
9. [Error Handling](#9-error-handling)
|
||||
10. [Breaking Changes from v1](#10-breaking-changes-from-v1)
|
||||
11. [JNI Implementation Notes](#11-jni-implementation-notes)
|
||||
|
||||
---
|
||||
|
||||
## 1. Initialization
|
||||
|
||||
### `static void init()`
|
||||
|
||||
Must be called **once** before any other operation. Performs global JVM-level setup:
|
||||
|
||||
- Installs ByteBuddy agent for runtime bytecode patching
|
||||
- Initializes Injekt dependency injection module
|
||||
|
||||
**JNI signature:**
|
||||
```
|
||||
(env)->CallStaticVoidMethod(aniyomiBridgeClass, initMethod);
|
||||
```
|
||||
|
||||
**Thread safety:** Safe to call from any thread, but should only be called once. Not synchronized externally — caller must ensure single invocation.
|
||||
|
||||
**Throws:** Nothing directly, but ByteBuddy installation can fail if the JVM doesn't support instrumentation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Extension Lifecycle (Anime)
|
||||
|
||||
### `void loadAnimeExtension(String extensionUrl, String basePath, String packageName)`
|
||||
|
||||
Downloads an APK from `extensionUrl`, converts it to JAR via dex2jar, loads the main extension class, and registers it as an `AnimeHttpSource`.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `extensionUrl` | Full HTTPS URL to the `.apk` file |
|
||||
| `basePath` | Local directory path where the converted `.jar` will be cached |
|
||||
| `packageName` | The Android package name (e.g., `eu.kanade.tachiyomi.animeextension.en.animepahe`). Used as the **internal key** for all subsequent lookups. |
|
||||
|
||||
**Behavior:**
|
||||
- Downloads APK if not already present
|
||||
- Converts APK → JAR using dex2jar
|
||||
- Inspects JAR to find the main extension class
|
||||
- Loads class via isolated `URLClassLoader`
|
||||
- Applies ByteBuddy patches to the extension's JVM classes
|
||||
- If the source implements `ConfigurableAnimeSource`, automatically calls `setupPreferenceScreen()` with a managed `PreferenceScreen`
|
||||
- Stores the source in an internal `ConcurrentHashMap` keyed by `packageName`
|
||||
|
||||
**Throws:** `UnyoCoreException` on any failure (download, dex2jar, class loading, casting).
|
||||
|
||||
---
|
||||
|
||||
### `void unloadAnimeExtension(String packageName)`
|
||||
|
||||
Unloads an anime extension and cleans up resources.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `packageName` | The same `packageName` passed to `loadAnimeExtension` |
|
||||
|
||||
**Behavior:**
|
||||
- Closes the extension's `URLClassLoader`
|
||||
- Deletes the cached JAR file from disk
|
||||
- Removes the source from internal maps
|
||||
|
||||
**Throws:** `UnyoCoreException` on cleanup failure.
|
||||
|
||||
---
|
||||
|
||||
## 3. Extension Lifecycle (Manga)
|
||||
|
||||
### `void loadMangaExtension(String extensionUrl, String basePath, String packageName)`
|
||||
|
||||
Identical to `loadAnimeExtension` but for manga sources implementing `CatalogueSource`.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `extensionUrl` | Full HTTPS URL to the `.apk` file |
|
||||
| `basePath` | Local directory path where the converted `.jar` will be cached |
|
||||
| `packageName` | Android package name, used as internal key |
|
||||
|
||||
**Behavior:** Same as anime version, but casts to `CatalogueSource` and calls `ConfigurableSource.setupPreferenceScreen()` if applicable.
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
---
|
||||
|
||||
### `void unloadMangaExtension(String packageName)`
|
||||
|
||||
Unloads a manga extension.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `packageName` | The same `packageName` passed to `loadMangaExtension` |
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
---
|
||||
|
||||
## 4. Querying Loaded Extensions
|
||||
|
||||
### `List<String> getAnimeLoadedExtensions()`
|
||||
|
||||
Returns a list of loaded anime extension package names.
|
||||
|
||||
**Return:** `java.util.List<java.lang.String>` — package names of all currently loaded anime sources.
|
||||
|
||||
---
|
||||
|
||||
### `List<String> getMangaLoadedExtensions()`
|
||||
|
||||
Returns a list of loaded manga extension package names.
|
||||
|
||||
**Return:** `java.util.List<java.lang.String>` — package names of all currently loaded manga sources.
|
||||
|
||||
---
|
||||
|
||||
## 5. Preferences API
|
||||
|
||||
> **New in v2.** Previously, preferences were not exposed to Flutter. Now they are fully readable and writable.
|
||||
|
||||
When a `ConfigurableAnimeSource` or `ConfigurableSource` is loaded, `AniyomiBridge` automatically calls `setupPreferenceScreen(screen)` and creates a `PreferenceScreen` backed by file-based `SharedPreferences` (stored under `~/.aniyomibridge/shared_prefs/<source-id>.xml`).
|
||||
|
||||
### `List<PreferenceItem> getAnimePreferences(String packageName)`
|
||||
|
||||
Reads all preferences registered by an anime extension.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `packageName` | Package name of the loaded anime extension |
|
||||
|
||||
**Return:** `java.util.List<PreferenceItem>`
|
||||
|
||||
**Behavior:**
|
||||
- Returns empty list if source is not configurable
|
||||
- Iterates all `Preference` objects on the source's `PreferenceScreen`
|
||||
- Reads current value from `SharedPreferences` (or default if never set)
|
||||
- Returns typed values based on preference type
|
||||
|
||||
**Supported preference types:**
|
||||
|
||||
| Type | Java Type | Value Type |
|
||||
|------|-----------|------------|
|
||||
| `SwitchPreferenceCompat` | `Boolean` | `true` / `false` |
|
||||
| `ListPreference` | `String` | Selected entry value |
|
||||
| `EditTextPreference` | `String` | User input text |
|
||||
| `MultiSelectListPreference` | `Set<String>` | Selected values |
|
||||
| `Preference` (fallback) | `null` | No value |
|
||||
|
||||
---
|
||||
|
||||
### `void setAnimePreference(String packageName, String key, Object value)`
|
||||
|
||||
Sets a single preference value for an anime extension.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `packageName` | Package name of the loaded anime extension |
|
||||
| `key` | Preference key (from `getAnimePreferences`) |
|
||||
| `value` | Value to set. Must match the expected type: `Boolean`, `String`, or `Set<String>` |
|
||||
|
||||
**Throws:** `UnyoCoreException` if source not configurable, key not found, or type mismatch.
|
||||
|
||||
---
|
||||
|
||||
### `void setAnimePreferences(String packageName, Map<String, Object> values)`
|
||||
|
||||
Batch-updates multiple preferences for an anime extension.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `packageName` | Package name of the loaded anime extension |
|
||||
| `values` | `java.util.Map` of `key → value` pairs |
|
||||
|
||||
**Behavior:** Iterates the map and calls `setAnimePreference` logic for each entry. Fails fast on first error.
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
> **Note:** Manga preferences API is **not yet exposed** in `AniyomiBridge.java`. If needed, the same pattern applies using `CatalogueSource` / `ConfigurableSource`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Content Fetching (Anime)
|
||||
|
||||
All content-fetching methods are **synchronous blocking calls** that internally run Kotlin coroutines via `runBlocking`. Call them from background threads in Flutter.
|
||||
|
||||
### `List<SAnime> getAnimeSearchResults(String query, int page, String packageName)`
|
||||
|
||||
Searches for anime using the specified extension.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `query` | Search query string |
|
||||
| `page` | Page number (1-based) |
|
||||
| `packageName` | Package name of the loaded anime extension |
|
||||
|
||||
**Return:** `java.util.List<SAnime>`
|
||||
|
||||
**Throws:** `UnyoCoreException` wrapping any network or parsing error.
|
||||
|
||||
---
|
||||
|
||||
### `List<SEpisode> getEpisodeList(SAnime anime, String packageName)`
|
||||
|
||||
Fetches the episode list for a given anime.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `anime` | `SAnime` object (from search results or previous call) |
|
||||
| `packageName` | Package name of the loaded anime extension |
|
||||
|
||||
**Return:** `java.util.List<SEpisode>`
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
---
|
||||
|
||||
### `List<Video> getVideoList(SEpisode episode, String packageName)`
|
||||
|
||||
Fetches video URLs/metadata for a given episode.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `episode` | `SEpisode` object |
|
||||
| `packageName` | Package name of the loaded anime extension |
|
||||
|
||||
**Return:** `java.util.List<Video>`
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
> **Important:** The `Video` class contains `okhttp3.Headers` which is **not serializable by default**. If you need to pass video data across JNI/Flutter isolates, consider using `SerializableVideo` (see Data Models).
|
||||
|
||||
---
|
||||
|
||||
## 7. Content Fetching (Manga)
|
||||
|
||||
### `List<SManga> getMangaSearchResults(String query, int page, String packageName)`
|
||||
|
||||
Searches for manga.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `query` | Search query string |
|
||||
| `page` | Page number (1-based) |
|
||||
| `packageName` | Package name of the loaded manga extension |
|
||||
|
||||
**Return:** `java.util.List<SManga>`
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
---
|
||||
|
||||
### `List<SChapter> getChapterList(SManga manga, String packageName)`
|
||||
|
||||
Fetches chapter list for a manga.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `manga` | `SManga` object |
|
||||
| `packageName` | Package name of the loaded manga extension |
|
||||
|
||||
**Return:** `java.util.List<SChapter>`
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
---
|
||||
|
||||
### `List<Page> getPageList(SChapter chapter, String packageName)`
|
||||
|
||||
Fetches image page URLs for a chapter.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `chapter` | `SChapter` object |
|
||||
| `packageName` | Package name of the loaded manga extension |
|
||||
|
||||
**Return:** `java.util.List<Page>`
|
||||
|
||||
**Throws:** `UnyoCoreException`
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Models
|
||||
|
||||
### `PreferenceItem`
|
||||
|
||||
**Package:** `dev.k3vinb5.unyo.core.PreferenceItem`
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `key` | `String` | Unique preference identifier |
|
||||
| `title` | `String` | Human-readable title |
|
||||
| `type` | `String` | One of: `SwitchPreferenceCompat`, `ListPreference`, `EditTextPreference`, `MultiSelectListPreference`, `Preference` |
|
||||
| `value` | `Object` | Current value (`Boolean`, `String`, or `Set<String>`) |
|
||||
| `entries` | `List<String>` | Display labels (for List/MultiSelect) |
|
||||
| `entryValues` | `List<String>` | Internal values (for List/MultiSelect) |
|
||||
| `summary` | `String` | Optional description text |
|
||||
|
||||
**JNI tip:** Access fields directly (they are `public`). No getters/setters.
|
||||
|
||||
---
|
||||
|
||||
### `SAnime`
|
||||
|
||||
**Package:** `eu.kanade.tachiyomi.animesource.model.SAnime`
|
||||
|
||||
**Interface fields (all `public`):**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `url` | `String` | Relative or absolute URL to the anime page |
|
||||
| `title` | `String` | Display title |
|
||||
| `artist` | `String?` | Nullable |
|
||||
| `author` | `String?` | Nullable |
|
||||
| `description` | `String?` | Nullable |
|
||||
| `genre` | `String?` | Nullable; comma-separated genres |
|
||||
| `status` | `int` | `UNKNOWN=0`, `ONGOING=1`, `COMPLETED=2`, `LICENSED=3`, `PUBLISHING_FINISHED=4`, `CANCELLED=5`, `ON_HIATUS=6` |
|
||||
| `thumbnail_url` | `String?` | Cover image URL |
|
||||
| `update_strategy` | `AnimeUpdateStrategy` | Enum |
|
||||
| `fetch_type` | `FetchType` | Enum |
|
||||
| `season_number` | `double` | Season number |
|
||||
| `initialized` | `boolean` | Whether full metadata has been fetched |
|
||||
|
||||
**Method:** `List<String> getGenres()` — parses `genre` string into a list.
|
||||
|
||||
---
|
||||
|
||||
### `SEpisode`
|
||||
|
||||
**Package:** `eu.kanade.tachiyomi.animesource.model.SEpisode`
|
||||
|
||||
**Interface fields:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `url` | `String` | Episode page URL |
|
||||
| `name` | `String` | Episode title/number |
|
||||
| `date_upload` | `long` | Unix timestamp (ms) |
|
||||
| `episode_number` | `float` | Numeric episode number |
|
||||
| `scanlator` | `String?` | Nullable |
|
||||
|
||||
---
|
||||
|
||||
### `Video`
|
||||
|
||||
**Package:** `eu.kanade.tachiyomi.animesource.model.Video`
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `videoUrl` | `String` | Direct video stream URL |
|
||||
| `videoTitle` | `String` | Quality label (e.g., "1080p") |
|
||||
| `resolution` | `Integer?` | Nullable resolution in pixels |
|
||||
| `bitrate` | `Integer?` | Nullable bitrate |
|
||||
| `headers` | `okhttp3.Headers?` | HTTP headers required for playback |
|
||||
| `preferred` | `boolean` | Whether this is the preferred quality |
|
||||
| `subtitleTracks` | `List<Track>` | Subtitle tracks |
|
||||
| `audioTracks` | `List<Track>` | Audio tracks |
|
||||
| `timestamps` | `List<TimeStamp>` | Chapter/intro markers |
|
||||
| `mpvArgs` | `List<Pair<String,String>>` | MPV player arguments |
|
||||
| `ffmpegStreamArgs` | `List<Pair<String,String>>` | FFmpeg stream arguments |
|
||||
| `ffmpegVideoArgs` | `List<Pair<String,String>>` | FFmpeg video arguments |
|
||||
| `internalData` | `String` | Opaque internal data from extractor |
|
||||
| `initialized` | `boolean` | Whether video metadata is resolved |
|
||||
|
||||
**Deprecation notice:** Old extensions may use `quality` (maps to `videoTitle`) and `url` (maps to internal `videoPageUrl`). New code should use `videoTitle` and `videoUrl`.
|
||||
|
||||
---
|
||||
|
||||
### `SManga`
|
||||
|
||||
**Package:** `eu.kanade.tachiyomi.source.model.SManga`
|
||||
|
||||
Identical structure to `SAnime` but without `fetch_type`, `season_number`, and `update_strategy` is `UpdateStrategy` instead of `AnimeUpdateStrategy`.
|
||||
|
||||
---
|
||||
|
||||
### `SChapter`
|
||||
|
||||
**Package:** `eu.kanade.tachiyomi.source.model.SChapter`
|
||||
|
||||
Identical structure to `SEpisode` but `chapter_number` instead of `episode_number`.
|
||||
|
||||
---
|
||||
|
||||
### `Page`
|
||||
|
||||
**Package:** `eu.kanade.tachiyomi.source.model.Page`
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `index` | `int` | Zero-based page index |
|
||||
| `url` | `String` | Page URL (may be relative) |
|
||||
| `imageUrl` | `String?` | Resolved direct image URL |
|
||||
| `number` | `int` | Human-readable page number (`index + 1`) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
### `UnyoCoreException`
|
||||
|
||||
**Package:** `dev.k3vinb5.unyo.core.exceptions.UnyoCoreException`
|
||||
|
||||
Extends `RuntimeException`.
|
||||
|
||||
**Constructors:**
|
||||
- `UnyoCoreException(String message)`
|
||||
- `UnyoCoreException(String message, Throwable cause)`
|
||||
|
||||
**Behavior:** Every public method in `AniyomiBridge` wraps failures in `UnyoCoreException` with the full Java stack trace in the message string. When catching via JNI, you get both the user-friendly message and the developer stack trace.
|
||||
|
||||
**Recommended Flutter handling:**
|
||||
```dart
|
||||
// Pseudocode — depends on your JNI bindings
|
||||
try {
|
||||
final results = bridge.getAnimeSearchResults(query, page, packageName);
|
||||
} on JniException catch (e) {
|
||||
// e.message contains both the summary and the full Java stack trace
|
||||
log.error(e.message);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Breaking Changes from v1
|
||||
|
||||
If your Flutter code was written against the pre-refactor API (before multi-module split), these are the critical changes:
|
||||
|
||||
### 1. Extension keys changed from `extensionName` to `packageName`
|
||||
|
||||
**Before:**
|
||||
```java
|
||||
loadAnimeExtension(String extensionUrl, String basePath)
|
||||
// Key was extracted from URL: "animepahe", "torrentio", etc.
|
||||
|
||||
unloadAnimeExtension(String extensionName, String version, String basePath)
|
||||
// You passed extensionName + version, bridge constructed filename
|
||||
```
|
||||
|
||||
**After:**
|
||||
```java
|
||||
loadAnimeExtension(String extensionUrl, String basePath, String packageName)
|
||||
// You pass the full Android package name as key
|
||||
|
||||
unloadAnimeExtension(String packageName)
|
||||
// Just packageName. Bridge tracks the JAR path internally.
|
||||
```
|
||||
|
||||
**Migration:**
|
||||
- Stop extracting extension names from URLs
|
||||
- Use the Android package name (from the extension repo JSON) as the key everywhere
|
||||
- Update your Flutter model to pass `packageName` to all methods
|
||||
|
||||
### 2. `getAnimeSearchResults` / `getMangaSearchResults` signature change
|
||||
|
||||
**Before:**
|
||||
```java
|
||||
getAnimeSearchResults(String query, int page, String animeSource)
|
||||
// 'animeSource' was the extension name
|
||||
```
|
||||
|
||||
**After:**
|
||||
```java
|
||||
getAnimeSearchResults(String query, int page, String packageName)
|
||||
// 'packageName' is the full package name
|
||||
```
|
||||
|
||||
**Migration:** Pass `packageName` instead of extension name.
|
||||
|
||||
### 3. Content fetching methods signature change
|
||||
|
||||
**Before:**
|
||||
```java
|
||||
getEpisodeList(SAnime anime, String animeSource)
|
||||
getVideoList(SEpisode episode, String animeSource)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```java
|
||||
getEpisodeList(SAnime anime, String packageName)
|
||||
getVideoList(SEpisode episode, String packageName)
|
||||
```
|
||||
|
||||
**Migration:** Same as above — replace `animeSource` string with `packageName`.
|
||||
|
||||
### 4. Removal of `correctedNames` map
|
||||
|
||||
**Before:** The bridge internally mapped names like `"zoro" → "hianime"` and `"aniwatch" → "aniwatchtv"`.
|
||||
|
||||
**After:** No internal name correction. You must use the exact `packageName`.
|
||||
|
||||
**Migration:** Remove any logic in Flutter that relied on these aliases. Use package names directly from the repo metadata.
|
||||
|
||||
### 5. Unload methods no longer take `version` or `basePath`
|
||||
|
||||
**Before:**
|
||||
```java
|
||||
unloadAnimeExtension(String extensionName, String version, String basePath)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```java
|
||||
unloadAnimeExtension(String packageName)
|
||||
```
|
||||
|
||||
**Migration:** Simpler — just pass `packageName`.
|
||||
|
||||
### 6. Preferences API is new
|
||||
|
||||
**Before:** No way to read/write extension preferences from Flutter.
|
||||
|
||||
**After:** Full `getAnimePreferences`, `setAnimePreference`, `setAnimePreferences` API.
|
||||
|
||||
**Migration:** If your app previously couldn't handle configurable sources, now you can/should expose these preferences in your UI.
|
||||
|
||||
### 7. `MediaSourceUtils` now uses per-source filters
|
||||
|
||||
**Before:**
|
||||
```kotlin
|
||||
animeSource.getSearchAnime(page, query, filters = defaultAnimeFilterList)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```kotlin
|
||||
animeSource.getSearchAnime(page, query, filters = animeSource.getFilterList())
|
||||
```
|
||||
|
||||
**Impact:** Search results may differ if an extension had custom filters. This is generally more correct but could change behavior for extensions that rely on non-default filter states.
|
||||
|
||||
---
|
||||
|
||||
## 11. JNI Implementation Notes
|
||||
|
||||
### Class Loading
|
||||
|
||||
The fat JAR (`unyo-core-1.0.0.jar`) contains everything including the Android SDK stubs, Kotlin stdlib, and all dependencies. Load it with a standard `URLClassLoader` or from the classpath.
|
||||
|
||||
**Important:** The `Main.class` (`dev.k3vinb5.unyo.core.cli.Main`) is excluded from the fat JAR (consumed by external Flutter app). The class you instantiate is `dev.k3vinb5.unyo.core.AniyomiBridge`.
|
||||
|
||||
### Threading
|
||||
|
||||
All content-fetching methods use `kotlinx.coroutines.runBlocking` internally. They **will block the calling thread**. In Flutter, always call these methods through a background isolate or `compute()` / `Isolate.run()`.
|
||||
|
||||
The internal maps (`animeSources`, `mangaSources`, `classLoaders`) use `ConcurrentHashMap`, so `load*` and `unload*` are thread-safe. However, you should not load and unload the same extension concurrently.
|
||||
|
||||
### Object Lifetimes
|
||||
|
||||
`SAnime`, `SEpisode`, `SManga`, `SChapter` are `Serializable`. You can safely pass them across JNI boundaries and cache them in Dart if needed.
|
||||
|
||||
`Video` contains `okhttp3.Headers` which is **not serializable**. If you need to serialize video data (e.g., for Dart isolates or JSON), convert `Headers` to a `Map<String, List<String>>` or use the `SerializableVideo` Kotlin helper class.
|
||||
|
||||
### Memory
|
||||
|
||||
Each loaded extension gets its own `URLClassLoader`. When you call `unload*Extension`, the classloader is closed and the JAR file is deleted. This should release most memory, but some classes may remain in the JVM's metaspace until GC.
|
||||
|
||||
### Logging
|
||||
|
||||
The bridge uses JBoss Logging. Logs go to stdout/stderr by default. In a Flutter desktop app, you'll see them in the console. Consider redirecting `java.util.logging` or JBoss LogManager to your Dart logging system if you need structured logs.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Quick Reference — Method Signatures
|
||||
|
||||
```java
|
||||
// Initialization
|
||||
public static void init()
|
||||
|
||||
// Anime lifecycle
|
||||
public void loadAnimeExtension(String extensionUrl, String basePath, String packageName)
|
||||
public void unloadAnimeExtension(String packageName)
|
||||
|
||||
// Manga lifecycle
|
||||
public void loadMangaExtension(String extensionUrl, String basePath, String packageName)
|
||||
public void unloadMangaExtension(String packageName)
|
||||
|
||||
// Query loaded
|
||||
public List<String> getAnimeLoadedExtensions()
|
||||
public List<String> getMangaLoadedExtensions()
|
||||
|
||||
// Preferences (anime only, currently)
|
||||
public List<PreferenceItem> getAnimePreferences(String packageName)
|
||||
public void setAnimePreference(String packageName, String key, Object value)
|
||||
public void setAnimePreferences(String packageName, Map<String, Object> values)
|
||||
|
||||
// Anime content
|
||||
public List<SAnime> getAnimeSearchResults(String query, int page, String packageName)
|
||||
public List<SEpisode> getEpisodeList(SAnime anime, String packageName)
|
||||
public List<Video> getVideoList(SEpisode episode, String packageName)
|
||||
|
||||
// Manga content
|
||||
public List<SManga> getMangaSearchResults(String query, int page, String packageName)
|
||||
public List<SChapter> getChapterList(SManga manga, String packageName)
|
||||
public List<Page> getPageList(SChapter chapter, String packageName)
|
||||
```
|
||||
40
README.md
40
README.md
@@ -1,4 +1,4 @@
|
||||
# AniyomiBridge Core
|
||||
# Unyo Core
|
||||
|
||||
A JVM runtime for running Aniyomi (anime/manga streaming) Android extensions on desktop. This project bridges the gap between Android's framework and desktop Java/Kotlin by providing a compatibility layer that lets Aniyomi extensions execute outside of the Android ecosystem.
|
||||
|
||||
@@ -8,8 +8,8 @@ A JVM runtime for running Aniyomi (anime/manga streaming) Android extensions on
|
||||
# Build the fat JAR (includes all dependencies)
|
||||
mvn clean package
|
||||
|
||||
# Output: aniyomibridge-core/target/aniyomibridge-core-1.0.0.jar
|
||||
# Main class: dev.k3vinb5.aniyomibridge.Main
|
||||
# Output: aniyomi-bridge/target/aniyomiBridge-1.0.0.jar
|
||||
# Main class: dev.k3vinb5.unyo.core.cli.Main
|
||||
```
|
||||
|
||||
## Project Architecture
|
||||
@@ -17,14 +17,14 @@ mvn clean package
|
||||
This is a **multi-module Maven project** with two main modules:
|
||||
|
||||
```
|
||||
aniyomibridge-core/
|
||||
unyo-core/
|
||||
├── pom.xml # Parent POM — coordinates modules
|
||||
├── androidcompat/ # Android compatibility layer
|
||||
│ ├── src/main/java/android/ # Custom Android stub implementations
|
||||
│ ├── src/main/java/androidx/ # AndroidX stub implementations
|
||||
│ ├── scripts/get-android.sh # Android SDK JAR download & patch script
|
||||
│ └── pom.xml
|
||||
├── aniyomibridge-core/ # Core bridge module
|
||||
├── aniyomi-bridge/ # Core bridge module
|
||||
│ ├── src/main/java/ # Java sources (bridge core)
|
||||
│ ├── src/main/kotlin/ # Kotlin sources (Tachiyomi/Aniyomi interfaces)
|
||||
│ └── pom.xml
|
||||
@@ -41,7 +41,7 @@ Provides Android framework classes on desktop JVM using a **hybrid approach** in
|
||||
|
||||
**Result**: A merged Android API where ~4,000 SDK classes work as-is, and ~30+ critical classes have desktop-aware implementations.
|
||||
|
||||
### Module: `aniyomibridge-core`
|
||||
### Module: `aniyomi-bridge`
|
||||
|
||||
The core bridge that loads and executes Aniyomi/Tachiyomi extensions.
|
||||
|
||||
@@ -67,16 +67,16 @@ Maven builds modules in dependency order:
|
||||
|
||||
1. **Parent POM** (coordinates children, does not produce a JAR)
|
||||
2. **`androidcompat`** (no internal dependencies)
|
||||
3. **`aniyomibridge-core`** (depends on `androidcompat`)
|
||||
3. **`aniyomi-bridge`** (depends on `androidcompat`)
|
||||
|
||||
`androidcompat` **must** finish its `package` phase before `aniyomibridge-core` can even start compiling.
|
||||
`androidcompat` **must** finish its `package` phase before `aniyomi-bridge` can even start compiling.
|
||||
|
||||
### Phase-by-Phase Breakdown
|
||||
|
||||
#### 1. `clean` phase
|
||||
**Plugin:** `maven-clean-plugin`
|
||||
|
||||
- Deletes `androidcompat/target/` and `aniyomibridge-core/target/`
|
||||
- Deletes `androidcompat/target/` and `aniyomi-bridge/target/`
|
||||
- Fresh slate. Any previously downloaded `android.jar` or compiled classes are removed.
|
||||
|
||||
#### 2. `validate` through `process-sources`
|
||||
@@ -141,10 +141,10 @@ Two different things happen per module:
|
||||
- Packages `target/classes/` into `androidcompat/target/androidcompat-1.0.0.jar`
|
||||
- This JAR contains the merged Android API (SDK + custom stubs)
|
||||
|
||||
##### In `aniyomibridge-core`:
|
||||
##### In `aniyomi-bridge`:
|
||||
**Plugins:** `maven-jar-plugin` → `maven-shade-plugin`
|
||||
|
||||
1. **maven-jar-plugin** creates the "thin" JAR: `aniyomibridge-core-1.0.0.jar`
|
||||
1. **maven-jar-plugin** creates the "thin" JAR: `aniyomiBridge-1.0.0.jar`
|
||||
|
||||
2. **maven-shade-plugin** then runs:
|
||||
- Takes the thin JAR
|
||||
@@ -152,10 +152,10 @@ Two different things happen per module:
|
||||
- **Unpacks** all other dependencies (OkHttp, Kotlin stdlib, Gson, etc.)
|
||||
- Applies filter rules:
|
||||
- `minimizeJar=true` strips unused classes from dependencies
|
||||
- A `<filter>` for `androidcompat` forces **all** its classes to be included (otherwise they'd be stripped because extensions load them dynamically, not via direct references in core code)
|
||||
- Excludes `Main.class` from the final JAR (consumed by Flutter externally)
|
||||
- Excludes signing files (`*.SF`, `*.DSA`, `*.RSA`) and `module-info.class`
|
||||
- Creates the **fat JAR**: `aniyomibridge-core/target/aniyomibridge-core-1.0.0.jar`
|
||||
- A `<filter>` for `androidcompat` forces **all** its classes to be included (otherwise they'd be stripped because extensions load them dynamically, not via direct references in core code)
|
||||
- Excludes `Main.class` from the final JAR (consumed by Flutter externally)
|
||||
- Excludes signing files (`*.SF`, `*.DSA`, `*.RSA`) and `module-info.class`
|
||||
- Creates the **fat JAR**: `aniyomi-bridge/target/aniyomiBridge-1.0.0.jar`
|
||||
|
||||
> **Result:** A single ~90MB JAR containing your core code + the entire merged Android API + all dependencies.
|
||||
|
||||
@@ -181,7 +181,7 @@ $ mvn clean package
|
||||
package → maven-jar-plugin creates androidcompat-1.0.0.jar
|
||||
↳ contains merged Android API
|
||||
|
||||
[MODULE: aniyomibridge-core]
|
||||
[MODULE: aniyomi-bridge]
|
||||
validate → check pom
|
||||
...
|
||||
compile → compile core sources (can reference androidcompat classes)
|
||||
@@ -190,12 +190,12 @@ $ mvn clean package
|
||||
→ maven-shade-plugin creates fat JAR
|
||||
↳ unpacks androidcompat + all deps
|
||||
↳ filters: include ALL androidcompat, exclude Main.class
|
||||
↳ outputs aniyomibridge-core-1.0.0.jar (~90MB)
|
||||
↳ outputs aniyomiBridge-1.0.0.jar (~90MB)
|
||||
```
|
||||
|
||||
### Why This Order Matters
|
||||
|
||||
1. **`androidcompat` must build first** — `aniyomibridge-core` declares it as a dependency, so Maven enforces this order automatically.
|
||||
1. **`androidcompat` must build first** — `aniyomi-bridge` declares it as a dependency, so Maven enforces this order automatically.
|
||||
|
||||
2. **`generate-resources` must happen before `process-resources`** — You can't unpack the JAR until it exists. The download script creates it during `generate-resources`, then Ant unpacks it during `process-resources`.
|
||||
|
||||
@@ -203,7 +203,7 @@ $ mvn clean package
|
||||
|
||||
4. **Custom stubs overwrite SDK classes in `target/classes/`** — This is by design. The compiler writes to the same directory that Ant unpacked into, so your `Context.java` overwrites the SDK's `Context.class`.
|
||||
|
||||
5. **`minimizeJar` + filter is critical** — Without the shade filter for `androidcompat`, Maven Shade would see that `aniyomibridge-core` never directly references `android.graphics.Bitmap` (extensions load it via reflection/ClassLoader), so it would strip it out. The filter forces inclusion of the entire merged Android API.
|
||||
5. **`minimizeJar` + filter is critical** — Without the shade filter for `androidcompat`, Maven Shade would see that `aniyomi-bridge` never directly references `android.graphics.Bitmap` (extensions load it via reflection/ClassLoader), so it would strip it out. The filter forces inclusion of the entire merged Android API.
|
||||
|
||||
## Key Technologies
|
||||
|
||||
@@ -234,7 +234,7 @@ $ mvn clean package
|
||||
| `generate-new-core-mac.sh` | Build + copy JAR to Flutter app assets (Mac path) |
|
||||
| `androidcompat/scripts/get-android.sh` | Download and patch Android SDK JAR |
|
||||
|
||||
Both build scripts copy the output to a sibling project `k3vinb5_aniyomi_bridge/assets/`.
|
||||
Both build scripts copy the output to a sibling project `unyo-lib/assets/`.
|
||||
|
||||
## IntelliJ IDEA Setup
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
|
||||
<parent>
|
||||
<groupId>dev.k3vinb5</groupId>
|
||||
<artifactId>aniyomibridge-parent</artifactId>
|
||||
<artifactId>unyo-core</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>aniyomibridge-androidcompat</artifactId>
|
||||
<name>AniyomiBridge Android Compatibility</name>
|
||||
<artifactId>androidcompat</artifactId>
|
||||
<name>Unyo Core Android Compatibility</name>
|
||||
<description>Android compatibility layer for running Aniyomi extensions on desktop JVM</description>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
@@ -190,6 +190,12 @@
|
||||
<exclude name="resources.arsc"/>
|
||||
<exclude name="org/json/**"/>
|
||||
<exclude name="org/apache/http/**"/>
|
||||
<!-- Strip JDK duplicates (android.jar shadows the JVM bootstrap classes) -->
|
||||
<exclude name="java/**"/>
|
||||
<exclude name="javax/**"/>
|
||||
<exclude name="org/w3c/**"/>
|
||||
<exclude name="org/xml/**"/>
|
||||
<exclude name="dalvik/**"/>
|
||||
</patternset>
|
||||
</unzip>
|
||||
<echo message="android.jar unpacked successfully"/>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package android.app;
|
||||
|
||||
import android.content.ApplicationContext;
|
||||
import androidx.preference.PreferenceScreen;
|
||||
import android.content.ContextWrapper;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.SharedPreferencesImpl;
|
||||
import androidx.preference.PreferenceScreen;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class Application {
|
||||
public class Application extends ContextWrapper {
|
||||
|
||||
private static final Application INSTANCE = new Application();
|
||||
|
||||
@@ -18,20 +19,23 @@ public class Application {
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
private Application() {
|
||||
super(null);
|
||||
this.applicationContext = new ApplicationContext(this);
|
||||
attachBaseContext(applicationContext);
|
||||
}
|
||||
|
||||
public static Application getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Context getApplicationContext() {
|
||||
return applicationContext;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences getSharedPreferences(String name, int mode) {
|
||||
// Mode is ignored on modern Android; use name only to avoid collision bugs
|
||||
return prefs.computeIfAbsent(name, k -> new SharedPreferencesImpl(k));
|
||||
return prefs.computeIfAbsent(name, SharedPreferencesImpl::new);
|
||||
}
|
||||
|
||||
public PreferenceScreen getPreferenceScreen(Long id) {
|
||||
@@ -40,4 +44,7 @@ public class Application {
|
||||
return new PreferenceScreen(applicationContext, pref);
|
||||
});
|
||||
}
|
||||
|
||||
// getSharedPreferences is overridden here to use Application-level caching.
|
||||
// All other Context methods are inherited from ContextWrapper → ApplicationContext.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package android.content;
|
||||
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.content.res.Resources;
|
||||
import android.os.Looper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
|
||||
/**
|
||||
* Stub ContextWrapper for desktop JVM.
|
||||
*
|
||||
* On real Android, ContextWrapper extends Context and delegates all
|
||||
* method calls to a wrapped base Context. Application extends ContextWrapper.
|
||||
* This stub provides minimal delegation so the bytecode verifier accepts
|
||||
* Application-as-ContextWrapper casts.
|
||||
*/
|
||||
public class ContextWrapper extends Context {
|
||||
|
||||
private Context mBase;
|
||||
|
||||
public ContextWrapper() {
|
||||
}
|
||||
|
||||
public ContextWrapper(Context base) {
|
||||
this.mBase = base;
|
||||
}
|
||||
|
||||
protected void attachBaseContext(Context base) {
|
||||
if (mBase != null) {
|
||||
throw new IllegalStateException("Base context already set");
|
||||
}
|
||||
mBase = base;
|
||||
}
|
||||
|
||||
public Context getBaseContext() {
|
||||
return mBase;
|
||||
}
|
||||
|
||||
// ==================== Delegate to mBase ====================
|
||||
|
||||
@Override public AssetManager getAssets() { return mBase.getAssets(); }
|
||||
@Override public Resources getResources() { return mBase.getResources(); }
|
||||
@Override public PackageManager getPackageManager() { return mBase.getPackageManager(); }
|
||||
@Override public ContentResolver getContentResolver() { return mBase.getContentResolver(); }
|
||||
@Override public Looper getMainLooper() { return mBase.getMainLooper(); }
|
||||
@Override public void setTheme(int resid) { mBase.setTheme(resid); }
|
||||
@Override public Resources.Theme getTheme() { return mBase.getTheme(); }
|
||||
@Override public ClassLoader getClassLoader() { return mBase.getClassLoader(); }
|
||||
@Override public String getPackageName() { return mBase.getPackageName(); }
|
||||
@Override public ApplicationInfo getApplicationInfo() { return mBase.getApplicationInfo(); }
|
||||
@Override public String getPackageResourcePath() { return mBase.getPackageResourcePath(); }
|
||||
@Override public String getPackageCodePath() { return mBase.getPackageCodePath(); }
|
||||
@Override public SharedPreferences getSharedPreferences(String name, int mode) { return mBase.getSharedPreferences(name, mode); }
|
||||
@Override public boolean moveSharedPreferencesFrom(Context sourceContext, String name) { return mBase.moveSharedPreferencesFrom(sourceContext, name); }
|
||||
@Override public boolean deleteSharedPreferences(String name) { return mBase.deleteSharedPreferences(name); }
|
||||
@Override public FileInputStream openFileInput(String name) throws FileNotFoundException { return mBase.openFileInput(name); }
|
||||
@Override public FileOutputStream openFileOutput(String name, int mode) throws FileNotFoundException { return mBase.openFileOutput(name, mode); }
|
||||
@Override public boolean deleteFile(String name) { return mBase.deleteFile(name); }
|
||||
@Override public File getFileStreamPath(String name) { return mBase.getFileStreamPath(name); }
|
||||
@Override public File getDataDir() { return mBase.getDataDir(); }
|
||||
@Override public File getFilesDir() { return mBase.getFilesDir(); }
|
||||
@Override public File getNoBackupFilesDir() { return mBase.getNoBackupFilesDir(); }
|
||||
@Override public File getExternalFilesDir(String type) { return mBase.getExternalFilesDir(type); }
|
||||
@Override public File[] getExternalFilesDirs(String type) { return mBase.getExternalFilesDirs(type); }
|
||||
@Override public File getObbDir() { return mBase.getObbDir(); }
|
||||
@Override public File[] getObbDirs() { return mBase.getObbDirs(); }
|
||||
@Override public File getCacheDir() { return mBase.getCacheDir(); }
|
||||
@Override public File getCodeCacheDir() { return mBase.getCodeCacheDir(); }
|
||||
@Override public File getExternalCacheDir() { return mBase.getExternalCacheDir(); }
|
||||
@Override public File[] getExternalCacheDirs() { return mBase.getExternalCacheDirs(); }
|
||||
@Override public File[] getExternalMediaDirs() { return mBase.getExternalMediaDirs(); }
|
||||
@Override public String[] fileList() { return mBase.fileList(); }
|
||||
@Override public File getDir(String name, int mode) { return mBase.getDir(name, mode); }
|
||||
@Override public Object getSystemService(String name) { return mBase.getSystemService(name); }
|
||||
@Override public Context getApplicationContext() { return mBase.getApplicationContext(); }
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package android.content;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -129,6 +128,8 @@ public class SharedPreferencesImpl implements SharedPreferences {
|
||||
|
||||
private static class EditorImpl implements Editor {
|
||||
|
||||
private static final Object REMOVED = new Object();
|
||||
|
||||
private final SharedPreferencesImpl prefs;
|
||||
private final Map<String, Object> pendingChanges = new ConcurrentHashMap<>();
|
||||
private boolean clear = false;
|
||||
@@ -175,7 +176,7 @@ public class SharedPreferencesImpl implements SharedPreferences {
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
pendingChanges.put(key, null); // null means remove
|
||||
pendingChanges.put(key, REMOVED);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -201,7 +202,11 @@ public class SharedPreferencesImpl implements SharedPreferences {
|
||||
@Override
|
||||
public void apply() {
|
||||
applyInternal();
|
||||
// Preferences auto-flushes on JVM exit; synchronous apply is acceptable for desktop
|
||||
try {
|
||||
prefs.prefs.flush();
|
||||
} catch (BackingStoreException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyInternal() {
|
||||
@@ -219,7 +224,7 @@ public class SharedPreferencesImpl implements SharedPreferences {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
|
||||
if (value == null) {
|
||||
if (value == REMOVED) {
|
||||
prefs.prefs.remove(key);
|
||||
} else if (value instanceof String) {
|
||||
prefs.prefs.put(key, (String) value);
|
||||
|
||||
@@ -48,6 +48,9 @@ public class CookieManager {
|
||||
}
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a cookie for the given URL.
|
||||
* Accepts full Set-Cookie style strings (e.g. "name=value; Domain=...; Path=...; Secure; HttpOnly").
|
||||
|
||||
@@ -45,13 +45,85 @@ public class WebView {
|
||||
webClient.getOptions().setUseInsecureSSL(true); // Allow SSL errors
|
||||
|
||||
// Set timeouts
|
||||
webClient.getOptions().setTimeout(30000); // 30 seconds
|
||||
webClient.getOptions().setTimeout(20000); // 20 seconds
|
||||
|
||||
// Disable downloading of images and CSS for performance
|
||||
webClient.getOptions().setDownloadImages(false);
|
||||
|
||||
// Set cookie manager integration
|
||||
webClient.getCookieManager().setCookiesEnabled(true);
|
||||
|
||||
// Suppress harmless "Obsolete content type" warnings for application/javascript.
|
||||
// Cloudflare serves challenge scripts with the legacy MIME type; HtmlUnit
|
||||
// processes them correctly but logs a warning that clutters output.
|
||||
webClient.setIncorrectnessListener(new org.htmlunit.IncorrectnessListener() {
|
||||
@Override
|
||||
public void notify(String message, Object origin) {
|
||||
if (message != null && message.contains("Obsolete content type encountered")) {
|
||||
return; // silently ignore
|
||||
}
|
||||
// Delegate everything else to the default listener so we still
|
||||
// see genuine problems.
|
||||
new org.htmlunit.IncorrectnessListenerImpl().notify(message, origin);
|
||||
}
|
||||
});
|
||||
|
||||
// Install a script pre-processor that neutralises Web Workers.
|
||||
// Cloudflare's challenge sometimes spins up Workers; HtmlUnit's
|
||||
// DedicatedWorkerGlobalScope initialisation is buggy (throws
|
||||
// IllegalStateException on setImmunePrototypeProperty) which causes
|
||||
// the challenge to retry every ~30 s. By returning a lightweight
|
||||
// mock that immediately dispatches an 'error' event, the script
|
||||
// gets a clean failure signal and can fall back to main-thread
|
||||
// execution far quicker.
|
||||
webClient.setScriptPreProcessor(new org.htmlunit.ScriptPreProcessor() {
|
||||
@Override
|
||||
public String preProcess(
|
||||
org.htmlunit.html.HtmlPage page,
|
||||
String sourceCode,
|
||||
String sourceName,
|
||||
int lineNumber,
|
||||
org.htmlunit.html.HtmlElement htmlElement) {
|
||||
|
||||
// Only inject once per page (guard inside the JS itself).
|
||||
if (sourceCode == null || sourceCode.trim().isEmpty()) {
|
||||
return sourceCode;
|
||||
}
|
||||
String guard = "if(window.__workerMockInstalled){}";
|
||||
if (sourceCode.contains(guard)) {
|
||||
return sourceCode; // already injected
|
||||
}
|
||||
String workerMock =
|
||||
"(function(){" +
|
||||
" if(window.__workerMockInstalled)return;" +
|
||||
" window.__workerMockInstalled=true;" +
|
||||
" var RealWorker=window.Worker;" +
|
||||
" window.Worker=function Worker(url){" +
|
||||
" var self=this;" +
|
||||
" this.postMessage=function(){};" +
|
||||
" this.terminate=function(){};" +
|
||||
" this.addEventListener=function(type,listener){" +
|
||||
" if(type==='error'){" +
|
||||
" setTimeout(function(){" +
|
||||
" if(self.onerror){" +
|
||||
" try{self.onerror(new ErrorEvent('error',{message:'Worker blocked'}));}catch(e){}" +
|
||||
" }" +
|
||||
" },0);" +
|
||||
" }" +
|
||||
" };" +
|
||||
" this.removeEventListener=function(){};" +
|
||||
" setTimeout(function(){" +
|
||||
" if(self.onerror){" +
|
||||
" try{self.onerror(new ErrorEvent('error',{message:'Worker blocked'}));}catch(e){}" +
|
||||
" }" +
|
||||
" },10);" +
|
||||
" return this;" +
|
||||
" };" +
|
||||
" if(RealWorker)window.Worker.prototype=RealWorker.prototype;" +
|
||||
"})();";
|
||||
return workerMock + sourceCode;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +184,12 @@ public class WebView {
|
||||
Page page = webClient.getPage(webRequest);
|
||||
int statusCode = page.getWebResponse().getStatusCode();
|
||||
|
||||
// Wait for JavaScript to execute (only for HTML pages)
|
||||
// Wait for JavaScript to execute (only for HTML pages).
|
||||
// Cloudflare's challenge is CPU-heavy in Rhino; give it a bit of
|
||||
// headroom but keep it under the overall latch timeout so we don't
|
||||
// cut off the callback that delivers the cf_clearance cookie.
|
||||
if (page instanceof HtmlPage) {
|
||||
webClient.waitForBackgroundJavaScript(10000); // Wait up to 10 seconds
|
||||
webClient.waitForBackgroundJavaScript(18000); // Wait up to 18 seconds
|
||||
}
|
||||
|
||||
// Track the actual final URL after any redirects
|
||||
@@ -140,8 +215,8 @@ public class WebView {
|
||||
page.getWebResponse().getContentType() != null
|
||||
? page.getWebResponse().getContentType()
|
||||
: "text/html",
|
||||
page.getWebResponse().getContentCharsetOrNull() != null
|
||||
? page.getWebResponse().getContentCharsetOrNull().name()
|
||||
page.getWebResponse().getHeaderContentCharset() != null
|
||||
? page.getWebResponse().getHeaderContentCharset().name()
|
||||
: "UTF-8",
|
||||
statusCode,
|
||||
reasonPhrase,
|
||||
|
||||
@@ -19,29 +19,10 @@ public class PreferenceScreen {
|
||||
|
||||
public boolean addPreference(Preference preference) {
|
||||
listPreferences.add(preference);
|
||||
SharedPreferences.Editor editor = sharedPrefs.edit();
|
||||
if (preference instanceof SwitchPreferenceCompat switchPreference) {
|
||||
if (switchPreference.getDefaultValue() == null) {
|
||||
return false;
|
||||
}
|
||||
editor.putBoolean(switchPreference.getKey(), switchPreference.getDefaultValue());
|
||||
} else if (preference instanceof EditTextPreference editTextPreference) {
|
||||
if (editTextPreference.getDefaultValue() == null) {
|
||||
return false;
|
||||
}
|
||||
editor.putString(editTextPreference.getKey(), editTextPreference.getDefaultValue());
|
||||
} else if (preference instanceof ListPreference listPreference) {
|
||||
if (listPreference.getDefaultValue() == null) {
|
||||
return false;
|
||||
}
|
||||
editor.putString(listPreference.getKey(), listPreference.getDefaultValue());
|
||||
} else if (preference instanceof MultiSelectListPreference multiSelectListPreference) {
|
||||
if (multiSelectListPreference.getDefaultValue() == null) {
|
||||
return false;
|
||||
}
|
||||
editor.putStringSet(multiSelectListPreference.getKey(), multiSelectListPreference.getDefaultValue());
|
||||
}
|
||||
editor.apply();
|
||||
// Do NOT write defaults to SharedPreferences here.
|
||||
// On real Android, addPreference() only adds the widget to the UI hierarchy;
|
||||
// defaults are supplied at read-time via getBoolean/getString(key, defaultValue).
|
||||
// Writing defaults here would clobber any user-changed values on every extension reload.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -87,6 +68,10 @@ public class PreferenceScreen {
|
||||
return new ArrayList<>(listPreferences);
|
||||
}
|
||||
|
||||
public void clearPreferences() {
|
||||
listPreferences.clear();
|
||||
}
|
||||
|
||||
public Preference findPreference(String key) {
|
||||
for (Preference preference : listPreferences) {
|
||||
if (preference.getKey() != null && preference.getKey().equals(key)) {
|
||||
|
||||
@@ -6,19 +6,19 @@
|
||||
|
||||
<parent>
|
||||
<groupId>dev.k3vinb5</groupId>
|
||||
<artifactId>aniyomibridge-parent</artifactId>
|
||||
<artifactId>unyo-core</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>aniyomibridge-core</artifactId>
|
||||
<name>AniyomiBridge Core</name>
|
||||
<artifactId>aniyomiBridge</artifactId>
|
||||
<name>Aniyomi Bridge</name>
|
||||
<description>Core bridge module for loading and executing Aniyomi extensions</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- Android Compatibility Layer -->
|
||||
<dependency>
|
||||
<groupId>dev.k3vinb5</groupId>
|
||||
<artifactId>aniyomibridge-androidcompat</artifactId>
|
||||
<artifactId>androidcompat</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
@@ -94,6 +94,10 @@
|
||||
<groupId>net.bytebuddy</groupId>
|
||||
<artifactId>byte-buddy-agent</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.ow2.asm</groupId>
|
||||
<artifactId>asm</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>uy.kohesive.injekt</groupId>
|
||||
<artifactId>injekt-core</artifactId>
|
||||
@@ -181,70 +185,89 @@
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<minimizeJar>true</minimizeJar>
|
||||
<minimizeJar>false</minimizeJar>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>dev.k3vinb5.aniyomibridge.cli.Main</mainClass>
|
||||
<mainClass>dev.k3vinb5.unyo.core.cli.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<!-- Signing artifacts -->
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
<!-- JPMS -->
|
||||
<exclude>module-info.class</exclude>
|
||||
<exclude>META-INF/versions/*/module-info.class</exclude>
|
||||
<!-- Merged manifests from dependencies -->
|
||||
<exclude>META-INF/MANIFEST.MF</exclude>
|
||||
<!-- License/notice files (many variants) -->
|
||||
<exclude>META-INF/LICENSE</exclude>
|
||||
<exclude>META-INF/LICENSE.txt</exclude>
|
||||
<exclude>META-INF/NOTICE</exclude>
|
||||
<exclude>META-INF/NOTICE.txt</exclude>
|
||||
<exclude>META-INF/README.md</exclude>
|
||||
<exclude>META-INF/DEPENDENCIES</exclude>
|
||||
<!-- Dependency metadata (131+ files, useless at runtime) -->
|
||||
<exclude>META-INF/maven/**</exclude>
|
||||
<!-- GraalVM native-image config (9 files, not using native image) -->
|
||||
<exclude>META-INF/native-image/**</exclude>
|
||||
<!-- ProGuard/R8 keep rules (5 files, not shrinking at runtime) -->
|
||||
<exclude>META-INF/proguard/**</exclude>
|
||||
<!-- Kotlin module metadata (17 files, not needed for reflection) -->
|
||||
<exclude>META-INF/*.kotlin_module</exclude>
|
||||
<!-- R8 shrinker bundled inside dex2jar's d2j-external (~13.8MB unpacked) -->
|
||||
<exclude>com/android/tools/r8/**</exclude>
|
||||
<!-- ICU4J locale data pulled in by ANTLR4 (~22MB unpacked) -->
|
||||
<exclude>com/ibm/icu/impl/data/**</exclude>
|
||||
<!-- ANTLR4 Swing GUI (grammar workbench, ~86KB) -->
|
||||
<exclude>org/antlr/v4/gui/**</exclude>
|
||||
<!-- dex2jar/R8 API compatibility database (~1MB) -->
|
||||
<exclude>api_database/**</exclude>
|
||||
<!-- ByteBuddy Windows native DLLs (~0.5MB, irrelevant on Linux/Mac) -->
|
||||
<exclude>win32-x86/**</exclude>
|
||||
<exclude>win32-x86-64/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>${project.groupId}:${project.artifactId}</artifact>
|
||||
<excludes>
|
||||
<!-- CLI test classes are excluded from the fat JAR -->
|
||||
<exclude>dev/k3vinb5/aniyomibridge/cli/**</exclude>
|
||||
<exclude>dev/k3vinb5/unyo/core/cli/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>dev.k3vinb5:aniyomibridge-androidcompat</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<!--
|
||||
Force-include all Kotlin runtime classes.
|
||||
Extensions are loaded dynamically and reference internal Kotlin
|
||||
classes (e.g. SpillingKt) that our core code never directly
|
||||
imports. Without this, maven-shade-plugin strips them.
|
||||
The following force-include filters were needed when minimizeJar=true
|
||||
to prevent maven-shade-plugin from stripping classes loaded via
|
||||
reflection at runtime. With minimizeJar=false they are redundant
|
||||
but kept commented for reference.
|
||||
-->
|
||||
<!--
|
||||
<filter>
|
||||
<artifact>dev.k3vinb5:androidcompat</artifact>
|
||||
<includes><include>**</include></includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.jetbrains.kotlin:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
<includes><include>**</include></includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.jetbrains.kotlinx:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
<includes><include>**</include></includes>
|
||||
</filter>
|
||||
<!--
|
||||
Force-include org.json classes.
|
||||
Extensions dynamically use org.json.JSONObject and the Android SDK
|
||||
org/json stubs are excluded from android.jar unpack. Without this,
|
||||
maven-shade-plugin strips the real org.json implementation.
|
||||
-->
|
||||
<filter>
|
||||
<artifact>org.json:json</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
<includes><include>**</include></includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>commons-logging:commons-logging</artifact>
|
||||
<includes><include>**</include></includes>
|
||||
</filter>
|
||||
-->
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
@@ -1,13 +1,14 @@
|
||||
package dev.k3vinb5.aniyomibridge;
|
||||
package dev.k3vinb5.unyo.core;
|
||||
|
||||
import android.app.Application;
|
||||
import androidx.preference.PreferenceScreen;
|
||||
import dev.k3vinb5.aniyomibridge.exceptions.UnyoCoreException;
|
||||
import dev.k3vinb5.aniyomibridge.loader.ExtensionLoader;
|
||||
import dev.k3vinb5.aniyomibridge.loader.ExtensionPatches;
|
||||
import dev.k3vinb5.aniyomibridge.loader.JarInspector;
|
||||
import dev.k3vinb5.aniyomibridge.utils.ApkUtils;
|
||||
import dev.k3vinb5.aniyomibridge.utils.MediaSourceUtils;
|
||||
import dev.k3vinb5.unyo.core.exceptions.UnyoCoreException;
|
||||
import dev.k3vinb5.unyo.core.loader.ExtensionLoader;
|
||||
import dev.k3vinb5.unyo.core.loader.ExtensionPatches;
|
||||
import dev.k3vinb5.unyo.core.loader.BytecodeFixer;
|
||||
import dev.k3vinb5.unyo.core.loader.JarInspector;
|
||||
import dev.k3vinb5.unyo.core.utils.ApkUtils;
|
||||
import dev.k3vinb5.unyo.core.utils.MediaSourceUtils;
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource;
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime;
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode;
|
||||
@@ -46,7 +47,8 @@ public class AniyomiBridge {
|
||||
private static final Map<String, AnimeHttpSource> animeSources = new ConcurrentHashMap<>();
|
||||
private static final Map<String, CatalogueSource> mangaSources = new ConcurrentHashMap<>();
|
||||
public static final Map<String, ClassLoader> classLoaders = new ConcurrentHashMap<>();
|
||||
private static final Map<String, String> extensionJarPaths = new ConcurrentHashMap<>();
|
||||
private static final Map<String, String> animeExtensionJarPaths = new ConcurrentHashMap<>();
|
||||
private static final Map<String, String> mangaExtensionJarPaths = new ConcurrentHashMap<>();
|
||||
|
||||
private static String formatWithStackTrace(String prefix, Throwable e) {
|
||||
StringWriter sw = new StringWriter();
|
||||
@@ -54,6 +56,17 @@ public class AniyomiBridge {
|
||||
return prefix + "\n" + sw.toString();
|
||||
}
|
||||
|
||||
static {
|
||||
// Set a predictable root for java.util.prefs so extension preferences
|
||||
// are persisted under ~/.aniyomibridge by default.
|
||||
// Flutter can override this by setting the property before any
|
||||
// SharedPreferencesImpl is instantiated.
|
||||
if (System.getProperty("java.util.prefs.userRoot") == null) {
|
||||
System.setProperty("java.util.prefs.userRoot",
|
||||
System.getProperty("user.home") + File.separator + ".aniyomibridge");
|
||||
}
|
||||
}
|
||||
|
||||
public static void init() {
|
||||
// TODO - search which module I should include in my custom JRE so ByteBuddy can run and apply its patches
|
||||
ExtensionPatches.installByteBuddyAgent();
|
||||
@@ -65,7 +78,8 @@ public class AniyomiBridge {
|
||||
File jarFile = ApkUtils.dex2Jar(extensionUrl, getExtensionFileName(extensionUrl), basePath);
|
||||
try {
|
||||
assert jarFile != null;
|
||||
extensionJarPaths.put(packageName, jarFile.getPath());
|
||||
animeExtensionJarPaths.put(packageName, jarFile.getPath());
|
||||
BytecodeFixer.fixJar(jarFile);
|
||||
String className = JarInspector.findMainClass(jarFile.getPath(), AnimeHttpSource.class);
|
||||
if (className == null || className.isEmpty()) {
|
||||
className = JarInspector.getClassName(jarFile.getPath(), extensionName);
|
||||
@@ -78,6 +92,7 @@ public class AniyomiBridge {
|
||||
classLoaders.put(packageName, response.getSecond());
|
||||
if (animeSource instanceof ConfigurableAnimeSource configurableAnimeSource) {
|
||||
PreferenceScreen screen = APP_INSTANCE.getPreferenceScreen(configurableAnimeSource.getId());
|
||||
screen.clearPreferences();
|
||||
configurableAnimeSource.setupPreferenceScreen(screen);
|
||||
}
|
||||
LOGGER.info("Loaded Anime extension: " + animeSource.getClass().getName());
|
||||
@@ -100,7 +115,7 @@ public class AniyomiBridge {
|
||||
LOGGER.warnf("Failed to close classloader for %s: %s", packageName, ioe.getMessage());
|
||||
}
|
||||
}
|
||||
String jarPath = extensionJarPaths.get(packageName);
|
||||
String jarPath = animeExtensionJarPaths.get(packageName);
|
||||
if (jarPath != null) {
|
||||
File jarFile = new File(jarPath);
|
||||
if (jarFile.exists()) {
|
||||
@@ -109,7 +124,7 @@ public class AniyomiBridge {
|
||||
LOGGER.warnf("Failed to delete JAR file: %s", jarPath);
|
||||
}
|
||||
}
|
||||
extensionJarPaths.remove(packageName);
|
||||
animeExtensionJarPaths.remove(packageName);
|
||||
}
|
||||
animeSources.remove(packageName);
|
||||
classLoaders.remove(packageName);
|
||||
@@ -125,7 +140,8 @@ public class AniyomiBridge {
|
||||
File jarFile = ApkUtils.dex2Jar(extensionUrl, getExtensionFileName(extensionUrl), basePath);
|
||||
try {
|
||||
assert jarFile != null;
|
||||
extensionJarPaths.put(packageName, jarFile.getPath());
|
||||
mangaExtensionJarPaths.put(packageName, jarFile.getPath());
|
||||
BytecodeFixer.fixJar(jarFile);
|
||||
String className = JarInspector.findMainClass(jarFile.getPath(), CatalogueSource.class);
|
||||
if (className == null || className.isEmpty()) {
|
||||
className = JarInspector.getClassName(jarFile.getPath(), extensionName);
|
||||
@@ -138,6 +154,7 @@ public class AniyomiBridge {
|
||||
classLoaders.put(packageName, response.getSecond());
|
||||
if (mangaSource instanceof ConfigurableSource configurableSource) {
|
||||
PreferenceScreen screen = APP_INSTANCE.getPreferenceScreen(configurableSource.getId());
|
||||
screen.clearPreferences();
|
||||
configurableSource.setupPreferenceScreen(screen);
|
||||
}
|
||||
LOGGER.info("Loaded Manga extension: " + mangaSource.getClass().getName());
|
||||
@@ -160,7 +177,7 @@ public class AniyomiBridge {
|
||||
LOGGER.warnf("Failed to close classloader for %s: %s", packageName, ioe.getMessage());
|
||||
}
|
||||
}
|
||||
String jarPath = extensionJarPaths.get(packageName);
|
||||
String jarPath = mangaExtensionJarPaths.get(packageName);
|
||||
if (jarPath != null) {
|
||||
File jarFile = new File(jarPath);
|
||||
if (jarFile.exists()) {
|
||||
@@ -169,7 +186,7 @@ public class AniyomiBridge {
|
||||
LOGGER.warnf("Failed to delete JAR file: %s", jarPath);
|
||||
}
|
||||
}
|
||||
extensionJarPaths.remove(packageName);
|
||||
mangaExtensionJarPaths.remove(packageName);
|
||||
}
|
||||
mangaSources.remove(packageName);
|
||||
classLoaders.remove(packageName);
|
||||
@@ -188,6 +205,16 @@ public class AniyomiBridge {
|
||||
return mangaSources.keySet().stream().toList();
|
||||
}
|
||||
|
||||
// ==================== INSTALLED EXTENSIONS API ====================
|
||||
|
||||
public Map<String, String> getInstalledAnimeExtensions() {
|
||||
return new HashMap<>(animeExtensionJarPaths);
|
||||
}
|
||||
|
||||
public Map<String, String> getInstalledMangaExtensions() {
|
||||
return new HashMap<>(mangaExtensionJarPaths);
|
||||
}
|
||||
|
||||
// ==================== PREFERENCES API ====================
|
||||
|
||||
public List<PreferenceItem> getAnimePreferences(String packageName) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge;
|
||||
package dev.k3vinb5.unyo.core;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -1,8 +1,8 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.k3vinb5.aniyomibridge.AniyomiBridge;
|
||||
import dev.k3vinb5.unyo.core.AniyomiBridge;
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime;
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode;
|
||||
import eu.kanade.tachiyomi.animesource.model.Video;
|
||||
@@ -26,7 +26,7 @@ import java.util.stream.Collectors;
|
||||
* Batch tests all anime extensions from configured repos.
|
||||
*
|
||||
* Usage:
|
||||
* java -cp ... dev.k3vinb5.aniyomibridge.cli.ExtensionBatchTester [options]
|
||||
* java -cp ... dev.k3vinb5.unyo.core.cli.ExtensionBatchTester [options]
|
||||
*
|
||||
* Options:
|
||||
* --concurrency N Thread pool size (default: CPU cores)
|
||||
@@ -1,11 +1,11 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.k3vinb5.aniyomibridge.AniyomiBridge;
|
||||
import dev.k3vinb5.aniyomibridge.loader.ExtensionLoader;
|
||||
import dev.k3vinb5.aniyomibridge.loader.JarInspector;
|
||||
import dev.k3vinb5.aniyomibridge.utils.ApkUtils;
|
||||
import dev.k3vinb5.unyo.core.AniyomiBridge;
|
||||
import dev.k3vinb5.unyo.core.loader.ExtensionLoader;
|
||||
import dev.k3vinb5.unyo.core.loader.JarInspector;
|
||||
import dev.k3vinb5.unyo.core.utils.ApkUtils;
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
@@ -23,7 +23,7 @@ import java.util.List;
|
||||
* 2. torrentioanime - known broken, getClassName fails, findMainClass should fix
|
||||
*
|
||||
* Usage:
|
||||
* java -cp ... dev.k3vinb5.aniyomibridge.cli.JarInspectorTest
|
||||
* java -cp ... dev.k3vinb5.unyo.core.cli.JarInspectorTest
|
||||
*/
|
||||
public class JarInspectorTest {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.k3vinb5.aniyomibridge.AniyomiBridge;
|
||||
import dev.k3vinb5.aniyomibridge.PreferenceItem;
|
||||
import dev.k3vinb5.unyo.core.AniyomiBridge;
|
||||
import dev.k3vinb5.unyo.core.PreferenceItem;
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime;
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode;
|
||||
import eu.kanade.tachiyomi.animesource.model.Video;
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
/**
|
||||
* Configuration for an extension repository.
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.cli;
|
||||
package dev.k3vinb5.unyo.core.cli;
|
||||
|
||||
/**
|
||||
* Gson model for a source within an extension entry.
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.exceptions;
|
||||
package dev.k3vinb5.unyo.core.exceptions;
|
||||
|
||||
public class UnyoCoreException extends RuntimeException {
|
||||
public UnyoCoreException(String message) {
|
||||
@@ -0,0 +1,142 @@
|
||||
package dev.k3vinb5.unyo.core.loader;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassVisitor;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.jar.*;
|
||||
|
||||
public class BytecodeFixer {
|
||||
private static final Logger LOGGER = Logger.getLogger(BytecodeFixer.class);
|
||||
|
||||
/**
|
||||
* Post-processes a dex2jar-converted JAR to fix bytecode issues:
|
||||
* 1. Upgrades class version from Java 6 (50) to Java 11 (55)
|
||||
* 2. Computes StackMapTable frames (missing from dex2jar output)
|
||||
*
|
||||
* dex2jar does not generate StackMapTable attributes, which are required
|
||||
* by the JVM verifier and JIT compiler for methods with branching.
|
||||
* ASM's COMPUTE_FRAMES auto-generates them.
|
||||
*/
|
||||
public static void fixJar(File jarFile) {
|
||||
File tempFile = new File(jarFile.getAbsolutePath() + ".fixing");
|
||||
int fixedCount = 0;
|
||||
int totalClasses = 0;
|
||||
|
||||
try (JarInputStream jis = new JarInputStream(new FileInputStream(jarFile));
|
||||
JarOutputStream jos = new JarOutputStream(new FileOutputStream(tempFile))) {
|
||||
|
||||
JarEntry entry;
|
||||
while ((entry = jis.getNextJarEntry()) != null) {
|
||||
byte[] entryBytes = jis.readAllBytes();
|
||||
String name = entry.getName();
|
||||
|
||||
if (name.endsWith(".class") && !name.equals("module-info.class")) {
|
||||
totalClasses++;
|
||||
try {
|
||||
byte[] fixedBytes = fixClassBytes(entryBytes);
|
||||
if (fixedBytes != entryBytes) {
|
||||
fixedCount++;
|
||||
}
|
||||
entryBytes = fixedBytes;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warnf("Failed to fix class %s: %s", name, e.getMessage());
|
||||
// Keep original bytes
|
||||
}
|
||||
}
|
||||
|
||||
JarEntry newEntry = new JarEntry(name);
|
||||
newEntry.setTime(entry.getTime());
|
||||
jos.putNextEntry(newEntry);
|
||||
jos.write(entryBytes);
|
||||
jos.closeEntry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.errorf(e, "Failed to fix JAR: %s", jarFile.getAbsolutePath());
|
||||
tempFile.delete();
|
||||
throw new RuntimeException("Failed to fix JAR bytecode: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// Replace original with fixed
|
||||
if (!jarFile.delete()) {
|
||||
tempFile.delete();
|
||||
throw new RuntimeException("Failed to delete original JAR: " + jarFile.getAbsolutePath());
|
||||
}
|
||||
if (!tempFile.renameTo(jarFile)) {
|
||||
throw new RuntimeException("Failed to rename fixed JAR to: " + jarFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
LOGGER.infof("BytecodeFixer: processed %d classes, fixed %d in %s", totalClasses, fixedCount, jarFile.getName());
|
||||
}
|
||||
|
||||
static byte[] fixClassBytes(byte[] classBytes) {
|
||||
int version = ((classBytes[6] & 0xFF) << 8 | (classBytes[7] & 0xFF));
|
||||
boolean needsVersionUpgrade = version < 51; // 50 = Java 6, StackMapTable is optional
|
||||
boolean needsFrames = hasBranches(classBytes);
|
||||
|
||||
if (!needsVersionUpgrade && !needsFrames) {
|
||||
return classBytes; // Nothing to fix
|
||||
}
|
||||
|
||||
ClassReader reader = new ClassReader(classBytes);
|
||||
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES) {
|
||||
@Override
|
||||
protected String getCommonSuperClass(String type1, String type2) {
|
||||
if (type1.equals(type2)) return type1;
|
||||
try {
|
||||
return super.getCommonSuperClass(type1, type2);
|
||||
} catch (RuntimeException e) {
|
||||
return "java/lang/Object";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
final int targetVersion = Math.max(version, Opcodes.V11);
|
||||
|
||||
ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM9, writer) {
|
||||
@Override
|
||||
public void visit(int v, int access, String name, String signature,
|
||||
String superName, String[] interfaces) {
|
||||
super.visit(targetVersion, access, name, signature, superName, interfaces);
|
||||
}
|
||||
};
|
||||
|
||||
reader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
|
||||
return writer.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check whether a class has any branch instructions (ifeq, goto, etc.)
|
||||
* that would require StackMapTable frames.
|
||||
*/
|
||||
private static boolean hasBranches(byte[] classBytes) {
|
||||
final boolean[] found = {false};
|
||||
ClassReader reader = new ClassReader(classBytes);
|
||||
reader.accept(new ClassVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public org.objectweb.asm.MethodVisitor visitMethod(
|
||||
int access, String name, String desc, String sig, String[] ex) {
|
||||
return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public void visitJumpInsn(int opcode, org.objectweb.asm.Label label) {
|
||||
found[0] = true;
|
||||
}
|
||||
@Override
|
||||
public void visitLookupSwitchInsn(
|
||||
org.objectweb.asm.Label dflt, int[] keys, org.objectweb.asm.Label[] labels) {
|
||||
found[0] = true;
|
||||
}
|
||||
@Override
|
||||
public void visitTableSwitchInsn(
|
||||
int min, int max, org.objectweb.asm.Label dflt, org.objectweb.asm.Label... labels) {
|
||||
found[0] = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
|
||||
return found[0];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package dev.k3vinb5.aniyomibridge.loader;
|
||||
package dev.k3vinb5.unyo.core.loader;
|
||||
|
||||
import dev.k3vinb5.aniyomibridge.AniyomiBridge;
|
||||
import dev.k3vinb5.unyo.core.AniyomiBridge;
|
||||
import kotlin.Pair;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package dev.k3vinb5.aniyomibridge.loader;
|
||||
package dev.k3vinb5.unyo.core.loader;
|
||||
|
||||
import dev.k3vinb5.aniyomibridge.loader.interceptors.TorrentioAnimeInteceptor;
|
||||
import net.bytebuddy.ByteBuddy;
|
||||
import net.bytebuddy.agent.ByteBuddyAgent;
|
||||
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy;
|
||||
@@ -18,18 +17,6 @@ public class ExtensionPatches {
|
||||
public static void applyExtensionPatchesToJVM(Class<?> clazz, URLClassLoader classLoader) {
|
||||
try {
|
||||
switch (clazz.getName()) {
|
||||
case "eu.kanade.tachiyomi.animeextension.all.torrentioanime.Torrentio":
|
||||
LOGGER.infof("Applying Torrentio.makeGraphQLRequest patch to JVM");
|
||||
BYTE_BUDDY
|
||||
.redefine(clazz)
|
||||
.method(ElementMatchers.nameStartsWith("makeGraphQLRequest")
|
||||
.and(ElementMatchers.not(ElementMatchers.isSynthetic())))
|
||||
.intercept(MethodDelegation.to(TorrentioAnimeInteceptor.GraphQLRequestInterceptor.class))
|
||||
.make()
|
||||
.load(classLoader,
|
||||
ClassReloadingStrategy.fromInstalledAgent());
|
||||
break;
|
||||
|
||||
default:
|
||||
LOGGER.warnf("No patches defined for class: %s", clazz.getName());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.loader;
|
||||
package dev.k3vinb5.unyo.core.loader;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.loader;
|
||||
package dev.k3vinb5.unyo.core.loader;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.utils;
|
||||
package dev.k3vinb5.unyo.core.utils;
|
||||
|
||||
import com.googlecode.d2j.Method;
|
||||
import com.googlecode.d2j.dex.Dex2jar;
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.k3vinb5.aniyomibridge.utils
|
||||
package dev.k3vinb5.unyo.core.utils
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.*
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||
@@ -22,9 +22,9 @@ class NetworkHelper(
|
||||
private val clientBuilder: OkHttpClient.Builder = run {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.callTimeout(5, TimeUnit.MINUTES)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(40, TimeUnit.SECONDS)
|
||||
.dispatcher(Dispatcher().apply { maxRequestsPerHost = 10 })
|
||||
.cache(
|
||||
Cache(
|
||||
@@ -0,0 +1,132 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import eu.kanade.tachiyomi.network.DesktopCookieJar
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
/**
|
||||
* Persistent cache for Cloudflare clearance cookies.
|
||||
*
|
||||
* When a Cloudflare challenge is solved via WebView the resulting cookies
|
||||
* (especially **cf_clearance**) are stored here keyed by domain.
|
||||
* On subsequent requests the cached cookies are injected into the
|
||||
* [DesktopCookieJar] before attempting the WebView challenge, which
|
||||
* allows the request to sail through when the cookie is still valid
|
||||
* (~24 h on Cloudflare’s side, we conservatively cache for 12 h).
|
||||
*/
|
||||
object CloudflareCookieCache {
|
||||
|
||||
private const val PREFS_NAME = "cloudflare_cookie_cache"
|
||||
private const val KEY_PREFIX = "cookies_"
|
||||
|
||||
/** Max age in milliseconds — Cloudflare usually honours cf_clearance for ~24 h. */
|
||||
private const val MAX_CACHE_AGE_MS = 12 * 60 * 60 * 1000L // 12 hours
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached cookies for [domain] if they exist and are not stale.
|
||||
*/
|
||||
fun get(domain: String): List<CachedCookie>? {
|
||||
val raw = prefs.getString(KEY_PREFIX + domain, null) ?: return null
|
||||
val entry = try {
|
||||
json.decodeFromString<CacheEntry>(raw)
|
||||
} catch (_: Exception) {
|
||||
prefs.edit().remove(KEY_PREFIX + domain).apply()
|
||||
return null
|
||||
}
|
||||
|
||||
val age = System.currentTimeMillis() - entry.timestamp
|
||||
if (age > MAX_CACHE_AGE_MS) {
|
||||
prefs.edit().remove(KEY_PREFIX + domain).apply()
|
||||
return null
|
||||
}
|
||||
|
||||
return entry.cookies
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores cookies for [domain] replacing any previous entry.
|
||||
*/
|
||||
fun put(domain: String, cookies: List<Cookie>) {
|
||||
val entry = CacheEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
cookies = cookies.map { CachedCookie.fromOkHttp(it) }
|
||||
)
|
||||
prefs.edit().putString(KEY_PREFIX + domain, json.encodeToString(entry)).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects cached cookies into [cookieJar] for [url].
|
||||
* Returns **true** if cookies were injected.
|
||||
*/
|
||||
fun injectIntoJar(cookieJar: DesktopCookieJar, url: String): Boolean {
|
||||
val domain = url.toHttpUrl().host
|
||||
val cached = get(domain) ?: return false
|
||||
|
||||
val fakeUrl = "https://$domain/".toHttpUrl()
|
||||
val cookies = cached.mapNotNull { it.toOkHttp(fakeUrl) }
|
||||
if (cookies.isEmpty()) return false
|
||||
|
||||
cookieJar.saveFromResponse(fakeUrl, cookies)
|
||||
return true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CacheEntry(
|
||||
val timestamp: Long,
|
||||
val cookies: List<CachedCookie>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CachedCookie(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val domain: String? = null,
|
||||
val path: String? = null,
|
||||
val maxAge: Long = -1,
|
||||
val secure: Boolean = false,
|
||||
val httpOnly: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
fun fromOkHttp(c: Cookie) = CachedCookie(
|
||||
name = c.name,
|
||||
value = c.value,
|
||||
domain = c.domain,
|
||||
path = c.path,
|
||||
maxAge = c.expiresAt,
|
||||
secure = c.secure,
|
||||
httpOnly = c.httpOnly,
|
||||
)
|
||||
}
|
||||
|
||||
fun toOkHttp(url: okhttp3.HttpUrl): Cookie? {
|
||||
val builder = Cookie.Builder()
|
||||
.name(name)
|
||||
.value(value)
|
||||
.path(path ?: "/")
|
||||
|
||||
domain?.let { builder.domain(it) }
|
||||
if (secure) builder.secure()
|
||||
if (httpOnly) builder.httpOnly()
|
||||
if (maxAge > 0) builder.expiresAt(maxAge)
|
||||
|
||||
return try {
|
||||
builder.build()
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,29 @@ class CloudflareInterceptor(
|
||||
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
||||
val oldCookie = cookieManager.get(request.url)
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
|
||||
// Fast path: try cached clearance cookies before burning time on WebView
|
||||
val cachedInjected = CloudflareCookieCache.injectIntoJar(cookieManager, request.url.toString())
|
||||
if (cachedInjected) {
|
||||
val cachedResponse = chain.proceed(request)
|
||||
if (!shouldIntercept(cachedResponse)) {
|
||||
// Cache hit — no challenge, return immediately
|
||||
return cachedResponse
|
||||
}
|
||||
// Cookie expired or rejected, close and fall through to WebView
|
||||
cachedResponse.close()
|
||||
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
||||
}
|
||||
|
||||
resolveWithWebView(request, oldCookie)
|
||||
|
||||
// Persist the freshly-obtained cookies so the next request is fast
|
||||
val freshCookies = cookieManager.get(request.url)
|
||||
.filter { it.name == "cf_clearance" || it.name.startsWith("cf_") }
|
||||
if (freshCookies.isNotEmpty()) {
|
||||
CloudflareCookieCache.put(request.url.host, freshCookies)
|
||||
}
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
@@ -126,7 +147,7 @@ class CloudflareInterceptor(
|
||||
webview?.loadUrl(origRequestUrl, headers)
|
||||
}
|
||||
|
||||
latch.awaitFor30Seconds()
|
||||
latch.awaitFor20Seconds()
|
||||
|
||||
executor.execute {
|
||||
if (!cloudflareBypassed) {
|
||||
@@ -78,8 +78,8 @@ abstract class WebViewInterceptor(
|
||||
.mapValues { it.value.getOrNull(0).orEmpty() }
|
||||
}
|
||||
|
||||
fun CountDownLatch.awaitFor30Seconds() {
|
||||
await(30, TimeUnit.SECONDS)
|
||||
fun CountDownLatch.awaitFor20Seconds() {
|
||||
await(20, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
fun createWebView(request: Request): WebView {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user