diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62744cd..5f36906 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,7 @@ android { } dependencies { + implementation(project(":js")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -96,7 +97,6 @@ dependencies { implementation(libs.coil.video) implementation(libs.ksoup.lite) implementation(libs.accompanist.permissions) - implementation(libs.rhino) implementation(libs.androidx.javascriptengine) implementation(libs.androidx.concurrent.futures.ktx) implementation(libs.jsoup) diff --git a/app/src/main/java/com/paulcoding/hviewer/MainApp.kt b/app/src/main/java/com/paulcoding/hviewer/MainApp.kt index 495f3d4..503025b 100644 --- a/app/src/main/java/com/paulcoding/hviewer/MainApp.kt +++ b/app/src/main/java/com/paulcoding/hviewer/MainApp.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import com.paulcoding.hviewer.helper.CrashHandler import com.paulcoding.hviewer.helper.setupPaths +import com.paulcoding.js.JS import com.tencent.mmkv.MMKV class MainApp : Application() { @@ -12,6 +13,7 @@ class MainApp : Application() { super.onCreate() appContext = this MMKV.initialize(this) + JS.initialize(this) setupPaths() } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostViewModel.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostViewModel.kt index 0eb9dc4..5c9d388 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostViewModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostViewModel.kt @@ -3,9 +3,10 @@ package com.paulcoding.hviewer.ui.page.post import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.paulcoding.hviewer.js.JS +import com.paulcoding.hviewer.helper.SCRIPTS_DIR import com.paulcoding.hviewer.model.PostData import com.paulcoding.hviewer.model.SiteConfig +import com.paulcoding.js.JS import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -15,7 +16,10 @@ class PostViewModel(private val postUrl: String, siteConfig: SiteConfig) : ViewM private var _stateFlow = MutableStateFlow(UiState()) val stateFlow = _stateFlow.asStateFlow() - private val js = JS(siteConfig) + private val js = JS( + fileRelativePath = SCRIPTS_DIR + "/${siteConfig.scriptFile}", + properties = mapOf("baseUrl" to siteConfig.baseUrl) + ) data class UiState( val images: List = listOf(), diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsViewModel.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsViewModel.kt index 400f307..efc6d1c 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsViewModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsViewModel.kt @@ -3,11 +3,12 @@ package com.paulcoding.hviewer.ui.page.posts import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.paulcoding.hviewer.js.JS +import com.paulcoding.hviewer.helper.SCRIPTS_DIR import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.Posts import com.paulcoding.hviewer.model.SiteConfig import com.paulcoding.hviewer.model.Tag +import com.paulcoding.js.JS import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -18,7 +19,10 @@ class PostsViewModel(siteConfig: SiteConfig, tag: Tag) : ViewModel() { private var _stateFlow = MutableStateFlow(UiState()) val stateFlow = _stateFlow.asStateFlow() - private val js = JS(siteConfig) + private val js = JS( + fileRelativePath = SCRIPTS_DIR + "/${siteConfig.scriptFile}", + properties = mapOf("baseUrl" to siteConfig.baseUrl) + ) data class UiState( val postItems: List = listOf(), diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchViewModel.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchViewModel.kt index ebe7b3f..80f5755 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchViewModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchViewModel.kt @@ -3,10 +3,11 @@ package com.paulcoding.hviewer.ui.page.search import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.paulcoding.hviewer.js.JS +import com.paulcoding.hviewer.helper.SCRIPTS_DIR import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.Posts import com.paulcoding.hviewer.model.SiteConfig +import com.paulcoding.js.JS import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -16,7 +17,10 @@ class SearchViewModel(siteConfig: SiteConfig) : ViewModel() { private var _stateFlow = MutableStateFlow(UiState()) val stateFlow = _stateFlow.asStateFlow() - private val js = JS(siteConfig) + private val js = JS( + fileRelativePath = SCRIPTS_DIR + "/${siteConfig.scriptFile}", + properties = mapOf("baseUrl" to siteConfig.baseUrl) + ) data class UiState( val postItems: List = listOf(), diff --git a/build.gradle.kts b/build.gradle.kts index b162aa1..b2a61c1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,5 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false + alias(libs.plugins.android.library) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 957aa1e..dc7cb93 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,8 @@ roomRuntime = "2.6.1" webkit = "1.13.0-alpha02" zoomable = "0.14.0" material3 = "1.3.1" +appcompat = "1.7.0" +material = "1.12.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -80,9 +82,12 @@ lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = " mmkv = { module = "com.tencent:mmkv", version.ref = "mmkv" } rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } zoomable = { module = "me.saket.telephoto:zoomable", version.ref = "zoomable" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 0000000..c81b89a --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,4 @@ +jdk: + - openjdk17 +install: + - ./gradlew :js:publishReleasePublicationToMavenLocal diff --git a/js/.gitignore b/js/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/js/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/js/README.md b/js/README.md new file mode 100644 index 0000000..f093522 --- /dev/null +++ b/js/README.md @@ -0,0 +1,142 @@ +# Android JavaScript Library + +A Kotlin-based Android library that provides seamless JavaScript execution and integration capabilities using Mozilla's Rhino engine. + +## Features + +- Execute JavaScript code from strings or files +- Built-in functions for network requests (fetch, xhr) +- Base64 encoding/decoding support +- Console logging +- File importing capability +- JSON conversion utilities +- Coroutine support for asynchronous operations + +## Installation + +Add the following dependency to your app's `build.gradle.kts`: + +```kotlin +dependencies { + implementation("com.paulcoding:js:1.0.0") // Replace with actual version +} +``` + +## Initialization + +Initialize the library in your Application class: + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + JS.initialize(applicationContext) + } +} +``` + +## Usage + +### Basic JavaScript Execution + +```kotlin +// Create JS instance +val js = JS() + +// Execute JavaScript string +suspend fun executeJs() { + js.evaluateString("'Hello, World!'") + .onSuccess { result -> + println(result) // Prints: Hello, World! + } + .onFailure { error -> + error.printStackTrace() + } +} + +// Call JavaScript function +suspend fun callJsFunction() { + js.callFunction("add", arrayOf(2, 3)) + .onSuccess { result -> + println(result) // Prints: 5 + } +} +``` + +### Loading JavaScript from File + +```kotlin +// Load from file in app's files directory +val js = JS("scripts", "main.js") + +// Or with full path +val js = JS(File("/path/to/script.js")) +``` + +### Built-in Functions + +The library provides several built-in JavaScript functions: + +#### fetch() +```javascript +const html = fetch('https://example.com') +``` + +#### xhr() +```javascript +const data = xhr('https://api.example.com/data') +``` + +#### import() +```javascript +import('utils.js') +``` + +#### console.log() +```javascript +console.log('Debug message') +``` + +#### atob() +```javascript +const decoded = atob('SGVsbG8gV29ybGQ=') +``` + +### Custom Properties + +You can pass custom properties to the JavaScript environment: + +```kotlin +val properties = mapOf( + "apiKey" to "your-api-key", + "baseUrl" to "https://api.example.com" +) +val js = JS(properties = properties) +``` + +## Dependencies + +- [Rhino](https://github.com/mozilla/rhino): JavaScript engine +- [Ktor](https://ktor.io/): HTTP client +- [Gson](https://github.com/google/gson): JSON parsing +- [JSoup](https://jsoup.org/): HTML parsing + +## Proguard + +``` +-keepattributes Signature +-dontwarn org.mozilla.javascript.** +-keep class org.mozilla.javascript.** { *; } +-keep class org.jsoup.** { *; } +-dontwarn org.jspecify.annotations.NullMarked +``` + +## Requirements + +- Minimum SDK: 24 +- Kotlin: 1.8+ +- Java: 11 + +## License + +[License](LICIENSE) diff --git a/js/build.gradle.kts b/js/build.gradle.kts new file mode 100644 index 0000000..e3d009d --- /dev/null +++ b/js/build.gradle.kts @@ -0,0 +1,92 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + `maven-publish` +} + +android { + namespace = "com.paulcoding.js" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + implementation(libs.rhino) + implementation(libs.jsoup) + implementation(libs.gson) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.android) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.serialization.gson) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} + +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + + groupId = "com.github.paulcoding810" + artifactId = "android-js" + version = "1.0.0" + + pom { + name.set("android-js") + description.set("Rhino wrapper for android kotlin") + url.set("https://github.com/paulcoding810/h-viewer") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set("paulcoding810") + name.set("Paul Nguyen") + email.set("paulcoding810@gmail.com") + } + } + } + } + } + } +} diff --git a/js/consumer-rules.pro b/js/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/js/proguard-rules.pro b/js/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/js/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/js/src/androidTest/java/com/paulcoding/js/ExampleInstrumentedTest.kt b/js/src/androidTest/java/com/paulcoding/js/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..2b675a4 --- /dev/null +++ b/js/src/androidTest/java/com/paulcoding/js/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.paulcoding.js + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.paulcoding.js.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/js/src/main/AndroidManifest.xml b/js/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/js/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/js/JS.kt b/js/src/main/java/com/paulcoding/js/JS.kt similarity index 76% rename from app/src/main/java/com/paulcoding/hviewer/js/JS.kt rename to js/src/main/java/com/paulcoding/js/JS.kt index 9f64976..c738444 100644 --- a/app/src/main/java/com/paulcoding/hviewer/js/JS.kt +++ b/js/src/main/java/com/paulcoding/js/JS.kt @@ -1,12 +1,8 @@ -package com.paulcoding.hviewer.js +package com.paulcoding.js import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.reflect.TypeToken -import com.paulcoding.hviewer.MainApp.Companion.appContext -import com.paulcoding.hviewer.helper.scriptsDir -import com.paulcoding.hviewer.model.SiteConfig -import com.tencent.mmkv.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.mozilla.javascript.Context @@ -33,13 +29,21 @@ fun toJsonElement(jsObject: Any?, gson: Gson = Gson()): JsonElement { } } -class JS(siteConfig: SiteConfig? = null) { +class JS(file: File, properties: Map? = null) { var scope: ScriptableObject val gson = Gson() + constructor(rootName: String, fileName: String, properties: Map? = null) : this( + File( + appContext.filesDir.absolutePath + "/$rootName", fileName + ), properties + ) + + constructor(fileRelativePath: String, properties: Map? = null) : this(File(appContext.filesDir.absolutePath, fileRelativePath), properties) + fun prepareContext(): Context { val context: Context = Context.enter() - context.optimizationLevel = -1 + context.setInterpretedMode(true) context.setGeneratingDebug(BuildConfig.DEBUG) return context @@ -47,6 +51,7 @@ class JS(siteConfig: SiteConfig? = null) { init { val context = prepareContext() + root = file.absolutePath scope = context.initStandardObjects() ScriptableObject.putProperty(scope, "import", importFunction) ScriptableObject.putProperty(scope, "fetch", fetchFunction) @@ -57,19 +62,18 @@ class JS(siteConfig: SiteConfig? = null) { }) try { - siteConfig?.let { - val fileName = it.scriptFile - + properties?.forEach { (key, value) -> ScriptableObject.putProperty( scope, - "baseUrl", - Context.javaToJS(it.baseUrl, scope) + key, + Context.javaToJS(value, scope) ) - val reader = FileReader(File(appContext.scriptsDir, fileName)) - context.evaluateReader(scope, reader, fileName, 1, null) - Context.exit() - reader.close() } + + val reader = FileReader(file) + context.evaluateReader(scope, reader, file.name, 1, null) + Context.exit() + reader.close() } catch (e: Exception) { e.printStackTrace() } @@ -109,6 +113,16 @@ class JS(siteConfig: SiteConfig? = null) { val type = object : TypeToken() {}.type return gson.fromJson(jsonElement, type) as T } + + + companion object { + lateinit var appContext: android.content.Context + lateinit var root: String + + fun initialize(context: android.content.Context) { + appContext = context + } + } } fun Result.closeContext(): Result { diff --git a/app/src/main/java/com/paulcoding/hviewer/js/JsFunctions.kt b/js/src/main/java/com/paulcoding/js/JsFunctions.kt similarity index 87% rename from app/src/main/java/com/paulcoding/hviewer/js/JsFunctions.kt rename to js/src/main/java/com/paulcoding/js/JsFunctions.kt index bb42f1d..b35d1fb 100644 --- a/app/src/main/java/com/paulcoding/hviewer/js/JsFunctions.kt +++ b/js/src/main/java/com/paulcoding/js/JsFunctions.kt @@ -1,9 +1,5 @@ -package com.paulcoding.hviewer.js +package com.paulcoding.js -import com.paulcoding.hviewer.MainApp.Companion.appContext -import com.paulcoding.hviewer.helper.log -import com.paulcoding.hviewer.helper.readFile -import com.paulcoding.hviewer.network.ktorClient import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.coroutines.runBlocking @@ -12,6 +8,7 @@ import org.mozilla.javascript.BaseFunction import org.mozilla.javascript.Context import org.mozilla.javascript.NativeJSON import org.mozilla.javascript.Scriptable +import java.io.File import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -82,11 +79,11 @@ val importFunction = object : BaseFunction() { thisObj: Scriptable?, args: Array ) { - val filePath = args.getOrNull(0) as? String + val fileName = args.getOrNull(0) as? String ?: throw IllegalArgumentException("File path is required") - val script = appContext.readFile(filePath) - cx?.evaluateString(scope, script, filePath, 1, null) + val file = File(JS.root, fileName) + cx?.evaluateReader(scope, file.reader(), file.name, 1, null) } } diff --git a/js/src/main/java/com/paulcoding/js/Ktor.kt b/js/src/main/java/com/paulcoding/js/Ktor.kt new file mode 100644 index 0000000..e8b0da8 --- /dev/null +++ b/js/src/main/java/com/paulcoding/js/Ktor.kt @@ -0,0 +1,27 @@ +package com.paulcoding.js + +import com.google.gson.Strictness +import io.ktor.client.HttpClient +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.serialization.gson.gson + +val ktorClient + get() = HttpClient(Android) { + install(ContentNegotiation) { + gson { + setStrictness(Strictness.LENIENT) + } + } + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + log(message, "HTTP Client") + } + } + level = if (BuildConfig.DEBUG) LogLevel.HEADERS else LogLevel.INFO + } + } diff --git a/js/src/main/java/com/paulcoding/js/Logger.kt b/js/src/main/java/com/paulcoding/js/Logger.kt new file mode 100644 index 0000000..9d5e4b3 --- /dev/null +++ b/js/src/main/java/com/paulcoding/js/Logger.kt @@ -0,0 +1,21 @@ +package com.paulcoding.js + +fun log( + message: Any?, + tag: String? = "Logger", +) { + val border = "*".repeat(150) + println("\n") + println(border) + print("\t") + println("$tag:") + print("\t") + println(message) + println(border) + println("\n") +} + +fun T.alsoLog(tag: String? = "Logger"): T { + log(this, tag) + return this +} diff --git a/js/src/test/java/com/paulcoding/js/ExampleUnitTest.kt b/js/src/test/java/com/paulcoding/js/ExampleUnitTest.kt new file mode 100644 index 0000000..4d00669 --- /dev/null +++ b/js/src/test/java/com/paulcoding/js/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.paulcoding.js + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 37889af..4287b59 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,4 +21,4 @@ dependencyResolutionManagement { rootProject.name = "H Viewer" include(":app") - \ No newline at end of file +include(":js")