diff --git a/CMakeLists.txt b/CMakeLists.txt index 6bbf8f8..9071f97 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,33 @@ set(YYJSON_INSTALL OFF) add_subdirectory(src/c_src/yyjson EXCLUDE_FROM_ALL) set_target_properties(yyjson PROPERTIES POSITION_INDEPENDENT_CODE ON) +# ============================================================================ +# Android backend +# ============================================================================ +if(ANDROID) + add_library(ring_webview_android OBJECT + src/c_src/ring_webview_android.c + ) + set_target_properties(ring_webview_android PROPERTIES + POSITION_INDEPENDENT_CODE ON + ) + if(TARGET ring) + target_link_libraries(ring_webview_android PUBLIC ring) + else() + target_include_directories(ring_webview_android PUBLIC + "${RING_ROOT}/language/include" + ) + endif() + target_link_libraries(ring_webview_android PUBLIC + android + log + yyjson + ) + message(STATUS "Ring Webview: Android backend") + return() +endif() + + if(NOT TARGET Ring::Ring) add_library(Ring::Ring UNKNOWN IMPORTED) find_path(RING_INCLUDE_DIR ring.h PATHS "${RING_ROOT}/language/include") diff --git a/README.md b/README.md index f38bc24..e7e1777 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ This project is made possible by the tiny [webview](https://github.com/webview/w ## ✨ Features -- **Cross-Platform:** Build applications for Windows, macOS, Linux, and FreeBSD from a single codebase. +- **Cross-Platform:** Build applications for Windows, macOS, Linux, FreeBSD, and Android from a single codebase. - **Modern UI:** Use familiar web technologies to design your user interface. - **Two-Way Binding:** Seamlessly call Ring functions from JavaScript and vice-versa. - **Easy to Use:** A simple and clean API makes it easy to get started. @@ -93,6 +93,15 @@ The compiled macOS library in this package uses the built-in WebKit framework. +
+Click here for instructions on Android + +Android apps are built with [ring2apk](https://github.com/ysdragon/ring2apk) (no Gradle). +You need the Android SDK, the NDK, a JDK, and CMake + Ninja. +See [📱 Android](#-android) and [`docs/ANDROID.md`](docs/ANDROID.md) for the full guide. + +
+ - **Install the library using RingPM:** ```sh ringpm install webview from ysdragon @@ -153,6 +162,25 @@ ring main.ring See [`examples/templates/`](examples/templates/) for details. +## 📱 Android + +The same `main.ring` runs on Android via [ring2apk](https://github.com/ysdragon/ring2apk) +— no Gradle. See [`examples/android/`](examples/android/) (a +glassmorphism notes app) and [`docs/ANDROID.md`](docs/ANDROID.md) (backend +architecture, lifecycle, recovery). + +See [`docs/ANDROID.md`](docs/ANDROID.md) to build and run it. + +
+ + Nota notes app on Android + +
+ + Nota notes app on Android + +
+ ## 📚 API Reference For a detailed list of all available functions, classes, and methods, please refer to our [API reference documentation](docs/REFERENCE.md). diff --git a/docs/ANDROID.md b/docs/ANDROID.md new file mode 100644 index 0000000..0edb7c8 --- /dev/null +++ b/docs/ANDROID.md @@ -0,0 +1,74 @@ +# Building Android Apps with Ring WebView + +Write `main.ring` once, ship it on desktop and Android. On Android the same +Ring C-API is backed by `android.webkit.WebView` through JNI — no Java and +no Gradle required from you. + +## Prerequisites + +- Ring 1.27+, Android SDK + NDK + JDK, CMake + Ninja +- [ring2apk](https://github.com/ysdragon/ring2apk): `ringpm install ring2apk from ysdragon` + +## Start + +```sh +ring2apk init myapp # scaffold, or copy examples/android/ +cd myapp +ring2apk build # → build/myapp-debug.apk +ring2apk run # build + install + launch + logcat +adb logcat -s RingOutput:D # `see` output +``` + +Config lives in `ring2apk.ring` (`:packageId`, `:targets`, `:permissions`, +`:entryPoint`). The working reference is +[`examples/android/`](../examples/android/) — a notes +app; its [README](../examples/android/README.md) covers the layout. + +## Write the app + +`ring/main.ring` is compiled to embedded bytecode and linked into +`libmain.so` — no `.ring` files ship. The API matches desktop, so most of +[USAGE](USAGE.md) and [REFERENCE](REFERENCE.md) apply as-is: + +```ring +load "webview.ring" + +aBindList = [["getState", :fetchNotes]] + +oWebView = new WebView() +oWebView { + setTitle("My App") + onLoad(:handleLoad) + onDomReady(:handleDomReady) + onClose(:handleClose) + bindMany(NULL) + setHtml(`

Hello Android

`) + run() +} + +func fetchNotes(cId, aReq) + oWebView.wreturn(cId, WEBVIEW_ERROR_OK, aNotes) +``` + +Bridge rules that matter on mobile: + +- Binds receive Ring lists (JS argument array, JSON-decoded) and should + `wreturn` results; JS gets promises. See the example's bridge table. +- Drive the first render from `onDomReady` (`evalJS("refresh()")`) — it runs + strictly after the bind shim is injected. +- Files persist in the app's `filesDir` (the worker starts there); use + relative paths. +- Window-management calls (`setSize`, `minimize`, …) are desktop no-ops + that return safely — guard mobile layouts with responsive CSS instead. + +## Under the hood + +UI thread owns the WebView and only enqueues; a worker thread owns the Ring +VM and runs a bounded job queue. Rotation re-attaches without destroying; +a killed renderer rebuilds the view and reloads the last HTML/URL (poison +pages stop after 3 crashes in 10s). Single webview per process. +`RingBridge` is callable by any loaded page JS, remote included — validate +bind arguments. + +Backend sources: `src/c_src/ring_webview_android.c`, +`src/android/io/github/ysdragon/webview/MainActivity.java`. diff --git a/examples/android/.gitignore b/examples/android/.gitignore new file mode 100644 index 0000000..eb5234b --- /dev/null +++ b/examples/android/.gitignore @@ -0,0 +1,3 @@ +src/cpp/ring +build/ +.ring2apk.hash \ No newline at end of file diff --git a/examples/android/README.md b/examples/android/README.md new file mode 100644 index 0000000..4c86752 --- /dev/null +++ b/examples/android/README.md @@ -0,0 +1,52 @@ +# Nota — Ring WebView on Android + +A real notes app with a glassmorphism mobile UI, built with Ring + WebView. +Same-language backend as desktop: Ring owns the state, JavaScript renders it. + +Nota on Android + +## What it does + +- Add, edit, delete, pin, and search notes +- Color-coded glass cards, bottom-sheet editor, toasts +- Persists to `notes.db` in the app's `filesDir` (survives restarts) +- Survives rotation and renderer kills (system memory pressure) + +## Bridge pattern + +Ring never touches JSON text. Binds receive Ring lists (decoded from the +JS argument array) and `wreturn` the full notes list back; JS re-renders +from the promise resolution: + +| JS call | Ring callback | +|---|---| +| `getState()` | `fetchNotes` | +| `note_save([id, title, body, color, pinned])` | `storeNote` | +| `note_delete([id])` | `removeNote` | +| `note_pin([id])` | `toggleNotePin` | + +The first pull is driven by native `onDomReady` (`evalJS("refresh()")`), +which runs strictly after the bind shim is injected — no polling. + +## Build + +Needs [ring2apk](https://github.com/ysdragon/ring2apk): + +```sh +ringpm install ring2apk from ysdragon +cd examples/android +ring2apk build # → build/webviewdemo-debug.apk +ring2apk run # build + install + launch + logcat +adb logcat -s RingOutput:D # `see` output +``` + +## Layout + +``` +ring/main.ring # app: state, binds, persistence, embedded UI +ring/fulltest.ring # backend conformance suite (20 checks) +ring/src/ # webview.ring loader (same API as desktop) +src/cpp/ # libmain.so: Ring VM + backend (ring/ vendored, ignored by git) +src/java/ # MainActivity mirror (canonical copy in src/android/) +ring2apk.ring # package io.github.ysdragon.webview, arm64-v8a +``` diff --git a/examples/android/img/nota-screenshot.png b/examples/android/img/nota-screenshot.png new file mode 100644 index 0000000..b91ff99 Binary files /dev/null and b/examples/android/img/nota-screenshot.png differ diff --git a/examples/android/res/mipmap-hdpi/ic_launcher.png b/examples/android/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..14a9b8a Binary files /dev/null and b/examples/android/res/mipmap-hdpi/ic_launcher.png differ diff --git a/examples/android/res/mipmap-mdpi/ic_launcher.png b/examples/android/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..2a3e0d1 Binary files /dev/null and b/examples/android/res/mipmap-mdpi/ic_launcher.png differ diff --git a/examples/android/res/mipmap-xhdpi/ic_launcher.png b/examples/android/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..cda35bf Binary files /dev/null and b/examples/android/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/examples/android/res/mipmap-xxhdpi/ic_launcher.png b/examples/android/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..c9c9509 Binary files /dev/null and b/examples/android/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/examples/android/res/mipmap-xxxhdpi/ic_launcher.png b/examples/android/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..d202917 Binary files /dev/null and b/examples/android/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/android/res/values/colors.xml b/examples/android/res/values/colors.xml new file mode 100644 index 0000000..030098f --- /dev/null +++ b/examples/android/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #6200EE + #3700B3 + #03DAC5 + diff --git a/examples/android/res/values/strings.xml b/examples/android/res/values/strings.xml new file mode 100644 index 0000000..70e5284 --- /dev/null +++ b/examples/android/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Ring WebView + diff --git a/examples/android/res/values/styles.xml b/examples/android/res/values/styles.xml new file mode 100644 index 0000000..23eba66 --- /dev/null +++ b/examples/android/res/values/styles.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/examples/android/ring/main.ring b/examples/android/ring/main.ring new file mode 100644 index 0000000..6b4b497 --- /dev/null +++ b/examples/android/ring/main.ring @@ -0,0 +1,587 @@ +# Nota: glassmorphism notes app for Ring WebView on Android. +# Ring owns the notes list and persists it to notes.db (length-prefixed +# flat file in filesDir). The bridge carries JSON both ways, so Ring only +# ever handles lists: binds wreturn the full state, JS re-renders from it. + +load "webview.ring" + +aNotes = [] +nNextId = 1 +cDbFile = "notes.db" + +aBindList = [ + ["getState", :fetchNotes], + ["note_save", :storeNote], + ["note_delete", :removeNote], + ["note_pin", :toggleNotePin] +] + +loadNotes() + +oWebView = new WebView() + +oWebView { + setTitle("Nota") + + onLoad(:handleLoad) + onDomReady(:handleDomReady) + onClose(:handleClose) + + bindMany(NULL) + + setHtml(` + + + + + + Nota + + + +
+

Nota.

loading
+ +
+
+
+
+
+ +
+
+
+ + + + + + +
+
+ + +
+
+ + + +
+
+
+
+ + + + `) + + run() +} + +see "Nota closed." + nl + +# --- Persistence: length-prefixed flat file, no escaping needed --- +# note block: id LF titleLen:title bodyLen:body color LF pinned LF updated LF + +func loadNotes + if not fexists(cDbFile) + return + ok + c = read(cDbFile) + aPos = [1] + while aPos[1] <= len(c) + cId = takeLine(c, aPos) + if cId = "" + exit + ok + cTitle = takeField(c, aPos) + cBody = takeField(c, aPos) + cColor = takeLine(c, aPos) + cPinned = takeLine(c, aPos) + cUpdated = takeLine(c, aPos) + if cColor = "" or cPinned = "" or cUpdated = "" + exit + ok + aNotes + [number(cId), cTitle, cBody, cColor, number(cPinned), cUpdated] + if number(cId) >= nNextId + nNextId = number(cId) + 1 + ok + end + +func takeLine(cText, aPos) + nStart = aPos[1] + nEnd = nStart + while nEnd <= len(cText) and ascii(cText[nEnd]) != 10 + nEnd = nEnd + 1 + end + cLine = "" + if nEnd > nStart + cLine = substr(cText, nStart, nEnd - nStart) + ok + aPos[1] = nEnd + 1 + return cLine + +func takeField(cText, aPos) + nStart = aPos[1] + nColon = nStart + while nColon <= len(cText) and cText[nColon] != ":" + nColon = nColon + 1 + end + if nColon > len(cText) + return "" + ok + nFieldLen = number(substr(cText, nStart, nColon - nStart)) + nFieldStart = nColon + 1 + cField = "" + if nFieldLen > 0 + cField = substr(cText, nFieldStart, nFieldLen) + ok + aPos[1] = nFieldStart + nFieldLen + return cField + +func persistNotes + cOut = "" + for aNote in aNotes + cOut += "" + aNote[1] + char(10) + cOut += "" + len(aNote[2]) + ":" + aNote[2] + cOut += "" + len(aNote[3]) + ":" + aNote[3] + cOut += aNote[4] + char(10) + cOut += "" + aNote[5] + char(10) + cOut += aNote[6] + char(10) + next + write(cDbFile, cOut) + +# --- Binds: req[1] is the JS argument array; always wreturn the full state --- + +func fetchNotes(cId, aReq) + oWebView.wreturn(cId, WEBVIEW_ERROR_OK, aNotes) + +func storeNote(cId, aReq) + aData = aReq[1] + nId = number(aData[1]) + cTitle = "" + aData[2] + cBody = "" + aData[3] + cColor = "" + aData[4] + nPinned = number(aData[5]) + cStamp = date() + " " + time() + if nId = 0 + aNotes + [nNextId, cTitle, cBody, cColor, nPinned, cStamp] + nNextId++ + else + for i = 1 to len(aNotes) + if aNotes[i][1] = nId + aNotes[i][2] = cTitle + aNotes[i][3] = cBody + aNotes[i][4] = cColor + aNotes[i][5] = nPinned + aNotes[i][6] = cStamp + exit + ok + next + ok + persistNotes() + oWebView.wreturn(cId, WEBVIEW_ERROR_OK, aNotes) + +func removeNote(cId, aReq) + nId = number(aReq[1][1]) + for i = 1 to len(aNotes) + if aNotes[i][1] = nId + del(aNotes, i) + exit + ok + next + persistNotes() + oWebView.wreturn(cId, WEBVIEW_ERROR_OK, aNotes) + +func toggleNotePin(cId, aReq) + nId = number(aReq[1][1]) + for i = 1 to len(aNotes) + if aNotes[i][1] = nId + aNotes[i][5] = 1 - aNotes[i][5] + exit + ok + next + persistNotes() + oWebView.wreturn(cId, WEBVIEW_ERROR_OK, aNotes) + +# --- Events --- + +func handleLoad(cState) + see "load: " + cState + nl + +func handleDomReady + oWebView.evalJS("refresh()") + +func handleClose + see "close" + nl diff --git a/examples/android/ring/src/assets.ring b/examples/android/ring/src/assets.ring new file mode 100644 index 0000000..d066abc --- /dev/null +++ b/examples/android/ring/src/assets.ring @@ -0,0 +1,60 @@ +# assets.ring — embedded-assets API for Ring WebView (embed.FS-style). +# +# In dev (`ring main.ring`) assets are read from disk ($cAssetsDir, default +# "frontend/dist"). In an exe produced by webview-bake they are read from the +# binary (zero-copy), via the exe-provided C functions: +# assets_read / assets_exists / assets_count / assets_list +# App code should always use the Assets* wrappers — never the primitives. + +$cAssetsDir = "frontend/dist" +$bAssetsProbeInit = 0 +$bAssetsPacked = 0 + +func IsPackedAssets + if $bAssetsProbeInit = 0 + $bAssetsProbeInit = 1 + $bAssetsPacked = iscfunction("assets_read") + ok + return $bAssetsPacked + +func AssetsDir cDir + $cAssetsDir = cDir + +func AssetsRead cPath + if IsPackedAssets() + return assets_read(cPath) + ok + return read($cAssetsDir + "/" + cPath) + +func AssetsExists cPath + if IsPackedAssets() + return assets_exists(cPath) + ok + return fexists($cAssetsDir + "/" + cPath) + +func AssetsList + aOut = [] + if IsPackedAssets() + aOut = assets_list() + else + assetsScan($cAssetsDir, "", aOut) + ok + return sort(aOut) + +func assetsScan cAbsDir, cRel, aOut + # Mirrors webview-bake's walk: relative slash-separated keys, empty files skipped. + for aItem in dir(cAbsDir) + cName = substr(aItem[1], char(92), "/") + cKey = cName + if cRel != "" + cKey = cRel + "/" + cName + ok + cFull = cAbsDir + "/" + aItem[1] + if aItem[2] = 1 + assetsScan(cFull, cKey, aOut) + else + if getfilesize(cFull) > 0 + aOut + cKey + ok + ok + next diff --git a/examples/android/ring/src/webview.rh b/examples/android/ring/src/webview.rh new file mode 100644 index 0000000..f5547bc --- /dev/null +++ b/examples/android/ring/src/webview.rh @@ -0,0 +1,26 @@ +WEBVIEW_HINT_NONE = get_WEBVIEW_HINT_NONE() +WEBVIEW_HINT_MIN = get_WEBVIEW_HINT_MIN() +WEBVIEW_HINT_MAX = get_WEBVIEW_HINT_MAX() +WEBVIEW_HINT_FIXED = get_WEBVIEW_HINT_FIXED() +WEBVIEW_NATIVE_HANDLE_KIND_UI_WINDOW = get_WEBVIEW_NATIVE_HANDLE_KIND_UI_WINDOW() +WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET = get_WEBVIEW_NATIVE_HANDLE_KIND_UI_WIDGET() +WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER = get_WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER() +WEBVIEW_ERROR_OK = get_WEBVIEW_ERROR_OK() +WEBVIEW_ERROR_UNSPECIFIED = get_WEBVIEW_ERROR_UNSPECIFIED() +WEBVIEW_ERROR_INVALID_ARGUMENT = get_WEBVIEW_ERROR_INVALID_ARGUMENT() +WEBVIEW_ERROR_INVALID_STATE = get_WEBVIEW_ERROR_INVALID_STATE() +WEBVIEW_ERROR_CANCELED = get_WEBVIEW_ERROR_CANCELED() +WEBVIEW_ERROR_MISSING_DEPENDENCY = get_WEBVIEW_ERROR_MISSING_DEPENDENCY() +WEBVIEW_ERROR_DUPLICATE = get_WEBVIEW_ERROR_DUPLICATE() +WEBVIEW_ERROR_NOT_FOUND = get_WEBVIEW_ERROR_NOT_FOUND() +WEBVIEW_VERSION_MAJOR = get_WEBVIEW_VERSION_MAJOR() +WEBVIEW_VERSION_MINOR = get_WEBVIEW_VERSION_MINOR() +WEBVIEW_VERSION_PATCH = get_WEBVIEW_VERSION_PATCH() +WEBVIEW_EDGE_TOP = get_webview_edge_top() +WEBVIEW_EDGE_BOTTOM = get_webview_edge_bottom() +WEBVIEW_EDGE_LEFT = get_webview_edge_left() +WEBVIEW_EDGE_RIGHT = get_webview_edge_right() +WEBVIEW_EDGE_TOP_LEFT = get_webview_edge_top_left() +WEBVIEW_EDGE_TOP_RIGHT = get_webview_edge_top_right() +WEBVIEW_EDGE_BOTTOM_LEFT = get_webview_edge_bottom_left() +WEBVIEW_EDGE_BOTTOM_RIGHT = get_webview_edge_bottom_right() \ No newline at end of file diff --git a/examples/android/ring/src/webview.ring b/examples/android/ring/src/webview.ring new file mode 100644 index 0000000..eb806bc --- /dev/null +++ b/examples/android/ring/src/webview.ring @@ -0,0 +1,780 @@ +# This file is part of the Ring WebView library. + + +/* + * aWebViewConfig: Global configuration for the WebView class. + * Fields: + * :debug - (Boolean) Enable debug mode for the WebView (default: true). + * :window - (Pointer/NULL) Native window handle to associate with the WebView (default: NULL). + * Set to NULL to let the library create its own window. + */ +aWebViewConfig = [ + :debug = true, + :window = NULL +] + +/* + * Internal global list to store objects for method bindings (used by eval in bind()). +*/ +__aWebViewObjects = [] + +/** + * Class WebView: Represents a webview instance for displaying HTML content. + * Provides methods for binding Ring functions to JavaScript, navigating URLs, + * and managing the webview lifecycle. + */ +Class WebView + + _pWebView + _bindings = [] + _isDestroyed = false + + /** + * Initializes the WebView instance using global configuration. + */ + func init + self._pWebView = webview_create(aWebViewConfig[:debug], aWebViewConfig[:window]) + if isNull(self._pWebView) or not isPointer(self._pWebView) + raise("Failed to create webview instance.") + ok + + # Automatically bind global `aBindList` if it exists. + bindMany(NULL) + + /** + * Checks if the webview has been destroyed. + * @return true if destroyed, false otherwise. + */ + func isDestroyed() + return self._isDestroyed + + /** + * Runs the webview event loop (blocking call). + * Destroys the webview after the loop ends. + */ + func run() + if self.isDestroyed() + return + ok + webview_run(self._pWebView) + # After run() finishes, the window is closed, so we must destroy. + self.destroy() + + /** + * Destroys the webview instance and releases resources. + */ + func destroy() + if self.isDestroyed() + return + ok + + self._isDestroyed = true + + webview_destroy(self._pWebView) + + self._pWebView = NULL + self._bindings = [] + + func terminate() + if self.isDestroyed() + return + ok + webview_terminate(self._pWebView) + + /** + * Dispatches code to run on the main thread. + * @param cCode Code to execute. + * @return Result of dispatch. + */ + func dispatch(cCode) + if self.isDestroyed() + return + ok + + return webview_dispatch(self._pWebView, cCode) + + /** + * Binds a Ring function or object methods to JavaScript. + * + * For simple functions: + * bind(jsName, ringFuncName) + * + * For object methods: + * bind(oObject, aMethods) + * + * @param p1 For functions, the JS name. For objects, the Ring object. + * @param p2 For functions, the Ring function name. For objects, a list of method pairs. + * @return Binding result pointer for simple functions, or nothing for objects. + */ + func bind(p1, p2) + if self.isDestroyed() + return + ok + + # Check for object binding + if isObject(p1) and isList(p2) + oObject = p1 + aMethods = p2 + + # Store object and get its index + nObjectIndex = find(__aWebViewObjects, oObject) + + if nObjectIndex = 0 + add(__aWebViewObjects, ref(oObject)) + nObjectIndex = len(__aWebViewObjects) + ok + + for aMethodInfo in aMethods + if isList(aMethodInfo) and len(aMethodInfo) = 2 and isString(aMethodInfo[1]) and isString(aMethodInfo[2]) + cJSName = aMethodInfo[1] + cMethodName = aMethodInfo[2] + + # Create wrapper function + cFunc = eval(print2str(` + return func (id, req) { __aWebViewObjects[#{nObjectIndex}].#{cMethodName}(id,req) } + `)) + + # Bind the generated function + self.bind(cJSName, cFunc) + else + see "Warning: Invalid method definition." + nl + ok + next + else + # Simple function binding + jsName = p1 + ringFuncName = p2 + aBindResult = webview_bind(self._pWebView, jsName, ringFuncName) + + if isPointer(aBindResult) + add(self._bindings, aBindResult) + ok + return aBindResult + ok + + /** + * Binds multiple Ring functions or object methods to JavaScript. + * + * This function accepts a list containing a mix of binding types: + * - For simple functions: `["jsFunctionName", "ringFunctionName"]` + * - For object methods: `[ringObject, [["jsFunctionName", "methodName"], ...]]` + * + * If `aList` is `NULL`, the function will attempt to use a global list + * named `aBindList` as the source of bindings. + * + * @param aList A list of bindings. If `NULL`, uses the global `aBindList`. + */ + func bindMany(aList) + if self.isDestroyed() + return + ok + + if isNull(aList) + if isGlobal("aBindList") + aList = aBindList + else + return # No bindings to process + ok + ok + + if isList(aList) + for aItem in aList + if isList(aItem) and len(aItem) = 2 + self.bind(aItem[1], aItem[2]) + ok + next + ok + + /** + * Unbinds a JavaScript function name. + * @param jsName JavaScript function name. + * @return Result of unbind. + */ + func unbind(jsName) + if self.isDestroyed() + return + ok + + return webview_unbind(self._pWebView, jsName) + + /** + * Gets the native window handle. + * @return Native window handle. + */ + func getWindow() + if self.isDestroyed() + return + ok + + return webview_get_window(self._pWebView) + + /** + * Gets the native handle of a specific kind. + * @param kind Type of native handle. + * @return Native handle. + */ + func getNativeHandle(kind) + if self.isDestroyed() + return + ok + + return webview_get_native_handle(self._pWebView, kind) + + /** + * Sets the window title. + * @param title Window title string. + */ + func setTitle(title) + if self.isDestroyed() + return + ok + + webview_set_title(self._pWebView, title) + + /** + * Sets the window size. + * @param width Window width. + * @param height Window height. + * @param hint Size hint. + */ + func setSize(width, height, hint) + if self.isDestroyed() + return + ok + + webview_set_size(self._pWebView, width, height, hint) + + /** + * Navigates to a URL. + * @param url URL to navigate to. + */ + func navigate(url) + if self.isDestroyed() + return + ok + + webview_navigate(self._pWebView, url) + + /** + * Sets the HTML content of the webview. + * @param html HTML string. + */ + func setHtml(html) + if self.isDestroyed() + return + ok + + webview_set_html(self._pWebView, html) + + /** + * Injects JavaScript code to run on initialization. + * @param js JavaScript code string. + */ + func injectJS(js) + if self.isDestroyed() + return + ok + + webview_init(self._pWebView, js) + + /** + * Evaluates JavaScript code in the webview. + * @param js JavaScript code string. + */ + func evalJS(js) + if self.isDestroyed() + return + ok + + webview_eval(self._pWebView, js) + + /** + * Returns a result to a JavaScript callback. + * @param id Callback id. + * @param result Result value e.g. WEBVIEW_ERROR_OK. + * @param json JSON string. + */ + func wreturn(id, result, json) + if self.isDestroyed() + return + ok + + webview_return(self._pWebView, id, result, json) + + /** + * Enables or disables window decorations (title bar, borders). + * @param decorated True to show decorations, false for frameless window. + * @return 1 on success, 0 on failure. + */ + func setDecorated(decorated) + if self.isDestroyed() + return 0 + ok + return webview_set_decorated(self._pWebView, decorated) + + /** + * Sets the window transparency level. + * @param opacity Value between 0.0 (transparent) and 1.0 (opaque). + * @return 1 on success, 0 on failure. + */ + func setOpacity(opacity) + if self.isDestroyed() + return 0 + ok + return webview_set_opacity(self._pWebView, opacity) + + /** + * Sets whether the window should stay above all other windows. + * @param onTop True to keep on top, false for normal behavior. + * @return 1 on success, 0 on failure. Not supported on Linux/GTK4. + */ + func setAlwaysOnTop(onTop) + if self.isDestroyed() + return 0 + ok + return webview_set_always_on_top(self._pWebView, onTop) + + /** + * Minimizes the window to the taskbar/dock. + * @return 1 on success, 0 on failure. + */ + func minimize() + if self.isDestroyed() + return 0 + ok + return webview_minimize(self._pWebView) + + /** + * Maximizes the window to fill the screen. + * @return 1 on success, 0 on failure. + */ + func maximize() + if self.isDestroyed() + return 0 + ok + return webview_maximize(self._pWebView) + + /** + * Restores the window from minimized or maximized state. + * @return 1 on success, 0 on failure. + */ + func restore() + if self.isDestroyed() + return 0 + ok + return webview_restore(self._pWebView) + + /** + * Checks if the window is currently maximized. + * @return 1 if maximized, 0 if not. + */ + func isMaximized() + if self.isDestroyed() + return 0 + ok + return webview_is_maximized(self._pWebView) + + /** + * Initiates window dragging. Call from a mouse-down event on custom title bar. + * @return 1 on success, 0 on failure. + */ + func startDrag() + if self.isDestroyed() + return 0 + ok + return webview_start_drag(self._pWebView) + + /** + * Moves the window to the specified screen coordinates. + * @param x The x-coordinate. + * @param y The y-coordinate. + * @return 1 on success, 0 on failure. Not supported on Linux/Wayland. + */ + func setPosition(x, y) + if self.isDestroyed() + return 0 + ok + return webview_set_position(self._pWebView, x, y) + + /** + * Gets the current window position. + * @return A list [x, y] containing the window coordinates. + */ + func getPosition() + if self.isDestroyed() + return [0, 0] + ok + return webview_get_position(self._pWebView) + + /** + * Gets the current window size. + * @return A list [width, height] containing the window dimensions. + */ + func getSize() + if self.isDestroyed() + return [0, 0] + ok + return webview_get_size(self._pWebView) + + /** + * Brings the window to the front and gives it focus. + * @return 1 on success, 0 on failure. + */ + func focus() + if self.isDestroyed() + return 0 + ok + return webview_focus(self._pWebView) + + /** + * Hides the window without destroying it. + * @return 1 on success, 0 on failure. + */ + func hide() + if self.isDestroyed() + return 0 + ok + return webview_hide(self._pWebView) + + /** + * Shows a previously hidden window. + * @return 1 on success, 0 on failure. + */ + func show() + if self.isDestroyed() + return 0 + ok + return webview_show(self._pWebView) + + /** + * Initiates window resizing from the specified edge. + * @param edge The edge constant (e.g., WEBVIEW_EDGE_RIGHT). + * @return 1 on success, 0 on failure. + */ + func startResize(edge) + if self.isDestroyed() + return 0 + ok + return webview_start_resize(self._pWebView, edge) + + /** + * Enables or disables fullscreen mode. + * @param fullscreen True for fullscreen, false for windowed mode. + * @return 1 on success, 0 on failure. + */ + func setFullscreen(fullscreen) + if self.isDestroyed() + return 0 + ok + return webview_set_fullscreen(self._pWebView, fullscreen) + + /** + * Checks if the window is currently in fullscreen mode. + * @return 1 if fullscreen, 0 if windowed. + */ + func isFullscreen() + if self.isDestroyed() + return 0 + ok + return webview_is_fullscreen(self._pWebView) + + /** + * Enables or disables window resizing by the user. + * @param resizable True to allow resizing, false to prevent it. + * @return 1 on success, 0 on failure. + */ + func setResizable(resizable) + if self.isDestroyed() + return 0 + ok + return webview_set_resizable(self._pWebView, resizable) + + /** + * Checks if the window is currently resizable. + * @return 1 if resizable, 0 if not. + */ + func isResizable() + if self.isDestroyed() + return 0 + ok + return webview_is_resizable(self._pWebView) + + /** + * Sets the minimum size constraint for the window. + * @param width Minimum width in pixels. + * @param height Minimum height in pixels. + * @return 1 on success, 0 on failure. + */ + func setMinSize(width, height) + if self.isDestroyed() + return 0 + ok + return webview_set_min_size(self._pWebView, width, height) + + /** + * Sets the maximum size constraint for the window. + * @param width Maximum width in pixels. + * @param height Maximum height in pixels. + * @return 1 on success, 0 on failure. + */ + func setMaxSize(width, height) + if self.isDestroyed() + return 0 + ok + return webview_set_max_size(self._pWebView, width, height) + + /** + * Sets the background color of the webview. + * @param r Red component (0-255). + * @param g Green component (0-255). + * @param b Blue component (0-255). + * @param a Alpha component (0-255). 0 = transparent, 255 = opaque. + * @return 1 on success, 0 on failure. + */ + func setBackgroundColor(r, g, b, a) + if self.isDestroyed() + return 0 + ok + return webview_set_background_color(self._pWebView, r, g, b, a) + + /** + * Navigates back in the browsing history. + * @return 1 on success, 0 on failure. + */ + func back() + if self.isDestroyed() + return 0 + ok + return webview_back(self._pWebView) + + /** + * Navigates forward in the browsing history. + * @return 1 on success, 0 on failure. + */ + func forward() + if self.isDestroyed() + return 0 + ok + return webview_forward(self._pWebView) + + /** + * Reloads the current page. + * @return 1 on success, 0 on failure. + */ + func reload() + if self.isDestroyed() + return 0 + ok + return webview_reload(self._pWebView) + + /** + * Shows or hides the developer tools (inspector). + * @param enabled True to show dev tools, false to hide. + * @return 1 on success, 0 on failure. + */ + func setDevTools(enabled) + if self.isDestroyed() + return 0 + ok + return webview_set_dev_tools(self._pWebView, enabled) + + /** + * Gets the URL of the currently loaded page. + * @return The current URL string, or empty string if not available. + */ + func getUrl() + if self.isDestroyed() + return "" + ok + return webview_get_url(self._pWebView) + + /** + * Gets the title of the currently loaded page. + * @return The page title string, or empty string if not available. + */ + func getPageTitle() + if self.isDestroyed() + return "" + ok + return webview_get_title(self._pWebView) + + /** + * Enables or disables the default browser context menu (right-click menu). + * @param enabled True to allow context menu, false to suppress it. + * @return 1 on success, 0 on failure. + */ + func setContextMenu(enabled) + if self.isDestroyed() + return 0 + ok + return webview_set_context_menu(self._pWebView, enabled) + + /** + * Sets the window icon from a file. + * @param iconPath Path to the icon file (PNG, ICO, etc.). + * @return 1 on success, 0 on failure. + */ + func setIcon(iconPath) + if self.isDestroyed() + return 0 + ok + return webview_set_icon(self._pWebView, iconPath) + + /** + * Checks if the window currently has focus. + * @return 1 if focused, 0 if not. + */ + func isFocused() + if self.isDestroyed() + return 0 + ok + return webview_is_focused(self._pWebView) + + /** + * Checks if the window is currently visible. + * @return 1 if visible, 0 if hidden. + */ + func isVisible() + if self.isDestroyed() + return 0 + ok + return webview_is_visible(self._pWebView) + + /** + * Programmatically closes the window. + * @return 1 on success, 0 on failure. + */ + func closeWindow() + if self.isDestroyed() + return 0 + ok + return webview_close(self._pWebView) + + /** + * Forces dark mode for the application UI. + * @param enabled True to force dark mode, false for system default. + * @return 1 on success, 0 on failure. Only supported on Linux with libadwaita. + */ + func setForceDark(enabled) + if self.isDestroyed() + return 0 + ok + return webview_set_force_dark(self._pWebView, enabled) + + /** + * Checks if dark mode is forced. + * @return 1 if dark mode is forced, 0 if not. + */ + func isForceDark() + if self.isDestroyed() + return 0 + ok + return webview_is_force_dark(self._pWebView) + + /** + * Gets information about all connected monitors/screens. + * @return A list of lists. Each: [name, x, y, width, height]. + */ + func getScreens() + if self.isDestroyed() + return [] + ok + return webview_get_screens(self._pWebView) + + /** + * Enables or disables click-through mode (mouse events pass through). + * @param enabled True to enable click-through, false to disable. + * @return 1 on success, 0 on failure. Only supported on Windows. + */ + func setClickThrough(enabled) + if self.isDestroyed() + return 0 + ok + return webview_set_click_through(self._pWebView, enabled) + + /** + * Checks if click-through mode is enabled. + * @return 1 if enabled, 0 if not. + */ + func isClickThrough() + if self.isDestroyed() + return 0 + ok + return webview_is_click_through(self._pWebView) + + /** + * Sets a callback for when the window close is requested. + * @param callback Name of the Ring function: func name(cData). + * @return 1 on success, 0 on failure. + */ + func onClose(callback) + if self.isDestroyed() + return 0 + ok + return webview_on_close(self._pWebView, callback) + + /** + * Sets a callback for when the window is resized. + * @param callback Name of the Ring function: func name(cSize) where cSize is "WIDTHxHEIGHT". + * @return 1 on success, 0 on failure. + */ + func onResize(callback) + if self.isDestroyed() + return 0 + ok + return webview_on_resize(self._pWebView, callback) + + /** + * Sets a callback for when the window gains or loses focus. + * @param callback Name of the Ring function: func name(cFocused) where cFocused is "true" or "false". + * @return 1 on success, 0 on failure. + */ + func onFocus(callback) + if self.isDestroyed() + return 0 + ok + return webview_on_focus(self._pWebView, callback) + + /** + * Sets a callback for when the DOM is ready. + * @param callback Name of the Ring function: func name(cData). + * @return 1 on success, 0 on failure. + */ + func onDomReady(callback) + if self.isDestroyed() + return 0 + ok + return webview_on_dom_ready(self._pWebView, callback) + + /** + * Sets a callback for when a page load starts or finishes. + * @param callback Name of the Ring function: func name(cState) where cState is "started" or "finished". + * @return 1 on success, 0 on failure. + */ + func onLoad(callback) + if self.isDestroyed() + return 0 + ok + return webview_on_load(self._pWebView, callback) + + /** + * Sets a callback for when navigation occurs. + * @param callback Name of the Ring function: func name(cUrl). + * @return 1 on success, 0 on failure. + */ + func onNavigate(callback) + if self.isDestroyed() + return 0 + ok + return webview_on_navigate(self._pWebView, callback) + + /** + * Sets a callback for when the page title changes. + * @param callback Name of the Ring function: func name(cTitle). + * @return 1 on success, 0 on failure. + */ + func onTitle(callback) + if self.isDestroyed() + return 0 + ok + return webview_on_title(self._pWebView, callback) \ No newline at end of file diff --git a/examples/android/ring/webview.ring b/examples/android/ring/webview.ring new file mode 100644 index 0000000..64fe65e --- /dev/null +++ b/examples/android/ring/webview.ring @@ -0,0 +1,5 @@ +# This file is part of the Ring WebView library. + +# Load the Ring WebView Class and Constants. +load "src/webview.ring" +load "src/webview.rh" diff --git a/examples/android/ring2apk.ring b/examples/android/ring2apk.ring new file mode 100644 index 0000000..a71e2c3 --- /dev/null +++ b/examples/android/ring2apk.ring @@ -0,0 +1,41 @@ +/* + ring2apk configuration file — Ring WebView Android example +*/ + +# App configuration +Ring2ApkConfig = [ + # App identity + :name = "webviewdemo", + :packageId = "io.github.ysdragon.webview", + :versionCode = 1, + :versionName = "1.0.0", + + # Android SDK versions + :minSdk = 21, + :targetSdk = 36, + :compileSdk = 36, + + # Target architectures + :targets = ["arm64-v8a"], + + # Directories + :assetsDir = "assets", + :resDir = "res", + :srcDir = "src", + :outputDir = "build", + + # Entry point Ring file + :entryPoint = "main.ring", + + :ringSrcDir = "ring", + + # App display settings + :label = "Ring WebView", + :orientation = "unspecified", + :theme = "@style/AppTheme", + + # Permissions + :permissions = [ + "android.permission.INTERNET" + ] +] diff --git a/examples/android/src/cpp/CMakeLists.txt b/examples/android/src/cpp/CMakeLists.txt new file mode 100644 index 0000000..29438cc --- /dev/null +++ b/examples/android/src/cpp/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.22) +project(myapp C) + +# Workaround for large file support on some Android ABIs +if((ANDROID_ABI STREQUAL "armeabi-v7a" OR ANDROID_ABI STREQUAL "x86") AND ANDROID_PLATFORM_LEVEL LESS 24) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -U_FILE_OFFSET_BITS") +endif() + +# ============================================================================ +# Ring Language (build from source) +# ============================================================================ +set(RING_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ring") +if(NOT EXISTS "${RING_DIR}/src") + message(FATAL_ERROR "Ring sources not found! Expected at ${RING_DIR}/src") +endif() + +file(GLOB RING_SOURCES ${RING_DIR}/src/*.c) + +add_library(ring STATIC ${RING_SOURCES}) +target_include_directories(ring PUBLIC "${RING_DIR}/include") +target_link_libraries(ring PUBLIC android log) + +# ============================================================================ +# Main Application +# ============================================================================ +add_library(main SHARED + main.c + ${CMAKE_SOURCE_DIR}/../../build/gen/ringappcode.c +) + +target_include_directories(main PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/../../build/gen + ${RING_DIR}/include +) + +target_link_libraries(main PRIVATE + ring + android + log +) + +add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../../.." "${CMAKE_BINARY_DIR}/ring_webview") + +target_link_libraries(main PRIVATE ring_webview_android) \ No newline at end of file diff --git a/examples/android/src/cpp/main.c b/examples/android/src/cpp/main.c new file mode 100644 index 0000000..0516fd8 --- /dev/null +++ b/examples/android/src/cpp/main.c @@ -0,0 +1,79 @@ +/* main.c — Ring app entry point for the Android WebView example. + * + * Owns ALL Ring loading. The backend (ring_webview_android.c) keeps only + * JNI; its worker thread calls ring_app_main() and never touches Ring + * loading directly. + * + * Two responsibilities: + * + * 1. ring_vm_extension() — replicates Ring's ext.c verbatim and appends + * the webview backend's ringlib_init() so the webview_* functions are + * registered when the VM starts. This file replaces ext.c in the Ring + * source list (see CMakeLists.txt), keeping Ring sources pristine. + * + * 2. ring_app_main() — creates the Ring state and runs the embedded + * bytecode from ringappcode.c (generated by `ring -geo -norun + * main.ring`, which compiles main.ring and every transitive load into + * one self-contained bytecode — no .ring files are read at runtime). + */ + +#include "ring.h" +#include "ringappcode.h" + +/* Provided by ring_webview_android.c */ +extern void ringlib_init(RingState *pRingState); + +/* ------------------------------------------------------------------ */ +/* 1. Ring VM extension hook (replaces ext.c) */ +/* ------------------------------------------------------------------ */ + +void ring_vm_extension(RingState *pRingState) { +/* List */ +#if RING_VM_LISTFUNCS + ring_vm_list_loadfunctions(pRingState); +#endif +/* Math */ +#if RING_VM_MATH + ring_vm_math_loadfunctions(pRingState); +#endif +/* File */ +#if RING_VM_FILE + ring_vm_file_loadfunctions(pRingState); +#endif +/* OS */ +#if RING_VM_OS + ring_vm_os_loadfunctions(pRingState); +#endif +/* DLL */ +#if RING_VM_DLL + ring_vm_dll_loadfunctions(pRingState); +#endif +/* Reflection and Meta-programming */ +#if RING_VM_REFMETA + ring_vm_refmeta_loadfunctions(pRingState); +#endif +/* VM Information */ +#if RING_VM_INFO + ring_vm_info_loadfunctions(pRingState); +#endif +/* WebView backend */ + ringlib_init(pRingState); +} + +/* ------------------------------------------------------------------ */ +/* 2. App entry point — called by the backend's worker thread */ +/* ------------------------------------------------------------------ */ + +/* Creates the Ring state and runs the embedded bytecode. Returns only + * when the Ring program exits (i.e. webview_run() returns and main() + * finishes). The backend captures the live RingState itself inside + * ring_webview_create() via RING_API_STATE, so no state is passed back. */ +void ring_app_main(void) { + RingState *pRingState = ring_state_new(); + if (!pRingState) { + return; + } + pRingState->lRun = 1; + ringappcode_run(pRingState); + ring_state_delete(pRingState); +} diff --git a/examples/android/src/java/io/github/ysdragon/webview/MainActivity.java b/examples/android/src/java/io/github/ysdragon/webview/MainActivity.java new file mode 100644 index 0000000..5fa51e2 --- /dev/null +++ b/examples/android/src/java/io/github/ysdragon/webview/MainActivity.java @@ -0,0 +1,464 @@ +/* + * MainActivity.java + * Copyright (c) Youssef Saeed + * All rights reserved. + */ +package io.github.ysdragon.webview; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.view.WindowInsetsController; +import android.view.WindowManager; +import android.webkit.ConsoleMessage; +import android.webkit.JavascriptInterface; +import android.webkit.JsPromptResult; +import android.webkit.JsResult; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.EditText; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +// Android host: owns the WebView on the UI thread. Ring VM runs on a native +// worker thread; all cross-talk goes through ring_webview_android.c. +public class MainActivity extends Activity { + + private static final String TAG = "RingWebView"; + private static final long SYNC_TIMEOUT_MS = 5000; + + static { + System.loadLibrary("main"); + } + + private WebView webView; + private volatile String injectJs = ""; + private volatile boolean destroyed = false; + // Last URL / HTML (crash reload; reset on rotation). Last size (resize on change). + // UI thread only. + private String lastUrl = ""; + private String lastHtml = ""; + private int lastW = -1, lastH = -1; + // Crash-loop guard: a poison page must not rebuild forever. + private int crashCount = 0; + private long lastCrashMs = 0; + + /* ------------------------------------------------------------------ */ + /* Lifecycle */ + /* ------------------------------------------------------------------ */ + + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + Window window = getWindow(); + window.setStatusBarColor(Color.TRANSPARENT); + if (Build.VERSION.SDK_INT >= 30) { + window.setDecorFitsSystemWindows(false); + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; + window.getDecorView().setSystemUiVisibility(flags); + } + + webView = createWebView(); + setContentView(webView); + + // Dark page: keep light status-bar icons. + if (Build.VERSION.SDK_INT >= 30) { + WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + controller.setSystemBarsAppearance( + 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS); + } + } + + nativeStart(); + } + + // Fully configured WebView. Shared by onCreate and crash recovery. + // UI thread only. + private WebView createWebView() { + final WebView v = new WebView(this); + + WebSettings settings = v.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowFileAccess(true); + settings.setAllowContentAccess(true); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + + v.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return false; + } + + @Override + public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { + if (url != null) { + lastUrl = url; + // Real navigation wins over earlier setHtml content. + if (!url.equals("about:blank")) lastHtml = ""; + } + nativeOnPageStarted(url); + } + + @Override + public void onPageFinished(WebView view, String url) { + String js = injectJs; + if (js != null && js.length() > 0) { + view.evaluateJavascript(js, null); + } + nativeOnPageFinished(url); + } + + @Override + public boolean onRenderProcessGone(WebView view, android.webkit.RenderProcessGoneDetail detail) { + return handleRenderCrash(view, detail); + } + }); + + v.setWebChromeClient(new WebChromeClient() { + @Override + public void onReceivedTitle(WebView view, String title) { + nativeOnTitleChanged(title); + } + + @Override + public boolean onConsoleMessage(ConsoleMessage cm) { + Log.i(TAG, "JS: " + cm.message() + " @" + cm.sourceId() + ":" + cm.lineNumber()); + return true; + } + + @Override + public boolean onJsAlert(WebView view, String url, String message, final JsResult result) { + new AlertDialog.Builder(MainActivity.this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setMessage(message) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.confirm(); + } + }) + .setCancelable(false) + .show(); + return true; + } + + @Override + public boolean onJsConfirm(WebView view, String url, String message, final JsResult result) { + new AlertDialog.Builder(MainActivity.this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setMessage(message) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.confirm(); + } + }) + .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.cancel(); + } + }) + .setCancelable(false) + .show(); + return true; + } + + @Override + public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, + final JsPromptResult result) { + final EditText input = new EditText(MainActivity.this); + input.setText(defaultValue); + new AlertDialog.Builder(MainActivity.this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setMessage(message) + .setView(input) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.confirm(input.getText().toString()); + } + }) + .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.cancel(); + } + }) + .setCancelable(false) + .show(); + return true; + } + }); + + v.addJavascriptInterface(new RingBridge(), "RingBridge"); + + v.getViewTreeObserver().addOnGlobalLayoutListener(new android.view.ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int w = v.getWidth(), h = v.getHeight(); + if (w != lastW || h != lastH) { + lastW = w; + lastH = h; + if (w > 0 && h > 0) nativeOnResize(w, h); + } + } + }); + + return v; + } + + // Renderer gone: destroy the dead view, build an identical one, reload. + // Inject JS persists, so bindings come back with the page. True = handled. + // API 26+ only. + private boolean handleRenderCrash(WebView view, android.webkit.RenderProcessGoneDetail detail) { + boolean crashed = detail != null && detail.didCrash(); + Log.w(TAG, "WebView render process " + (crashed ? "crashed" : "killed by system") + + "; rebuilding view, lastUrl=" + lastUrl); + if (view != null) view.destroy(); + lastW = lastH = -1; + webView = createWebView(); + setContentView(webView); + long now = System.currentTimeMillis(); + if (now - lastCrashMs > 10000) crashCount = 0; + lastCrashMs = now; + // Same page crashing repeatedly: rebuild empty, stop reloading it. + if (++crashCount > 3) { + Log.e(TAG, "WebView crashed repeatedly; showing empty view"); + return true; + } + if (!lastHtml.isEmpty()) renderHtml(webView, lastHtml); + else if (!lastUrl.isEmpty()) webView.loadUrl(lastUrl); + return true; + } + + private void renderHtml(WebView v, String html) { + if (v != null) v.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + nativeOnFocusChanged(hasFocus); + } + + @Override + public void onBackPressed() { + if (webView != null && webView.canGoBack()) { + webView.goBack(); + } else { + nativeOnClose(); + super.onBackPressed(); + } + } + + @Override + protected void onDestroy() { + // Rotation destroys too; only a real finish kills the worker. + if (isFinishing()) { + destroyed = true; + nativeDestroy(); + } + if (webView != null) { + webView.destroy(); + webView = null; + } + super.onDestroy(); + } + + /* ------------------------------------------------------------------ */ + /* Called from native (worker thread unless noted) */ + /* ------------------------------------------------------------------ */ + + public void evalJs(final String js) { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null) webView.evaluateJavascript(js, null); + } + }); + } + + public void loadUrl(final String url) { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null) webView.loadUrl(url); + } + }); + } + + public void loadHtml(final String html) { + if (destroyed || webView == null) return; + lastHtml = (html == null) ? "" : html; + runOnUiThread(new Runnable() { + @Override + public void run() { + renderHtml(webView, html); + } + }); + } + + public void goBack() { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null && webView.canGoBack()) webView.goBack(); + } + }); + } + + public void goForward() { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null && webView.canGoForward()) webView.goForward(); + } + }); + } + + public void reload() { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null) webView.reload(); + } + }); + } + + // Sync URL query; blocks the caller on the UI thread. + public String getUrlSync() { + if (destroyed || webView == null) return ""; + if (Looper.myLooper() == Looper.getMainLooper()) { + String u = webView.getUrl(); + return u == null ? "" : u; + } + final AtomicReference ref = new AtomicReference(""); + final CountDownLatch latch = new CountDownLatch(1); + runOnUiThread(new Runnable() { + @Override + public void run() { + String u = (webView != null) ? webView.getUrl() : null; + ref.set(u == null ? "" : u); + latch.countDown(); + } + }); + try { + boolean ok = latch.await(SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (!ok) { + Log.w(TAG, "getUrlSync timed out after " + SYNC_TIMEOUT_MS + "ms; returning \"\""); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "getUrlSync interrupted; returning \"\""); + } + return ref.get(); + } + + // Sync title query; blocks the caller on the UI thread. + public String getTitleSync() { + if (destroyed || webView == null) return ""; + if (Looper.myLooper() == Looper.getMainLooper()) { + String t = webView.getTitle(); + return t == null ? "" : t; + } + final AtomicReference ref = new AtomicReference(""); + final CountDownLatch latch = new CountDownLatch(1); + runOnUiThread(new Runnable() { + @Override + public void run() { + String t = (webView != null) ? webView.getTitle() : null; + ref.set(t == null ? "" : t); + latch.countDown(); + } + }); + try { + boolean ok = latch.await(SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (!ok) { + Log.w(TAG, "getTitleSync timed out after " + SYNC_TIMEOUT_MS + "ms; returning \"\""); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "getTitleSync interrupted; returning \"\""); + } + return ref.get(); + } + + public void setDebug(final boolean enabled) { + // setWebContentsDebuggingEnabled must run on the UI thread. + runOnUiThread(new Runnable() { + @Override + public void run() { + WebView.setWebContentsDebuggingEnabled(enabled); + } + }); + } + + // Script re-injected after every page load. + public void setInjectJs(String js) { + injectJs = (js == null) ? "" : js; + } + + public void finishApp() { + if (destroyed) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + finish(); + } + }); + } + + /* ------------------------------------------------------------------ */ + /* JavaScript bridge */ + /* ------------------------------------------------------------------ */ + + // TRUST BOUNDARY: every page, remote included, can call any bound Ring + // function by name. Validate args; no privileged ops. + private class RingBridge { + @JavascriptInterface + public void call(String name, String id, String req) { + nativeOnBridgeCall(name, id, req); + } + } + + /* ------------------------------------------------------------------ */ + /* Native methods (implemented in ring_webview_android.c) */ + /* ------------------------------------------------------------------ */ + + private native void nativeStart(); + private native void nativeDestroy(); + private native void nativeOnBridgeCall(String name, String id, String req); + private native void nativeOnPageStarted(String url); + private native void nativeOnPageFinished(String url); + private native void nativeOnClose(); + private native void nativeOnTitleChanged(String title); + private native void nativeOnFocusChanged(boolean focused); + private native void nativeOnResize(int width, int height); +} diff --git a/examples/android/src/proguard-webview.pro b/examples/android/src/proguard-webview.pro new file mode 100644 index 0000000..8c4573a --- /dev/null +++ b/examples/android/src/proguard-webview.pro @@ -0,0 +1,17 @@ +# Ring WebView Android backend — ProGuard / R8 keep rules. +# +# The native backend (ring_webview_android.c) looks up MainActivity methods by +# string name via GetMethodID, and the JS bridge entry point is reached by +# reflection (@JavascriptInterface). With minification enabled, R8 would rename +# or strip them and the bridge would break silently at runtime (UnsatisfiedLink +# errors never fire — calls just stop arriving). +# +# Pass this file to the APK packager (ring2apk) whenever minify is enabled. +# Without minification it is inert documentation of the reflective boundary. + +-keep class io.github.ysdragon.webview.MainActivity { + *; +} +-keepclassmembers class io.github.ysdragon.webview.MainActivity$RingBridge { + *; +} diff --git a/package.ring b/package.ring index a87d9c0..d45376b 100644 --- a/package.ring +++ b/package.ring @@ -5,12 +5,16 @@ aPackageInfo = [ :developer = "ysdragon", :email = "youssefelkholey@gmail.com", :license = "MIT License", - :version = "1.5.2", + :version = "2.0.0", :ringversion = "1.27", :versions = [ [ - :version = "1.5.2", + :version = "2.0.0", :branch = "main" + ], + [ + :version = "1.5.2", + :branch = "v1.5.2" ] ], :libs = [ @@ -48,11 +52,19 @@ aPackageInfo = [ :name = "weblib", :version = "1.0.9", :providerusername = "ringpackages" + ], + [ + :name = "ring2apk", + :version = "1.1.0", + :providerusername = "ysdragon" ] ], :files = [ "src/c_src/ring_webview.c", "src/c_src/ring_webview_json.h", + "src/c_src/ring_webview_android.c", + "src/android/io/github/ysdragon/webview/MainActivity.java", + "src/android/proguard-webview.pro", "CMakeLists.txt", "lib.ring", "main.ring", @@ -204,9 +216,31 @@ aPackageInfo = [ "examples/templates/vue/frontend/tsconfig.json", "examples/templates/vue/frontend/tsconfig.node.json", "examples/templates/vue/frontend/vite.config.ts", + "examples/android/.gitignore", + "examples/android/README.md", + "examples/android/img/nota-screenshot.png", + "examples/android/res/mipmap-hdpi/ic_launcher.png", + "examples/android/res/mipmap-mdpi/ic_launcher.png", + "examples/android/res/mipmap-xhdpi/ic_launcher.png", + "examples/android/res/mipmap-xxhdpi/ic_launcher.png", + "examples/android/res/mipmap-xxxhdpi/ic_launcher.png", + "examples/android/res/values/colors.xml", + "examples/android/res/values/strings.xml", + "examples/android/res/values/styles.xml", + "examples/android/ring/main.ring", + "examples/android/ring/src/assets.ring", + "examples/android/ring/src/webview.rh", + "examples/android/ring/src/webview.ring", + "examples/android/ring/webview.ring", + "examples/android/ring2apk.ring", + "examples/android/src/cpp/CMakeLists.txt", + "examples/android/src/cpp/main.c", + "examples/android/src/java/io/github/ysdragon/webview/MainActivity.java", + "examples/android/src/proguard-webview.pro", "README.md", "docs/REFERENCE.md", "docs/USAGE.md", + "docs/ANDROID.md", "LICENSE" ], :ringfolderfiles = [ diff --git a/src/android/io/github/ysdragon/webview/MainActivity.java b/src/android/io/github/ysdragon/webview/MainActivity.java new file mode 100644 index 0000000..5fa51e2 --- /dev/null +++ b/src/android/io/github/ysdragon/webview/MainActivity.java @@ -0,0 +1,464 @@ +/* + * MainActivity.java + * Copyright (c) Youssef Saeed + * All rights reserved. + */ +package io.github.ysdragon.webview; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.view.WindowInsetsController; +import android.view.WindowManager; +import android.webkit.ConsoleMessage; +import android.webkit.JavascriptInterface; +import android.webkit.JsPromptResult; +import android.webkit.JsResult; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.EditText; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +// Android host: owns the WebView on the UI thread. Ring VM runs on a native +// worker thread; all cross-talk goes through ring_webview_android.c. +public class MainActivity extends Activity { + + private static final String TAG = "RingWebView"; + private static final long SYNC_TIMEOUT_MS = 5000; + + static { + System.loadLibrary("main"); + } + + private WebView webView; + private volatile String injectJs = ""; + private volatile boolean destroyed = false; + // Last URL / HTML (crash reload; reset on rotation). Last size (resize on change). + // UI thread only. + private String lastUrl = ""; + private String lastHtml = ""; + private int lastW = -1, lastH = -1; + // Crash-loop guard: a poison page must not rebuild forever. + private int crashCount = 0; + private long lastCrashMs = 0; + + /* ------------------------------------------------------------------ */ + /* Lifecycle */ + /* ------------------------------------------------------------------ */ + + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + Window window = getWindow(); + window.setStatusBarColor(Color.TRANSPARENT); + if (Build.VERSION.SDK_INT >= 30) { + window.setDecorFitsSystemWindows(false); + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; + window.getDecorView().setSystemUiVisibility(flags); + } + + webView = createWebView(); + setContentView(webView); + + // Dark page: keep light status-bar icons. + if (Build.VERSION.SDK_INT >= 30) { + WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + controller.setSystemBarsAppearance( + 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS); + } + } + + nativeStart(); + } + + // Fully configured WebView. Shared by onCreate and crash recovery. + // UI thread only. + private WebView createWebView() { + final WebView v = new WebView(this); + + WebSettings settings = v.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowFileAccess(true); + settings.setAllowContentAccess(true); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + + v.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return false; + } + + @Override + public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { + if (url != null) { + lastUrl = url; + // Real navigation wins over earlier setHtml content. + if (!url.equals("about:blank")) lastHtml = ""; + } + nativeOnPageStarted(url); + } + + @Override + public void onPageFinished(WebView view, String url) { + String js = injectJs; + if (js != null && js.length() > 0) { + view.evaluateJavascript(js, null); + } + nativeOnPageFinished(url); + } + + @Override + public boolean onRenderProcessGone(WebView view, android.webkit.RenderProcessGoneDetail detail) { + return handleRenderCrash(view, detail); + } + }); + + v.setWebChromeClient(new WebChromeClient() { + @Override + public void onReceivedTitle(WebView view, String title) { + nativeOnTitleChanged(title); + } + + @Override + public boolean onConsoleMessage(ConsoleMessage cm) { + Log.i(TAG, "JS: " + cm.message() + " @" + cm.sourceId() + ":" + cm.lineNumber()); + return true; + } + + @Override + public boolean onJsAlert(WebView view, String url, String message, final JsResult result) { + new AlertDialog.Builder(MainActivity.this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setMessage(message) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.confirm(); + } + }) + .setCancelable(false) + .show(); + return true; + } + + @Override + public boolean onJsConfirm(WebView view, String url, String message, final JsResult result) { + new AlertDialog.Builder(MainActivity.this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setMessage(message) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.confirm(); + } + }) + .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.cancel(); + } + }) + .setCancelable(false) + .show(); + return true; + } + + @Override + public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, + final JsPromptResult result) { + final EditText input = new EditText(MainActivity.this); + input.setText(defaultValue); + new AlertDialog.Builder(MainActivity.this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setMessage(message) + .setView(input) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.confirm(input.getText().toString()); + } + }) + .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + result.cancel(); + } + }) + .setCancelable(false) + .show(); + return true; + } + }); + + v.addJavascriptInterface(new RingBridge(), "RingBridge"); + + v.getViewTreeObserver().addOnGlobalLayoutListener(new android.view.ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int w = v.getWidth(), h = v.getHeight(); + if (w != lastW || h != lastH) { + lastW = w; + lastH = h; + if (w > 0 && h > 0) nativeOnResize(w, h); + } + } + }); + + return v; + } + + // Renderer gone: destroy the dead view, build an identical one, reload. + // Inject JS persists, so bindings come back with the page. True = handled. + // API 26+ only. + private boolean handleRenderCrash(WebView view, android.webkit.RenderProcessGoneDetail detail) { + boolean crashed = detail != null && detail.didCrash(); + Log.w(TAG, "WebView render process " + (crashed ? "crashed" : "killed by system") + + "; rebuilding view, lastUrl=" + lastUrl); + if (view != null) view.destroy(); + lastW = lastH = -1; + webView = createWebView(); + setContentView(webView); + long now = System.currentTimeMillis(); + if (now - lastCrashMs > 10000) crashCount = 0; + lastCrashMs = now; + // Same page crashing repeatedly: rebuild empty, stop reloading it. + if (++crashCount > 3) { + Log.e(TAG, "WebView crashed repeatedly; showing empty view"); + return true; + } + if (!lastHtml.isEmpty()) renderHtml(webView, lastHtml); + else if (!lastUrl.isEmpty()) webView.loadUrl(lastUrl); + return true; + } + + private void renderHtml(WebView v, String html) { + if (v != null) v.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + nativeOnFocusChanged(hasFocus); + } + + @Override + public void onBackPressed() { + if (webView != null && webView.canGoBack()) { + webView.goBack(); + } else { + nativeOnClose(); + super.onBackPressed(); + } + } + + @Override + protected void onDestroy() { + // Rotation destroys too; only a real finish kills the worker. + if (isFinishing()) { + destroyed = true; + nativeDestroy(); + } + if (webView != null) { + webView.destroy(); + webView = null; + } + super.onDestroy(); + } + + /* ------------------------------------------------------------------ */ + /* Called from native (worker thread unless noted) */ + /* ------------------------------------------------------------------ */ + + public void evalJs(final String js) { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null) webView.evaluateJavascript(js, null); + } + }); + } + + public void loadUrl(final String url) { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null) webView.loadUrl(url); + } + }); + } + + public void loadHtml(final String html) { + if (destroyed || webView == null) return; + lastHtml = (html == null) ? "" : html; + runOnUiThread(new Runnable() { + @Override + public void run() { + renderHtml(webView, html); + } + }); + } + + public void goBack() { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null && webView.canGoBack()) webView.goBack(); + } + }); + } + + public void goForward() { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null && webView.canGoForward()) webView.goForward(); + } + }); + } + + public void reload() { + if (destroyed || webView == null) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (webView != null) webView.reload(); + } + }); + } + + // Sync URL query; blocks the caller on the UI thread. + public String getUrlSync() { + if (destroyed || webView == null) return ""; + if (Looper.myLooper() == Looper.getMainLooper()) { + String u = webView.getUrl(); + return u == null ? "" : u; + } + final AtomicReference ref = new AtomicReference(""); + final CountDownLatch latch = new CountDownLatch(1); + runOnUiThread(new Runnable() { + @Override + public void run() { + String u = (webView != null) ? webView.getUrl() : null; + ref.set(u == null ? "" : u); + latch.countDown(); + } + }); + try { + boolean ok = latch.await(SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (!ok) { + Log.w(TAG, "getUrlSync timed out after " + SYNC_TIMEOUT_MS + "ms; returning \"\""); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "getUrlSync interrupted; returning \"\""); + } + return ref.get(); + } + + // Sync title query; blocks the caller on the UI thread. + public String getTitleSync() { + if (destroyed || webView == null) return ""; + if (Looper.myLooper() == Looper.getMainLooper()) { + String t = webView.getTitle(); + return t == null ? "" : t; + } + final AtomicReference ref = new AtomicReference(""); + final CountDownLatch latch = new CountDownLatch(1); + runOnUiThread(new Runnable() { + @Override + public void run() { + String t = (webView != null) ? webView.getTitle() : null; + ref.set(t == null ? "" : t); + latch.countDown(); + } + }); + try { + boolean ok = latch.await(SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (!ok) { + Log.w(TAG, "getTitleSync timed out after " + SYNC_TIMEOUT_MS + "ms; returning \"\""); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "getTitleSync interrupted; returning \"\""); + } + return ref.get(); + } + + public void setDebug(final boolean enabled) { + // setWebContentsDebuggingEnabled must run on the UI thread. + runOnUiThread(new Runnable() { + @Override + public void run() { + WebView.setWebContentsDebuggingEnabled(enabled); + } + }); + } + + // Script re-injected after every page load. + public void setInjectJs(String js) { + injectJs = (js == null) ? "" : js; + } + + public void finishApp() { + if (destroyed) return; + runOnUiThread(new Runnable() { + @Override + public void run() { + finish(); + } + }); + } + + /* ------------------------------------------------------------------ */ + /* JavaScript bridge */ + /* ------------------------------------------------------------------ */ + + // TRUST BOUNDARY: every page, remote included, can call any bound Ring + // function by name. Validate args; no privileged ops. + private class RingBridge { + @JavascriptInterface + public void call(String name, String id, String req) { + nativeOnBridgeCall(name, id, req); + } + } + + /* ------------------------------------------------------------------ */ + /* Native methods (implemented in ring_webview_android.c) */ + /* ------------------------------------------------------------------ */ + + private native void nativeStart(); + private native void nativeDestroy(); + private native void nativeOnBridgeCall(String name, String id, String req); + private native void nativeOnPageStarted(String url); + private native void nativeOnPageFinished(String url); + private native void nativeOnClose(); + private native void nativeOnTitleChanged(String title); + private native void nativeOnFocusChanged(boolean focused); + private native void nativeOnResize(int width, int height); +} diff --git a/src/android/proguard-webview.pro b/src/android/proguard-webview.pro new file mode 100644 index 0000000..8c4573a --- /dev/null +++ b/src/android/proguard-webview.pro @@ -0,0 +1,17 @@ +# Ring WebView Android backend — ProGuard / R8 keep rules. +# +# The native backend (ring_webview_android.c) looks up MainActivity methods by +# string name via GetMethodID, and the JS bridge entry point is reached by +# reflection (@JavascriptInterface). With minification enabled, R8 would rename +# or strip them and the bridge would break silently at runtime (UnsatisfiedLink +# errors never fire — calls just stop arriving). +# +# Pass this file to the APK packager (ring2apk) whenever minify is enabled. +# Without minification it is inert documentation of the reflective boundary. + +-keep class io.github.ysdragon.webview.MainActivity { + *; +} +-keepclassmembers class io.github.ysdragon.webview.MainActivity$RingBridge { + *; +} diff --git a/src/c_src/ring_webview_android.c b/src/c_src/ring_webview_android.c new file mode 100644 index 0000000..3a16f88 --- /dev/null +++ b/src/c_src/ring_webview_android.c @@ -0,0 +1,2197 @@ +/* + * ring_webview_android.c + * + * Copyright (c) Youssef Saeed + * All rights reserved. + * + * Android backend: same `webview_*` Ring C-API as ring_webview.c, + * backed by android.webkit.WebView through JNI. + * + * UI thread (Java) owns the WebView; JNI entry points only enqueue. + * Worker thread (C) owns the Ring VM and runs the job queue. + * g_on* names are worker-only; JNI enqueues raw events, never reads them. + * Flags are C11 atomics; queue is bounded + condvar (no polling). + * C->Java calls hold g_activityMutex; rotation swaps under the same lock. + * Java host is io.github.ysdragon.webview.MainActivity (static JNI naming). + * + * RingBridge is callable by any page JS, remote included. Validate bind args. + */ + +#include "ring.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "RingWebView" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +// Webview version this binding tracks. +#define RING_WEBVIEW_ANDROID_VERSION "0.12.0" + +#define RING_WEBVIEW_UI_TIMEOUT_MS 5000 + +// Provided by the app's main.c: creates the Ring state, runs the bytecode. +extern void ring_app_main(void); + +/* ============================================================================ + * Globals + * ============================================================================ + */ + +static JavaVM *g_jvm = NULL; +// Global refs: MainActivity + AssetManager. +static jobject g_activity = NULL; +static jobject g_assetMgrJava = NULL; +static AAssetManager *g_assetMgr = NULL; +static char g_basePath[1024] = {0}; +static char g_apkPath[1024] = {0}; + +/* Cached Activity method IDs */ +static jmethodID mid_evalJs = NULL; +static jmethodID mid_loadUrl = NULL; +static jmethodID mid_loadHtml = NULL; +static jmethodID mid_goBack = NULL; +static jmethodID mid_goForward = NULL; +static jmethodID mid_reload = NULL; +static jmethodID mid_getUrlSync = NULL; +static jmethodID mid_getTitleSync = NULL; +static jmethodID mid_setDebug = NULL; +static jmethodID mid_setInjectJs = NULL; +static jmethodID mid_finishApp = NULL; + +/* Cached AssetManager.list method ID */ +static jmethodID mid_assetList = NULL; + +// Set in webview_create (worker thread). +static RingState *g_ringState = NULL; + +// Atomics: shared between UI/JNI threads and the worker. +static atomic_int g_running = 0; +static atomic_int g_terminate = 0; +static atomic_int g_webviewCreated = 0; +static pthread_t g_workerThread; +// UI thread only (nativeStart), before the worker exists. +static int g_workerStarted = 0; + +// Worker-only: written by SET_EVENT, read by process_job. +static char *g_onClose = NULL; +static char *g_onLoad = NULL; +static char *g_onDomReady = NULL; +static char *g_onNavigate = NULL; +static char *g_onTitle = NULL; +static char *g_onFocus = NULL; +static char *g_onResize = NULL; + +/* Bound functions: jsName -> ringFuncName */ +#define MAX_BINDS 256 +static char *g_bindJsName[MAX_BINDS]; +static char *g_bindRingFunc[MAX_BINDS]; +static int g_bindCount = 0; + +/* Init scripts injected on every page load */ +#define MAX_INIT_SCRIPTS 64 +static char *g_initScripts[MAX_INIT_SCRIPTS]; +static int g_initScriptCount = 0; + +/* ============================================================================ + * Job queue (Java threads -> worker thread) + * ============================================================================ + */ + +// JNI enqueues; worker resolves g_on* at process time. +typedef enum +{ + JOB_BIND_CALL, /* name=jsName, id, req */ + JOB_PAGE_STARTED, /* arg=url (may be NULL) */ + JOB_PAGE_FINISHED, /* arg=url (may be NULL) */ + JOB_TITLE_CHANGED, /* arg=title */ + JOB_FOCUS_CHANGED, /* arg="true"/"false" (window focus, desktop parity) */ + JOB_RESIZED, /* arg="WIDTHxHEIGHT" */ + JOB_CLOSE, /* no arg */ + JOB_DISPATCH, /* req=Ring code */ + JOB_REATTACH, /* no arg: re-push shim+scripts (rotation) */ + JOB_TERMINATE +} JobType; + +typedef struct Job +{ + JobType type; + char *name; + char *id; + char *req; + char *arg; + struct Job *next; +} Job; + +static Job *g_queueHead = NULL; +static Job *g_queueTail = NULL; +static pthread_mutex_t g_queueMutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_queueCond = PTHREAD_COND_INITIALIZER; +// Drop page/bind jobs past this depth; TERMINATE/REATTACH always pass. +#define RING_WEBVIEW_MAX_QUEUE 4096 +// Guarded by g_queueMutex. +static int g_queueLen = 0; + +// Guards g_activity swaps (rotation) vs in-flight JNI calls. +static pthread_mutex_t g_activityMutex = PTHREAD_MUTEX_INITIALIZER; + +static void free_job(Job *job); + +static char *xstrdup(const char *s) +{ + size_t n; + char *p; + if (!s) + return NULL; + n = strlen(s); + p = (char *)malloc(n + 1); + if (p) + { + memcpy(p, s, n); + p[n] = '\0'; + } + return p; +} + +static void enqueue_job(Job *job) +{ + pthread_mutex_lock(&g_queueMutex); + if (g_queueLen >= RING_WEBVIEW_MAX_QUEUE && job->type != JOB_TERMINATE && job->type != JOB_REATTACH) + { + pthread_mutex_unlock(&g_queueMutex); + LOGE("job queue full (%d); dropping type %d", RING_WEBVIEW_MAX_QUEUE, (int)job->type); + free_job(job); + return; + } + job->next = NULL; + if (g_queueTail) + g_queueTail->next = job; + else + g_queueHead = job; + g_queueTail = job; + g_queueLen++; + pthread_cond_signal(&g_queueCond); + pthread_mutex_unlock(&g_queueMutex); +} + +// Worker pop: waits on condvar. NULL = terminated and empty. +static Job *dequeue_job_blocking(void) +{ + Job *job; + pthread_mutex_lock(&g_queueMutex); + while (!g_queueHead && !atomic_load_explicit(&g_terminate, memory_order_acquire)) + pthread_cond_wait(&g_queueCond, &g_queueMutex); + job = g_queueHead; + if (job) + { + g_queueHead = job->next; + if (!g_queueHead) + g_queueTail = NULL; + job->next = NULL; + g_queueLen--; + } + pthread_mutex_unlock(&g_queueMutex); + return job; +} + +static void queue_signal_all(void) +{ + pthread_mutex_lock(&g_queueMutex); + pthread_cond_broadcast(&g_queueCond); + pthread_mutex_unlock(&g_queueMutex); +} + +// Drain pop; NULL when empty. +static Job *try_dequeue_job(void) +{ + Job *job; + pthread_mutex_lock(&g_queueMutex); + job = g_queueHead; + if (job) + { + g_queueHead = job->next; + if (!g_queueHead) + g_queueTail = NULL; + job->next = NULL; + g_queueLen--; + } + pthread_mutex_unlock(&g_queueMutex); + return job; +} + +static void free_job(Job *job) +{ + if (!job) + return; + if (job->name) + free(job->name); + if (job->id) + free(job->id); + if (job->req) + free(job->req); + if (job->arg) + free(job->arg); + free(job); +} + +static void enqueue_bind_call(const char *name, const char *id, const char *req) +{ + Job *job = (Job *)calloc(1, sizeof(Job)); + if (!job) + return; + job->type = JOB_BIND_CALL; + job->name = xstrdup(name); + job->id = xstrdup(id); + job->req = xstrdup(req); + enqueue_job(job); +} + +static void enqueue_raw_event(JobType type, const char *arg) +{ + Job *job = (Job *)calloc(1, sizeof(Job)); + if (!job) + return; + job->type = type; + job->arg = arg ? xstrdup(arg) : NULL; + enqueue_job(job); +} + +static void enqueue_dispatch(const char *code) +{ + Job *job = (Job *)calloc(1, sizeof(Job)); + if (!job) + return; + job->type = JOB_DISPATCH; + job->req = xstrdup(code); + enqueue_job(job); +} + +static void enqueue_terminate(void) +{ + Job *job = (Job *)calloc(1, sizeof(Job)); + if (!job) + return; + job->type = JOB_TERMINATE; + enqueue_job(job); +} + +/* ============================================================================ + * JNI helpers + * ============================================================================ + */ + +static JNIEnv *get_env(void) +{ + JNIEnv *env = NULL; + jint rc; + if (!g_jvm) + return NULL; + rc = (*g_jvm)->GetEnv(g_jvm, (void **)&env, JNI_VERSION_1_6); + if (rc == JNI_EDETACHED) + { + if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != 0) + return NULL; + } + return env; +} + +// Hold g_activityMutex for the whole call; rotation swaps under the same lock. +static void call_java_void_string(jmethodID mid, const char *value) +{ + JNIEnv *env = get_env(); + jstring s; + jobject activity; + if (!env || !mid) + return; + pthread_mutex_lock(&g_activityMutex); + activity = g_activity; + if (!activity) + { + pthread_mutex_unlock(&g_activityMutex); + return; + } + s = (*env)->NewStringUTF(env, value ? value : ""); + if (!s) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("NewStringUTF OOM; dropping Java call"); + pthread_mutex_unlock(&g_activityMutex); + return; + } + (*env)->CallVoidMethod(env, activity, mid, s); + (*env)->DeleteLocalRef(env, s); + if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + } + pthread_mutex_unlock(&g_activityMutex); +} + +static void call_java_void(jmethodID mid) +{ + JNIEnv *env = get_env(); + jobject activity; + if (!env || !mid) + return; + pthread_mutex_lock(&g_activityMutex); + activity = g_activity; + if (!activity) + { + pthread_mutex_unlock(&g_activityMutex); + return; + } + (*env)->CallVoidMethod(env, activity, mid); + if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + } + pthread_mutex_unlock(&g_activityMutex); +} + +// Blocks the worker on the Java latch. Timeout reads back as "". +static char *call_java_string_sync(jmethodID mid) +{ + JNIEnv *env = get_env(); + jstring s; + const char *utf; + char *result = NULL; + jobject activity; + if (!env || !mid) + return xstrdup(""); + pthread_mutex_lock(&g_activityMutex); + activity = g_activity; + if (!activity) + { + pthread_mutex_unlock(&g_activityMutex); + return xstrdup(""); + } + s = (jstring)(*env)->CallObjectMethod(env, activity, mid); + if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + pthread_mutex_unlock(&g_activityMutex); + return xstrdup(""); + } + pthread_mutex_unlock(&g_activityMutex); + if (!s) + { + // Null = latch timeout/interrupt (Java logs too). + LOGE("sync query timed out; returning \"\""); + return xstrdup(""); + } + utf = (*env)->GetStringUTFChars(env, s, NULL); + if (!utf) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("GetStringUTFChars OOM on sync query result"); + (*env)->DeleteLocalRef(env, s); + return xstrdup(""); + } + result = xstrdup(utf); + (*env)->ReleaseStringUTFChars(env, s, utf); + (*env)->DeleteLocalRef(env, s); + return result ? result : xstrdup(""); +} + +// Escape for a single-quoted JS string. +static char *js_escape(const char *s) +{ + size_t len, cap; + char *out, *o; + const unsigned char *p; + if (!s) + return xstrdup(""); + len = strlen(s); + cap = len * 6 + 1; + out = (char *)malloc(cap); + if (!out) + return xstrdup(""); + o = out; + for (p = (const unsigned char *)s; *p; p++) + { + unsigned char c = *p; + switch (c) + { + case '\\': + *o++ = '\\'; + *o++ = '\\'; + break; + case '\'': + *o++ = '\\'; + *o++ = '\''; + break; + case '\n': + *o++ = '\\'; + *o++ = 'n'; + break; + case '\r': + *o++ = '\\'; + *o++ = 'r'; + break; + case '\t': + *o++ = '\\'; + *o++ = 't'; + break; + default: + if (c < 0x20) + o += sprintf(o, "\\x%02x", c); + else + *o++ = (char)c; + } + } + *o = '\0'; + return out; +} + +/* ============================================================================ + * JS injection shim (Promise-based bind protocol, matches desktop webview) + * ============================================================================ + */ + +static const char *BASE_SHIM = "(function(){" + "if(window.__ringwebview){return;}" + "window.__ringwebview={seq:0,pending:{}};" + "window.__ringwebview.result=function(id,status,json){" + "var p=window.__ringwebview.pending[id];" + "if(!p){return;}" + "delete window.__ringwebview.pending[id];" + "var data;try{data=JSON.parse(json);}catch(e){data=json;}" + "if(status===0){p.resolve(data);}else{p.reject(data);}" + "};" + "})();"; + +// Safe to eval anytime: shim guards re-init, wrappers re-assign. +// PREFIX + escName + SUFFIX, sized exactly. +static const char *BIND_WRAPPER_PREFIX = "(function(){var n='"; +static const char *BIND_WRAPPER_SUFFIX = + "';" + "window[n]=function(){" + "var a=Array.prototype.slice.call(arguments);" + "var id=String(++window.__ringwebview.seq);" + "return new Promise(function(res,rej){" + "window.__ringwebview.pending[id]={resolve:res,reject:rej};" + "RingBridge.call(n,id,JSON.stringify(a));" + "});};" + "})();"; + +static char *build_bind_js(void) +{ + // Escape first, then size from escaped lengths (js_escape expands up to 6x). + char *escNames[MAX_BINDS]; + size_t cap = strlen(BASE_SHIM) + 1; + char *js, *o; + int i; + if (g_bindCount < 0 || g_bindCount > MAX_BINDS) + return NULL; + for (i = 0; i < g_bindCount; i++) + { + if (!g_bindJsName[i]) + { + while (--i >= 0) + free(escNames[i]); + return NULL; + } + escNames[i] = js_escape(g_bindJsName[i]); + if (!escNames[i]) + { + while (--i >= 0) + free(escNames[i]); + return NULL; + } + cap += strlen(BIND_WRAPPER_PREFIX) + strlen(escNames[i]) + strlen(BIND_WRAPPER_SUFFIX); + } + + js = (char *)malloc(cap); + if (!js) + { + for (i = 0; i < g_bindCount; i++) + free(escNames[i]); + return NULL; + } + o = js; + // Clamp: truncation must not run past the allocation. + { + size_t rem = cap - (size_t)(o - js); + int w = snprintf(o, rem, "%s", BASE_SHIM); + o += (w < 0) ? 0 : ((size_t)w >= rem ? rem - 1 : (size_t)w); + } + + for (i = 0; i < g_bindCount; i++) + { + size_t rem = cap - (size_t)(o - js); + int w = snprintf(o, rem, "%s%s%s", BIND_WRAPPER_PREFIX, escNames[i], BIND_WRAPPER_SUFFIX); + o += (w < 0) ? 0 : ((size_t)w >= rem ? rem - 1 : (size_t)w); + free(escNames[i]); + } + return js; +} + +// Store for re-inject on page load; eval now for the current page. +static void rebuild_inject_js(void) +{ + size_t cap; + char *full, *o, *bindJs; + int i; + + bindJs = build_bind_js(); + if (!bindJs) + return; + + // +64 covers the 33-char wrapper; snprintf truncates on miscount. + cap = strlen(bindJs) + 1 + 64; + for (i = 0; i < g_initScriptCount; i++) + cap += strlen(g_initScripts[i]) + 64; + + full = (char *)malloc(cap); + if (full) + { + size_t left = cap; + o = full; + { + size_t rem = left - (size_t)(o - full); + int w = snprintf(o, rem, "%s", bindJs); + o += (w < 0) ? 0 : ((size_t)w >= rem ? rem - 1 : (size_t)w); + } + for (i = 0; i < g_initScriptCount; i++) + { + size_t rem = left - (size_t)(o - full); + int w = snprintf(o, rem, "(function(){try{%s}catch(e){}})();", g_initScripts[i]); + o += (w < 0) ? 0 : ((size_t)w >= rem ? rem - 1 : (size_t)w); + } + call_java_void_string(mid_setInjectJs, full); + free(full); + } + + call_java_void_string(mid_evalJs, bindJs); + free(bindJs); +} + +#include "ring_webview_json.h" + +/* ============================================================================ + * Ring callback execution (worker thread) + * ============================================================================ + */ + +static void call_ring_func(const char *cCallback, const char *cArg) +{ + VM *pVM; + unsigned int nSP_before, nFuncSP_before, nCallListSize_before; + + if (!g_ringState || !cCallback) + return; + pVM = g_ringState->pVM; + if (!pVM) + return; + if (!atomic_load_explicit(&g_running, memory_order_acquire)) + return; + + ring_vm_mutexlock(pVM); + + nSP_before = pVM->nSP; + nFuncSP_before = pVM->nFuncSP; + nCallListSize_before = RING_VM_FUNCCALLSCOUNT; + + if (!ring_vm_loadfunc2(pVM, (char *)cCallback, RING_FALSE)) + { + pVM->nSP = nSP_before; + pVM->nFuncSP = nFuncSP_before; + ring_vm_mutexunlock(pVM); + return; + } + + if (cArg) + { + RING_VM_STACK_PUSHCVALUE2(cArg, strlen(cArg)); + } + + ring_vm_call2(pVM); + while (RING_VM_FUNCCALLSCOUNT > nCallListSize_before) + ring_vm_fetch(pVM); + + pVM->nSP = nSP_before; + pVM->nFuncSP = nFuncSP_before; + + ring_vm_mutexunlock(pVM); +} + +static void call_ring_bind_func(const char *cFunc, const char *id, const char *req) +{ + VM *pVM; + unsigned int nSP_before, nFuncSP_before, nCallListSize_before; + + if (!g_ringState || !cFunc) + return; + pVM = g_ringState->pVM; + if (!pVM) + return; + if (!atomic_load_explicit(&g_running, memory_order_acquire)) + return; + + ring_vm_mutexlock(pVM); + + nSP_before = pVM->nSP; + nFuncSP_before = pVM->nFuncSP; + nCallListSize_before = RING_VM_FUNCCALLSCOUNT; + + if (!ring_vm_loadfunc2(pVM, (char *)cFunc, RING_FALSE)) + { + pVM->nSP = nSP_before; + pVM->nFuncSP = nFuncSP_before; + ring_vm_mutexunlock(pVM); + return; + } + + RING_VM_STACK_PUSHCVALUE2(id ? id : "", id ? strlen(id) : 0); + + // req = JSON array of JS args; pass as Ring list. + { + List *pReqList = json_decode_to_ring_list(pVM, req); + if (!pReqList) + { + LOGE("bind call \"%s\": invalid JSON request; passing an empty list", cFunc); + pReqList = ring_vm_api_newlist(pVM); + } + ring_vm_api_retlist2(pVM, pReqList, RING_OUTPUT_RETLISTBYREF); + } + + ring_vm_call2(pVM); + while (RING_VM_FUNCCALLSCOUNT > nCallListSize_before) + ring_vm_fetch(pVM); + + pVM->nSP = nSP_before; + pVM->nFuncSP = nFuncSP_before; + + ring_vm_mutexunlock(pVM); +} + +static const char *find_ring_func(const char *jsName) +{ + int i; + if (!jsName) + return NULL; + for (i = 0; i < g_bindCount; i++) + { + if (g_bindJsName[i] && strcmp(g_bindJsName[i], jsName) == 0) + return g_bindRingFunc[i]; + } + return NULL; +} + +// Worker-only state; JNI never touches it. +static void process_job(Job *job) +{ + if (!job) + return; + switch (job->type) + { + case JOB_BIND_CALL: { + const char *fn = find_ring_func(job->name); + if (fn) + call_ring_bind_func(fn, job->id, job->req); + else + LOGE("No Ring binding for JS function: %s", job->name ? job->name : "?"); + break; + } + case JOB_PAGE_STARTED: + if (g_onLoad) + call_ring_func(g_onLoad, "started"); + if (g_onNavigate && job->arg) + call_ring_func(g_onNavigate, job->arg); + break; + case JOB_PAGE_FINISHED: + if (g_onLoad) + call_ring_func(g_onLoad, "finished"); + if (g_onDomReady) + call_ring_func(g_onDomReady, NULL); + break; + case JOB_TITLE_CHANGED: + if (g_onTitle && job->arg) + call_ring_func(g_onTitle, job->arg); + break; + case JOB_FOCUS_CHANGED: + if (g_onFocus && job->arg) + call_ring_func(g_onFocus, job->arg); + break; + case JOB_RESIZED: + if (g_onResize && job->arg) + call_ring_func(g_onResize, job->arg); + break; + case JOB_CLOSE: + if (g_onClose) + call_ring_func(g_onClose, NULL); + break; + case JOB_DISPATCH: + if (g_ringState && g_ringState->pVM && job->req) + ring_vm_runcodefromthread(g_ringState->pVM, job->req); + break; + case JOB_REATTACH: + // Re-push shim+scripts; tables stay single-threaded. + rebuild_inject_js(); + break; + case JOB_TERMINATE: + atomic_store_explicit(&g_terminate, 1, memory_order_release); + break; + } +} + +/* ============================================================================ + * Asset extraction (APK assets -> filesDir) via JNI AssetManager.list() + * ============================================================================ + */ + +static void mkdirs_for(const char *path) +{ + char tmp[1024]; + char *p; + size_t len; + int n; + if (!path || !*path) + return; + n = snprintf(tmp, sizeof(tmp), "%s", path); + if (n < 0 || (size_t)n >= sizeof(tmp)) + { + LOGE("path too long, skipping mkdirs: %.64s...", path); + return; + } + len = strlen(tmp); + if (len > 0 && tmp[len - 1] == '/') + tmp[len - 1] = '\0'; + for (p = tmp + 1; *p; p++) + { + if (*p == '/') + { + *p = '\0'; + mkdir(tmp, 0755); + *p = '/'; + } + } +} + +static int asset_is_file(const char *assetPath) +{ + AAsset *asset; + if (!g_assetMgr) + return 0; + asset = AAssetManager_open(g_assetMgr, assetPath, AASSET_MODE_STREAMING); + if (asset) + { + AAsset_close(asset); + return 1; + } + return 0; +} + +static void extract_asset_file(const char *assetPath, const char *destPath) +{ + AAsset *asset; + off_t length; + char buf[8192]; + int rd; + FILE *out; + + if (!g_assetMgr) + return; + asset = AAssetManager_open(g_assetMgr, assetPath, AASSET_MODE_STREAMING); + if (!asset) + { + LOGE("Cannot open asset: %s", assetPath); + return; + } + length = AAsset_getLength(asset); + (void)length; + + mkdirs_for(destPath); + out = fopen(destPath, "wb"); + if (!out) + { + LOGE("Cannot write: %s", destPath); + AAsset_close(asset); + return; + } + while ((rd = AAsset_read(asset, buf, sizeof(buf))) > 0) + fwrite(buf, 1, rd, out); + fclose(out); + AAsset_close(asset); + LOGI("Extracted: %s", assetPath); +} + +static void extract_dir(JNIEnv *env, const char *assetDir, const char *destDir) +{ + jstring jdir; + jobjectArray entries; + jsize n, i; + + if (!env || !g_assetMgrJava || !mid_assetList) + return; + + jdir = (*env)->NewStringUTF(env, assetDir ? assetDir : ""); + if (!jdir) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("NewStringUTF OOM in extract_dir"); + return; + } + entries = (jobjectArray)(*env)->CallObjectMethod(env, g_assetMgrJava, mid_assetList, jdir); + (*env)->DeleteLocalRef(env, jdir); + if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + return; + } + if (!entries) + return; + + n = (*env)->GetArrayLength(env, entries); + for (i = 0; i < n; i++) + { + jstring jname = (jstring)(*env)->GetObjectArrayElement(env, entries, i); + const char *name; + char assetPath[1024]; + char destPath[1024]; + int na, nd; + if (!jname) + continue; + if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionClear(env); + continue; + } + name = (*env)->GetStringUTFChars(env, jname, NULL); + if (!name) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("GetStringUTFChars OOM in extract_dir"); + (*env)->DeleteLocalRef(env, jname); + continue; + } + + if (assetDir && assetDir[0]) + na = snprintf(assetPath, sizeof(assetPath), "%s/%s", assetDir, name); + else + na = snprintf(assetPath, sizeof(assetPath), "%s", name); + nd = snprintf(destPath, sizeof(destPath), "%s/%s", destDir, name); + if (na < 0 || (size_t)na >= sizeof(assetPath) || nd < 0 || (size_t)nd >= sizeof(destPath)) + { + LOGE("asset path too long, skipping: %.64s", name); + (*env)->ReleaseStringUTFChars(env, jname, name); + (*env)->DeleteLocalRef(env, jname); + continue; + } + + if (asset_is_file(assetPath)) + { + extract_asset_file(assetPath, destPath); + } + else + { + mkdir(destPath, 0755); + extract_dir(env, assetPath, destPath); + } + + (*env)->ReleaseStringUTFChars(env, jname, name); + (*env)->DeleteLocalRef(env, jname); + } + (*env)->DeleteLocalRef(env, entries); +} + +// 1 = assets present or unreadable (extract); 0 = confirmed none. +static int apk_has_assets(const char *path) +{ + FILE *f; + long fsize, scanStart, cdSize, p, i, eocd = -1; + static unsigned char buf[65557]; + unsigned char *cd; + unsigned int nEntries, e; + int found; + + if (!path || !*path) + return 1; + f = fopen(path, "rb"); + if (!f) + return 1; + if (fseek(f, 0, SEEK_END) != 0 || (fsize = ftell(f)) < 22) + { + fclose(f); + return 1; + } + + // Find EOCD scanning backwards. + scanStart = fsize - (long)sizeof(buf); + if (scanStart < 0) + scanStart = 0; + if (fseek(f, scanStart, SEEK_SET) != 0) + { + fclose(f); + return 1; + } + { + long n = (long)fread(buf, 1, sizeof(buf), f); + for (i = n - 22; i >= 0; i--) + if (buf[i] == 0x50 && buf[i + 1] == 0x4b && buf[i + 2] == 0x05 && buf[i + 3] == 0x06) + { + eocd = i; + break; + } + } + if (eocd < 0) + { + fclose(f); + return 1; + } + + nEntries = (unsigned int)(buf[eocd + 10] | (buf[eocd + 11] << 8)); + { + long cdOff = (long)((unsigned int)(buf[eocd + 16] | (buf[eocd + 17] << 8) | (buf[eocd + 18] << 16) | + ((unsigned int)buf[eocd + 19] << 24))); + if (nEntries == 0) + { + fclose(f); + return 0; + } + if (nEntries == 0xFFFF || cdOff < 0 || cdOff >= fsize) + { + fclose(f); + // zip64/malformed: extract. + return 1; + } + + cdSize = fsize - cdOff; + // Cap the transient malloc; fall back to extracting. + if (cdSize > (long)(8 * 1024 * 1024)) + { + fclose(f); + LOGI("APK central dir too large (%ld); assuming assets exist", cdSize); + return 1; + } + cd = (unsigned char *)malloc((size_t)cdSize); + if (!cd) + { + fclose(f); + return 1; + } + if (fseek(f, cdOff, SEEK_SET) != 0 || fread(cd, 1, (size_t)cdSize, f) != (size_t)cdSize) + { + free(cd); + fclose(f); + return 1; + } + fclose(f); + + found = 0; + for (e = 0, p = 0; e < nEntries; e++) + { + unsigned int nameLen, extraLen, commentLen; + if (p + 46 > cdSize) + break; + if (!(cd[p] == 0x50 && cd[p + 1] == 0x4b && cd[p + 2] == 0x01 && cd[p + 3] == 0x02)) + break; + nameLen = (unsigned int)(cd[p + 28] | (cd[p + 29] << 8)); + extraLen = (unsigned int)(cd[p + 30] | (cd[p + 31] << 8)); + commentLen = (unsigned int)(cd[p + 32] | (cd[p + 33] << 8)); + if (nameLen >= 7 && memcmp(cd + p + 46, "assets/", 7) == 0) + { + found = 1; + break; + } + p += 46 + nameLen + extraLen + commentLen; + } + free(cd); + } + return found; +} + +static void extract_assets(void) +{ + JNIEnv *env = get_env(); + if (!env) + return; + // Skip when the APK has no assets/ (merged providers would dump junk). + if (!apk_has_assets(g_apkPath)) + { + LOGI("No app assets; skipping extraction"); + return; + } + mkdir(g_basePath, 0755); + extract_dir(env, "", g_basePath); +} + +/* ============================================================================ + * stdout/stderr -> logcat pipe (so Ring `see` output is visible in logcat) + * ============================================================================ + */ + +static int g_logPipe[2]; +static pthread_t g_logThread; +static atomic_int g_loggerStarted = 0; + +static void *log_thread(void *arg) +{ + ssize_t rdsz; + char buf[256]; + (void)arg; + while ((rdsz = read(g_logPipe[0], buf, sizeof(buf) - 1)) > 0) + { + buf[rdsz] = '\0'; + __android_log_write(ANDROID_LOG_INFO, "RingOutput", buf); + } + return NULL; +} + +static void start_logger(void) +{ + // Detached thread, process lifetime; single-start for rotation. + if (atomic_exchange_explicit(&g_loggerStarted, 1, memory_order_acq_rel)) + return; + setvbuf(stdout, 0, _IOLBF, 0); + setvbuf(stderr, 0, _IONBF, 0); + if (pipe(g_logPipe) != 0) + { + atomic_store_explicit(&g_loggerStarted, 0, memory_order_release); + return; + } + dup2(g_logPipe[1], 1); + dup2(g_logPipe[1], 2); + if (pthread_create(&g_logThread, 0, log_thread, 0) != 0) + return; + pthread_detach(g_logThread); +} + +/* ============================================================================ + * Worker thread: runs the Ring VM + * ============================================================================ + */ + +static void *worker_thread(void *arg) +{ + JNIEnv *env = NULL; + + (void)arg; + if (g_jvm) + (*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL); + + LOGI("Worker started, base path: %s", g_basePath); + + start_logger(); + + extract_assets(); + if (chdir(g_basePath) != 0) + LOGE("chdir failed: %s", g_basePath); + + // Ring loading lives in the app's main.c. + ring_app_main(); + LOGI("Ring app finished"); + + // Finish the Activity if still alive. + call_java_void(mid_finishApp); + + // Worker is the last native user; drop global refs. + { + JNIEnv *cleanEnv = NULL; + if (g_jvm && (*g_jvm)->GetEnv(g_jvm, (void **)&cleanEnv, JNI_VERSION_1_6) == JNI_OK && cleanEnv) + { + pthread_mutex_lock(&g_activityMutex); + if (g_activity) + { + (*cleanEnv)->DeleteGlobalRef(cleanEnv, g_activity); + g_activity = NULL; + } + if (g_assetMgrJava) + { + (*cleanEnv)->DeleteGlobalRef(cleanEnv, g_assetMgrJava); + g_assetMgrJava = NULL; + } + pthread_mutex_unlock(&g_activityMutex); + g_assetMgr = NULL; + } + } + + if (g_jvm) + (*g_jvm)->DetachCurrentThread(g_jvm); + return NULL; +} + +// Java -> C (io.github.ysdragon.webview.MainActivity). Entry points only enqueue. + +JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) +{ + (void)reserved; + g_jvm = vm; + LOGI("ring_webview_android loaded"); + return JNI_VERSION_1_6; +} + +static void cache_activity_methods(JNIEnv *env, jobject thiz) +{ + jclass cls = (*env)->GetObjectClass(env, thiz); + if (!cls) + return; + mid_evalJs = (*env)->GetMethodID(env, cls, "evalJs", "(Ljava/lang/String;)V"); + mid_loadUrl = (*env)->GetMethodID(env, cls, "loadUrl", "(Ljava/lang/String;)V"); + mid_loadHtml = (*env)->GetMethodID(env, cls, "loadHtml", "(Ljava/lang/String;)V"); + mid_goBack = (*env)->GetMethodID(env, cls, "goBack", "()V"); + mid_goForward = (*env)->GetMethodID(env, cls, "goForward", "()V"); + mid_reload = (*env)->GetMethodID(env, cls, "reload", "()V"); + mid_getUrlSync = (*env)->GetMethodID(env, cls, "getUrlSync", "()Ljava/lang/String;"); + mid_getTitleSync = (*env)->GetMethodID(env, cls, "getTitleSync", "()Ljava/lang/String;"); + mid_setDebug = (*env)->GetMethodID(env, cls, "setDebug", "(Z)V"); + mid_setInjectJs = (*env)->GetMethodID(env, cls, "setInjectJs", "(Ljava/lang/String;)V"); + mid_finishApp = (*env)->GetMethodID(env, cls, "finishApp", "()V"); + (*env)->DeleteLocalRef(env, cls); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeStart(JNIEnv *env, jobject thiz) +{ + jobject assets; + jclass assetCls; + jobject filesDir; + jmethodID midGetFilesDir, midGetAbsPath, midGetAssets; + jstring absPath; + const char *utf; + jclass clsForPaths; + + // Rotation: worker runs, just swap to the new Activity. + if (g_workerStarted) + { + jobject newRef; + jobject oldRef; + pthread_mutex_lock(&g_activityMutex); + newRef = (*env)->NewGlobalRef(env, thiz); + if (newRef) + { + oldRef = g_activity; + g_activity = newRef; + cache_activity_methods(env, thiz); + if (oldRef) + (*env)->DeleteGlobalRef(env, oldRef); + LOGI("re-attached to new Activity after rotation"); + } + else + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("NewGlobalRef OOM on re-attach; keeping old Activity"); + } + pthread_mutex_unlock(&g_activityMutex); + // New Activity lost injectJs; re-push from worker. + enqueue_raw_event(JOB_REATTACH, NULL); + return; + } + + pthread_mutex_lock(&g_activityMutex); + g_activity = (*env)->NewGlobalRef(env, thiz); + if (!g_activity) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + pthread_mutex_unlock(&g_activityMutex); + LOGE("NewGlobalRef OOM in nativeStart"); + return; + } + cache_activity_methods(env, thiz); + pthread_mutex_unlock(&g_activityMutex); + clsForPaths = (*env)->GetObjectClass(env, thiz); + + if (!clsForPaths) + { + pthread_mutex_lock(&g_activityMutex); + if (g_activity) + { + (*env)->DeleteGlobalRef(env, g_activity); + g_activity = NULL; + } + pthread_mutex_unlock(&g_activityMutex); + LOGE("GetObjectClass failed in nativeStart"); + return; + } + // AssetManager (Java + native). + midGetAssets = (*env)->GetMethodID(env, clsForPaths, "getAssets", "()Landroid/content/res/AssetManager;"); + assets = midGetAssets ? (*env)->CallObjectMethod(env, thiz, midGetAssets) : NULL; + if (assets && !(*env)->ExceptionCheck(env)) + { + jobject assetRef = (*env)->NewGlobalRef(env, assets); + if (assetRef) + { + g_assetMgrJava = assetRef; + g_assetMgr = AAssetManager_fromJava(env, assets); + assetCls = (*env)->GetObjectClass(env, assets); + if (assetCls) + { + mid_assetList = (*env)->GetMethodID(env, assetCls, "list", "(Ljava/lang/String;)[Ljava/lang/String;"); + (*env)->DeleteLocalRef(env, assetCls); + } + } + else if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + (*env)->DeleteLocalRef(env, assets); + } + else if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + if (assets) + (*env)->DeleteLocalRef(env, assets); + } + + // Base path = filesDir. + midGetFilesDir = (*env)->GetMethodID(env, clsForPaths, "getFilesDir", "()Ljava/io/File;"); + filesDir = midGetFilesDir ? (*env)->CallObjectMethod(env, thiz, midGetFilesDir) : NULL; + if (filesDir && !(*env)->ExceptionCheck(env)) + { + jclass fileCls = (*env)->GetObjectClass(env, filesDir); + if (fileCls) + { + midGetAbsPath = (*env)->GetMethodID(env, fileCls, "getAbsolutePath", "()Ljava/lang/String;"); + absPath = midGetAbsPath ? (jstring)(*env)->CallObjectMethod(env, filesDir, midGetAbsPath) : NULL; + if (absPath && !(*env)->ExceptionCheck(env)) + { + utf = (*env)->GetStringUTFChars(env, absPath, NULL); + if (utf) + { + snprintf(g_basePath, sizeof(g_basePath), "%s", utf); + (*env)->ReleaseStringUTFChars(env, absPath, utf); + } + else if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + (*env)->DeleteLocalRef(env, absPath); + } + else if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionClear(env); + if (absPath) + (*env)->DeleteLocalRef(env, absPath); + } + (*env)->DeleteLocalRef(env, fileCls); + } + (*env)->DeleteLocalRef(env, filesDir); + } + else if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionClear(env); + if (filesDir) + (*env)->DeleteLocalRef(env, filesDir); + } + + // APK path (asset-presence check). + { + jmethodID midGetPkgPath = (*env)->GetMethodID(env, clsForPaths, "getPackageCodePath", "()Ljava/lang/String;"); + jstring pkgPath = midGetPkgPath ? (jstring)(*env)->CallObjectMethod(env, thiz, midGetPkgPath) : NULL; + if (pkgPath && !(*env)->ExceptionCheck(env)) + { + const char *putf = (*env)->GetStringUTFChars(env, pkgPath, NULL); + if (putf) + { + snprintf(g_apkPath, sizeof(g_apkPath), "%s", putf); + (*env)->ReleaseStringUTFChars(env, pkgPath, putf); + } + else if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + (*env)->DeleteLocalRef(env, pkgPath); + } + else if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionClear(env); + if (pkgPath) + (*env)->DeleteLocalRef(env, pkgPath); + } + } + (*env)->DeleteLocalRef(env, clsForPaths); + if (pthread_create(&g_workerThread, NULL, worker_thread, NULL) == 0) + g_workerStarted = 1; + else + LOGE("Failed to create worker thread"); +} + +// Java calls this only when finishing; rotation re-attaches instead. +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeDestroy(JNIEnv *env, jobject thiz) +{ + (void)env; + (void)thiz; + atomic_store_explicit(&g_terminate, 1, memory_order_release); + enqueue_terminate(); + queue_signal_all(); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeOnBridgeCall(JNIEnv *env, jobject thiz, jstring jname, + jstring jid, jstring jreq) +{ + const char *name, *id, *req; + (void)thiz; + name = jname ? (*env)->GetStringUTFChars(env, jname, NULL) : NULL; + id = jid ? (*env)->GetStringUTFChars(env, jid, NULL) : NULL; + req = jreq ? (*env)->GetStringUTFChars(env, jreq, NULL) : NULL; + if ((jname && !name) || (jid && !id) || (jreq && !req)) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("GetStringUTFChars OOM in bridge call; dropping"); + } + else + { + enqueue_bind_call(name, id, req); + } + + if (name) + (*env)->ReleaseStringUTFChars(env, jname, name); + if (id) + (*env)->ReleaseStringUTFChars(env, jid, id); + if (req) + (*env)->ReleaseStringUTFChars(env, jreq, req); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeOnPageStarted(JNIEnv *env, jobject thiz, jstring jurl) +{ + const char *url; + (void)thiz; + url = jurl ? (*env)->GetStringUTFChars(env, jurl, NULL) : NULL; + if (jurl && !url) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("GetStringUTFChars OOM in onPageStarted"); + return; + } + enqueue_raw_event(JOB_PAGE_STARTED, url); + if (url) + (*env)->ReleaseStringUTFChars(env, jurl, url); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeOnPageFinished(JNIEnv *env, jobject thiz, jstring jurl) +{ + const char *url; + (void)thiz; + url = jurl ? (*env)->GetStringUTFChars(env, jurl, NULL) : NULL; + if (jurl && !url) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("GetStringUTFChars OOM in onPageFinished"); + return; + } + enqueue_raw_event(JOB_PAGE_FINISHED, url); + if (url) + (*env)->ReleaseStringUTFChars(env, jurl, url); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeOnTitleChanged(JNIEnv *env, jobject thiz, + jstring jtitle) +{ + const char *title; + (void)thiz; + title = jtitle ? (*env)->GetStringUTFChars(env, jtitle, NULL) : NULL; + if (jtitle && !title) + { + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); + LOGE("GetStringUTFChars OOM in onTitleChanged"); + return; + } + if (title) + enqueue_raw_event(JOB_TITLE_CHANGED, title); + if (title) + (*env)->ReleaseStringUTFChars(env, jtitle, title); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeOnFocusChanged(JNIEnv *env, jobject thiz, + jboolean focused) +{ + (void)env; + (void)thiz; + enqueue_raw_event(JOB_FOCUS_CHANGED, focused ? "true" : "false"); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeOnResize(JNIEnv *env, jobject thiz, jint width, + jint height) +{ + char buf[32]; + (void)env; + (void)thiz; + snprintf(buf, sizeof(buf), "%dx%d", (int)width, (int)height); + buf[sizeof(buf) - 1] = '\0'; + enqueue_raw_event(JOB_RESIZED, buf); +} + +JNIEXPORT void JNICALL Java_io_github_ysdragon_webview_MainActivity_nativeOnClose(JNIEnv *env, jobject thiz) +{ + (void)env; + (void)thiz; + enqueue_raw_event(JOB_CLOSE, NULL); +} + +/* ============================================================================ + * Ring C-API: helpers + * ============================================================================ + */ + +static void ring_webview_android_free(void *pState, void *pPointer) +{ + if (pPointer) + ring_state_free(pState, pPointer); +} + +static int api_check_webview(void *pPointer) +{ + if (RING_API_PARACOUNT < 1) + return 0; + if (!RING_API_ISCPOINTER(1)) + return 0; + if (!atomic_load_explicit(&g_webviewCreated, memory_order_acquire)) + return 0; + (void)pPointer; + return 1; +} + +/* ============================================================================ + * Ring C-API: core lifecycle + * ============================================================================ + */ + +RING_FUNC(ring_webview_create) +{ + int debug; + int *pHandle; + + if (RING_API_PARACOUNT != 2) + { + RING_API_ERROR(RING_API_MISS2PARA); + return; + } + if (!RING_API_ISNUMBER(1)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + debug = (int)RING_API_GETNUMBER(1); + + // One webview per process; all state is global. + if (atomic_exchange_explicit(&g_webviewCreated, 1, memory_order_acq_rel)) + { + RING_API_ERROR("webview_create: Android backend supports a single webview instance"); + return; + } + g_ringState = RING_API_STATE; + atomic_store_explicit(&g_terminate, 0, memory_order_release); + + // Forward debug flag to Java. + { + JNIEnv *env = get_env(); + jobject activity; + jmethodID setDebug; + pthread_mutex_lock(&g_activityMutex); + activity = g_activity; + setDebug = mid_setDebug; + if (env && activity && setDebug) + { + (*env)->CallVoidMethod(env, activity, setDebug, debug ? JNI_TRUE : JNI_FALSE); + // Clear: a pending exception aborts the next JNI call. + if ((*env)->ExceptionCheck(env)) + { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + } + } + pthread_mutex_unlock(&g_activityMutex); + } + + // Shim now so bind works before the first page load. + rebuild_inject_js(); + + pHandle = (int *)RING_API_MALLOC(sizeof(int)); + if (!pHandle) + { + RING_API_ERROR(RING_OOM); + return; + } + *pHandle = 1; + RING_API_RETMANAGEDCPOINTER(pHandle, "webview_t", ring_webview_android_free); +} + +RING_FUNC(ring_webview_run) +{ + if (!api_check_webview(pPointer)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + + atomic_store_explicit(&g_running, 1, memory_order_release); + while (!atomic_load_explicit(&g_terminate, memory_order_acquire)) + { + Job *job = dequeue_job_blocking(); + if (job) + { + process_job(job); + free_job(job); + } + // NULL = terminated with an empty queue. + } + // Drain jobs that raced terminate; VM still alive. + for (;;) + { + Job *job = try_dequeue_job(); + if (!job) + break; + process_job(job); + free_job(job); + } + atomic_store_explicit(&g_running, 0, memory_order_release); +} + +RING_FUNC(ring_webview_terminate) +{ + if (!api_check_webview(pPointer)) + return; + atomic_store_explicit(&g_terminate, 1, memory_order_release); + enqueue_terminate(); + queue_signal_all(); + call_java_void(mid_finishApp); +} + +RING_FUNC(ring_webview_destroy) +{ + if (RING_API_PARACOUNT < 1) + return; + atomic_store_explicit(&g_terminate, 1, memory_order_release); + atomic_store_explicit(&g_running, 0, memory_order_release); + atomic_store_explicit(&g_webviewCreated, 0, memory_order_release); + enqueue_terminate(); + queue_signal_all(); + if (RING_API_ISCPOINTER(1)) + RING_API_SETNULLPOINTER(1); +} + +RING_FUNC(ring_webview_dispatch) +{ + if (RING_API_PARACOUNT != 2) + { + RING_API_ERROR(RING_API_MISS2PARA); + return; + } + if (!RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + enqueue_dispatch(RING_API_GETSTRING(2)); + // WEBVIEW_ERROR_OK. + RING_API_RETNUMBER(0); +} + +/* ============================================================================ + * Ring C-API: navigation & content + * ============================================================================ + */ + +RING_FUNC(ring_webview_navigate) +{ + if (RING_API_PARACOUNT != 2 || !RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + call_java_void_string(mid_loadUrl, RING_API_GETSTRING(2)); +} + +RING_FUNC(ring_webview_set_html) +{ + if (RING_API_PARACOUNT != 2 || !RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + call_java_void_string(mid_loadHtml, RING_API_GETSTRING(2)); +} + +RING_FUNC(ring_webview_eval) +{ + if (RING_API_PARACOUNT != 2 || !RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + call_java_void_string(mid_evalJs, RING_API_GETSTRING(2)); +} + +RING_FUNC(ring_webview_init) +{ + if (RING_API_PARACOUNT != 2 || !RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + if (g_initScriptCount < MAX_INIT_SCRIPTS) + { + g_initScripts[g_initScriptCount] = xstrdup(RING_API_GETSTRING(2)); + g_initScriptCount++; + rebuild_inject_js(); + } +} + +RING_FUNC(ring_webview_back) +{ + if (!api_check_webview(pPointer)) + return; + call_java_void(mid_goBack); + RING_API_RETNUMBER(1); +} + +RING_FUNC(ring_webview_forward) +{ + if (!api_check_webview(pPointer)) + return; + call_java_void(mid_goForward); + RING_API_RETNUMBER(1); +} + +RING_FUNC(ring_webview_reload) +{ + if (!api_check_webview(pPointer)) + return; + call_java_void(mid_reload); + RING_API_RETNUMBER(1); +} + +RING_FUNC(ring_webview_get_url) +{ + char *url; + if (!api_check_webview(pPointer)) + { + RING_API_RETSTRING(""); + return; + } + url = call_java_string_sync(mid_getUrlSync); + RING_API_RETSTRING(url ? url : ""); + if (url) + free(url); +} + +RING_FUNC(ring_webview_get_title) +{ + char *title; + if (!api_check_webview(pPointer)) + { + RING_API_RETSTRING(""); + return; + } + title = call_java_string_sync(mid_getTitleSync); + RING_API_RETSTRING(title ? title : ""); + if (title) + free(title); +} + +/* ============================================================================ + * Ring C-API: binding + * ============================================================================ + */ + +RING_FUNC(ring_webview_bind) +{ + const char *jsName; + const char *ringFunc; + char *lower; + char *p; + int *h; + + if (RING_API_PARACOUNT != 3) + { + RING_API_ERROR(RING_API_MISS3PARA); + return; + } + if (!RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2) || !RING_API_ISSTRING(3)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + jsName = RING_API_GETSTRING(2); + ringFunc = RING_API_GETSTRING(3); + + if (g_bindCount >= MAX_BINDS) + { + RING_API_RETNUMBER(-1); + return; + } + + g_bindJsName[g_bindCount] = xstrdup(jsName); + lower = xstrdup(ringFunc); + for (p = lower; p && *p; p++) + *p = (char)tolower((unsigned char)*p); + g_bindRingFunc[g_bindCount] = lower; + g_bindCount++; + + rebuild_inject_js(); + + h = (int *)RING_API_MALLOC(sizeof(int)); + if (!h) + { + RING_API_ERROR(RING_OOM); + return; + } + *h = g_bindCount; + RING_API_RETMANAGEDCPOINTER(h, "webview_bind_t", ring_webview_android_free); +} + +RING_FUNC(ring_webview_unbind) +{ + const char *jsName; + int i; + if (RING_API_PARACOUNT != 2 || !RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + jsName = RING_API_GETSTRING(2); + for (i = 0; i < g_bindCount; i++) + { + if (g_bindJsName[i] && strcmp(g_bindJsName[i], jsName) == 0) + { + free(g_bindJsName[i]); + free(g_bindRingFunc[i]); + g_bindJsName[i] = g_bindJsName[g_bindCount - 1]; + g_bindRingFunc[i] = g_bindRingFunc[g_bindCount - 1]; + g_bindCount--; + rebuild_inject_js(); + RING_API_RETNUMBER(0); + return; + } + } + RING_API_RETNUMBER(-1); +} + +RING_FUNC(ring_webview_return) +{ + const char *id; + int status; + const char *json; + char *cJsonOwned = NULL; + char *escId, *escJson, *js; + size_t cap; + + if (RING_API_PARACOUNT != 4) + { + RING_API_ERROR(RING_API_MISS4PARA); + return; + } + if (!RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2) || !RING_API_ISNUMBER(3)) + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + id = RING_API_GETSTRING(2); + status = (int)RING_API_GETNUMBER(3); + + if (RING_API_ISLIST(4)) + { + cJsonOwned = ring_list_to_json_string(RING_API_STATE, RING_API_GETLIST(4)); + if (!cJsonOwned) + { + RING_API_ERROR("Failed to generate JSON string from list."); + return; + } + json = cJsonOwned; + } + else if (RING_API_ISNUMBER(4)) + { + cJsonOwned = ring_number_to_json_string(RING_API_GETNUMBER(4)); + if (!cJsonOwned) + { + RING_API_ERROR("Failed to generate JSON number."); + return; + } + json = cJsonOwned; + } + else if (RING_API_ISSTRING(4)) + { + json = RING_API_GETSTRING(4); + } + else + { + RING_API_ERROR(RING_API_BADPARATYPE); + return; + } + + escId = js_escape(id); + escJson = js_escape(json); + // Stale wreturn: page moved on, shim not back yet. Drop; the promise died with the page. + // Fixed overhead 71 + int; +128 slack, snprintf truncates on miscount. + cap = strlen(escId) + strlen(escJson) + 128; + js = (char *)malloc(cap); + if (js) + { + snprintf(js, cap, "(function(){var w=window.__ringwebview;if(w)w.result('%s',%d,'%s');})();", escId, status, escJson); + js[cap - 1] = '\0'; + call_java_void_string(mid_evalJs, js); + free(js); + } + free(escId); + free(escJson); + if (cJsonOwned) + free(cJsonOwned); +} + +RING_FUNC(ring_webview_version) +{ + if (RING_API_PARACOUNT != 0) + { + RING_API_ERROR(RING_API_BADPARACOUNT); + return; + } + RING_API_RETSTRING(RING_WEBVIEW_ANDROID_VERSION); +} + +/* ============================================================================ + * Ring C-API: event callbacks + * ============================================================================ + */ + +#define RING_WEBVIEW_ANDROID_SET_EVENT(funcname, member) \ + RING_FUNC(funcname) \ + { \ + if (RING_API_PARACOUNT != 2) \ + { \ + RING_API_ERROR(RING_API_MISS2PARA); \ + return; \ + } \ + if (!RING_API_ISCPOINTER(1) || !RING_API_ISSTRING(2)) \ + { \ + RING_API_ERROR(RING_API_BADPARATYPE); \ + return; \ + } \ + if (member) \ + { \ + free(member); \ + member = NULL; \ + } \ + member = xstrdup(RING_API_GETSTRING(2)); \ + RING_API_RETNUMBER(1); \ + } + +RING_WEBVIEW_ANDROID_SET_EVENT(ring_webview_on_close, g_onClose) +RING_WEBVIEW_ANDROID_SET_EVENT(ring_webview_on_resize, g_onResize) +RING_WEBVIEW_ANDROID_SET_EVENT(ring_webview_on_focus, g_onFocus) +RING_WEBVIEW_ANDROID_SET_EVENT(ring_webview_on_dom_ready, g_onDomReady) +RING_WEBVIEW_ANDROID_SET_EVENT(ring_webview_on_load, g_onLoad) +RING_WEBVIEW_ANDROID_SET_EVENT(ring_webview_on_navigate, g_onNavigate) +RING_WEBVIEW_ANDROID_SET_EVENT(ring_webview_on_title, g_onTitle) + +/* ============================================================================ + * Ring C-API: window-management no-ops (desktop parity, safe on Android) + * ============================================================================ + */ + +#define RING_WEBVIEW_ANDROID_NOOP_RET1(funcname) \ + RING_FUNC(funcname) \ + { \ + RING_API_RETNUMBER(1); \ + } + +#define RING_WEBVIEW_ANDROID_NOOP_RET0(funcname) \ + RING_FUNC(funcname) \ + { \ + RING_API_RETNUMBER(0); \ + } + +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_title) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_size) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_decorated) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_opacity) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_always_on_top) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_minimize) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_maximize) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_restore) +RING_WEBVIEW_ANDROID_NOOP_RET0(ring_webview_is_maximized) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_start_drag) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_position) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_focus) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_hide) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_show) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_start_resize) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_fullscreen) +RING_WEBVIEW_ANDROID_NOOP_RET0(ring_webview_is_fullscreen) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_resizable) +RING_WEBVIEW_ANDROID_NOOP_RET0(ring_webview_is_resizable) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_min_size) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_max_size) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_background_color) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_icon) +RING_WEBVIEW_ANDROID_NOOP_RET0(ring_webview_is_focused) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_is_visible) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_close) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_dev_tools) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_context_menu) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_force_dark) +RING_WEBVIEW_ANDROID_NOOP_RET0(ring_webview_is_force_dark) +RING_WEBVIEW_ANDROID_NOOP_RET1(ring_webview_set_click_through) +RING_WEBVIEW_ANDROID_NOOP_RET0(ring_webview_is_click_through) + +RING_FUNC(ring_webview_get_window) +{ + RING_API_RETCPOINTER(NULL, "void"); +} + +RING_FUNC(ring_webview_get_native_handle) +{ + RING_API_RETCPOINTER(NULL, "void"); +} + +RING_FUNC(ring_webview_get_position) +{ + List *pList = ring_list_new(0); + ring_list_adddouble_gc(RING_API_STATE, pList, 0); + ring_list_adddouble_gc(RING_API_STATE, pList, 0); + RING_API_RETLIST(pList); +} + +RING_FUNC(ring_webview_get_size) +{ + List *pList = ring_list_new(0); + ring_list_adddouble_gc(RING_API_STATE, pList, 0); + ring_list_adddouble_gc(RING_API_STATE, pList, 0); + RING_API_RETLIST(pList); +} + +RING_FUNC(ring_webview_get_screens) +{ + List *pList = ring_list_new(0); + RING_API_RETLIST(pList); +} + +/* ============================================================================ + * Ring C-API: constants + * ============================================================================ + */ + +RING_FUNC(ring_get_webview_hint_none) +{ + RING_API_RETNUMBER(0); +} +RING_FUNC(ring_get_webview_hint_min) +{ + RING_API_RETNUMBER(1); +} +RING_FUNC(ring_get_webview_hint_max) +{ + RING_API_RETNUMBER(2); +} +RING_FUNC(ring_get_webview_hint_fixed) +{ + RING_API_RETNUMBER(3); +} +RING_FUNC(ring_get_webview_native_handle_kind_ui_window) +{ + RING_API_RETNUMBER(0); +} +RING_FUNC(ring_get_webview_native_handle_kind_ui_widget) +{ + RING_API_RETNUMBER(1); +} +RING_FUNC(ring_get_webview_native_handle_kind_browser_controller) +{ + RING_API_RETNUMBER(2); +} +RING_FUNC(ring_get_webview_error_ok) +{ + RING_API_RETNUMBER(0); +} +RING_FUNC(ring_get_webview_error_unspecified) +{ + RING_API_RETNUMBER(1); +} +RING_FUNC(ring_get_webview_error_invalid_argument) +{ + RING_API_RETNUMBER(2); +} +RING_FUNC(ring_get_webview_error_invalid_state) +{ + RING_API_RETNUMBER(3); +} +RING_FUNC(ring_get_webview_error_canceled) +{ + RING_API_RETNUMBER(4); +} +RING_FUNC(ring_get_webview_error_missing_dependency) +{ + RING_API_RETNUMBER(5); +} +RING_FUNC(ring_get_webview_error_duplicate) +{ + RING_API_RETNUMBER(6); +} +RING_FUNC(ring_get_webview_error_not_found) +{ + RING_API_RETNUMBER(7); +} +RING_FUNC(ring_get_webview_version_major) +{ + RING_API_RETNUMBER(0); +} +RING_FUNC(ring_get_webview_version_minor) +{ + RING_API_RETNUMBER(12); +} +RING_FUNC(ring_get_webview_version_patch) +{ + RING_API_RETNUMBER(0); +} +RING_FUNC(ring_get_webview_edge_top) +{ + RING_API_RETNUMBER(0); +} +RING_FUNC(ring_get_webview_edge_bottom) +{ + RING_API_RETNUMBER(1); +} +RING_FUNC(ring_get_webview_edge_left) +{ + RING_API_RETNUMBER(2); +} +RING_FUNC(ring_get_webview_edge_right) +{ + RING_API_RETNUMBER(3); +} +RING_FUNC(ring_get_webview_edge_top_left) +{ + RING_API_RETNUMBER(4); +} +RING_FUNC(ring_get_webview_edge_top_right) +{ + RING_API_RETNUMBER(5); +} +RING_FUNC(ring_get_webview_edge_bottom_left) +{ + RING_API_RETNUMBER(6); +} +RING_FUNC(ring_get_webview_edge_bottom_right) +{ + RING_API_RETNUMBER(7); +} + +/* ============================================================================ + * Library initialization + * ============================================================================ + */ + +RING_LIBINIT +{ + /* Core lifecycle */ + RING_API_REGISTER("webview_create", ring_webview_create); + RING_API_REGISTER("webview_destroy", ring_webview_destroy); + RING_API_REGISTER("webview_run", ring_webview_run); + RING_API_REGISTER("webview_terminate", ring_webview_terminate); + RING_API_REGISTER("webview_dispatch", ring_webview_dispatch); + RING_API_REGISTER("webview_version", ring_webview_version); + + /* Navigation & content */ + RING_API_REGISTER("webview_navigate", ring_webview_navigate); + RING_API_REGISTER("webview_set_html", ring_webview_set_html); + RING_API_REGISTER("webview_eval", ring_webview_eval); + RING_API_REGISTER("webview_init", ring_webview_init); + RING_API_REGISTER("webview_back", ring_webview_back); + RING_API_REGISTER("webview_forward", ring_webview_forward); + RING_API_REGISTER("webview_reload", ring_webview_reload); + RING_API_REGISTER("webview_get_url", ring_webview_get_url); + RING_API_REGISTER("webview_get_title", ring_webview_get_title); + + /* Binding */ + RING_API_REGISTER("webview_bind", ring_webview_bind); + RING_API_REGISTER("webview_unbind", ring_webview_unbind); + RING_API_REGISTER("webview_return", ring_webview_return); + + /* Handles */ + RING_API_REGISTER("webview_get_window", ring_webview_get_window); + RING_API_REGISTER("webview_get_native_handle", ring_webview_get_native_handle); + + /* Window management */ + RING_API_REGISTER("webview_set_title", ring_webview_set_title); + RING_API_REGISTER("webview_set_size", ring_webview_set_size); + RING_API_REGISTER("webview_set_decorated", ring_webview_set_decorated); + RING_API_REGISTER("webview_set_opacity", ring_webview_set_opacity); + RING_API_REGISTER("webview_set_always_on_top", ring_webview_set_always_on_top); + RING_API_REGISTER("webview_minimize", ring_webview_minimize); + RING_API_REGISTER("webview_maximize", ring_webview_maximize); + RING_API_REGISTER("webview_restore", ring_webview_restore); + RING_API_REGISTER("webview_is_maximized", ring_webview_is_maximized); + RING_API_REGISTER("webview_start_drag", ring_webview_start_drag); + RING_API_REGISTER("webview_set_position", ring_webview_set_position); + RING_API_REGISTER("webview_get_position", ring_webview_get_position); + RING_API_REGISTER("webview_get_size", ring_webview_get_size); + RING_API_REGISTER("webview_focus", ring_webview_focus); + RING_API_REGISTER("webview_hide", ring_webview_hide); + RING_API_REGISTER("webview_show", ring_webview_show); + RING_API_REGISTER("webview_start_resize", ring_webview_start_resize); + RING_API_REGISTER("webview_set_fullscreen", ring_webview_set_fullscreen); + RING_API_REGISTER("webview_is_fullscreen", ring_webview_is_fullscreen); + RING_API_REGISTER("webview_set_resizable", ring_webview_set_resizable); + RING_API_REGISTER("webview_is_resizable", ring_webview_is_resizable); + RING_API_REGISTER("webview_set_min_size", ring_webview_set_min_size); + RING_API_REGISTER("webview_set_max_size", ring_webview_set_max_size); + RING_API_REGISTER("webview_set_background_color", ring_webview_set_background_color); + RING_API_REGISTER("webview_set_icon", ring_webview_set_icon); + RING_API_REGISTER("webview_is_focused", ring_webview_is_focused); + RING_API_REGISTER("webview_is_visible", ring_webview_is_visible); + RING_API_REGISTER("webview_close", ring_webview_close); + RING_API_REGISTER("webview_set_dev_tools", ring_webview_set_dev_tools); + RING_API_REGISTER("webview_set_context_menu", ring_webview_set_context_menu); + RING_API_REGISTER("webview_set_force_dark", ring_webview_set_force_dark); + RING_API_REGISTER("webview_is_force_dark", ring_webview_is_force_dark); + RING_API_REGISTER("webview_get_screens", ring_webview_get_screens); + RING_API_REGISTER("webview_set_click_through", ring_webview_set_click_through); + RING_API_REGISTER("webview_is_click_through", ring_webview_is_click_through); + + /* Event callbacks */ + RING_API_REGISTER("webview_on_close", ring_webview_on_close); + RING_API_REGISTER("webview_on_resize", ring_webview_on_resize); + RING_API_REGISTER("webview_on_focus", ring_webview_on_focus); + RING_API_REGISTER("webview_on_dom_ready", ring_webview_on_dom_ready); + RING_API_REGISTER("webview_on_load", ring_webview_on_load); + RING_API_REGISTER("webview_on_navigate", ring_webview_on_navigate); + RING_API_REGISTER("webview_on_title", ring_webview_on_title); + + /* Constants */ + RING_API_REGISTER("get_webview_hint_none", ring_get_webview_hint_none); + RING_API_REGISTER("get_webview_hint_min", ring_get_webview_hint_min); + RING_API_REGISTER("get_webview_hint_max", ring_get_webview_hint_max); + RING_API_REGISTER("get_webview_hint_fixed", ring_get_webview_hint_fixed); + RING_API_REGISTER("get_webview_native_handle_kind_ui_window", ring_get_webview_native_handle_kind_ui_window); + RING_API_REGISTER("get_webview_native_handle_kind_ui_widget", ring_get_webview_native_handle_kind_ui_widget); + RING_API_REGISTER("get_webview_native_handle_kind_browser_controller", + ring_get_webview_native_handle_kind_browser_controller); + RING_API_REGISTER("get_webview_error_ok", ring_get_webview_error_ok); + RING_API_REGISTER("get_webview_error_unspecified", ring_get_webview_error_unspecified); + RING_API_REGISTER("get_webview_error_invalid_argument", ring_get_webview_error_invalid_argument); + RING_API_REGISTER("get_webview_error_invalid_state", ring_get_webview_error_invalid_state); + RING_API_REGISTER("get_webview_error_canceled", ring_get_webview_error_canceled); + RING_API_REGISTER("get_webview_error_missing_dependency", ring_get_webview_error_missing_dependency); + RING_API_REGISTER("get_webview_error_duplicate", ring_get_webview_error_duplicate); + RING_API_REGISTER("get_webview_error_not_found", ring_get_webview_error_not_found); + RING_API_REGISTER("get_webview_version_major", ring_get_webview_version_major); + RING_API_REGISTER("get_webview_version_minor", ring_get_webview_version_minor); + RING_API_REGISTER("get_webview_version_patch", ring_get_webview_version_patch); + RING_API_REGISTER("get_webview_edge_top", ring_get_webview_edge_top); + RING_API_REGISTER("get_webview_edge_bottom", ring_get_webview_edge_bottom); + RING_API_REGISTER("get_webview_edge_left", ring_get_webview_edge_left); + RING_API_REGISTER("get_webview_edge_right", ring_get_webview_edge_right); + RING_API_REGISTER("get_webview_edge_top_left", ring_get_webview_edge_top_left); + RING_API_REGISTER("get_webview_edge_top_right", ring_get_webview_edge_top_right); + RING_API_REGISTER("get_webview_edge_bottom_left", ring_get_webview_edge_bottom_left); + RING_API_REGISTER("get_webview_edge_bottom_right", ring_get_webview_edge_bottom_right); +}