docs(android-sdk): 添加 Android SDK 完整文档与模块配置

- 新增 Android SDK 主文档,包含模块结构、集成方式、快速开始指南
- 添加 sdk-core、sdk-im、sdk-push、sdk-update、sdk-webview 各模块详细说明
- 配置各模块的 build.gradle.kts 文件,设置依赖和发布选项
- 更新 gradle.properties 版本配置和编译参数
- 重构 XWebViewView 组件,增加相机权限、文件选择、下载拦截功能
- 添加 XWebViewTypes.kt 定义配置类和控制器接口
- 集成 Flutter WebView 桥接代码,实现跨平台功能对齐
这个提交包含在:
XuqmGroup 2026-05-11 15:21:54 +08:00
父节点 3937c29552
当前提交 4fe7678e07
共有 10 个文件被更改,包括 262 次插入30 次删除

查看文件

@ -42,11 +42,11 @@ NEXUS_PASSWORD=your_password
引入依赖:
```kotlin
dependencies {
implementation("com.xuqm:sdk-core:0.4.0")
implementation("com.xuqm:sdk-im:0.4.0") // 可选
implementation("com.xuqm:sdk-push:0.4.0") // 可选
implementation("com.xuqm:sdk-update:0.4.0") // 可选
implementation("com.xuqm:sdk-webview:0.4.0") // 可选
implementation("com.xuqm:sdk-core:0.4.2")
implementation("com.xuqm:sdk-im:0.4.2") // 可选
implementation("com.xuqm:sdk-push:0.4.2") // 可选
implementation("com.xuqm:sdk-update:0.4.2") // 可选
implementation("com.xuqm:sdk-webview:0.4.2") // 可选
}
```

查看文件

@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
PUBLISH_VERSION=0.4.1
PUBLISH_VERSION=0.4.3

查看文件

@ -25,7 +25,9 @@ android {
}
publishing {
singleVariant("release")
singleVariant("release") {
withSourcesJar()
}
}
}

查看文件

@ -19,7 +19,9 @@ android {
}
publishing {
singleVariant("release")
singleVariant("release") {
withSourcesJar()
}
}
}

查看文件

@ -22,7 +22,9 @@ android {
}
publishing {
singleVariant("release")
singleVariant("release") {
withSourcesJar()
}
}
}

查看文件

@ -19,7 +19,9 @@ android {
}
publishing {
singleVariant("release")
singleVariant("release") {
withSourcesJar()
}
}
}

查看文件

@ -24,7 +24,9 @@ android {
}
publishing {
singleVariant("release")
singleVariant("release") {
withSourcesJar()
}
}
}

查看文件

@ -21,19 +21,9 @@ object XWebViewControl : XWebViewController {
override fun canGoBack(): Boolean = currentController?.canGoBack() ?: false
override fun canGoForward(): Boolean = currentController?.canGoForward() ?: false
override fun currentUrl(): String? = currentController?.currentUrl()
override fun goBack() {
currentController?.goBack()
}
override fun goForward() {
currentController?.goForward()
}
override fun reload() {
currentController?.reload()
}
override fun loadUrl(url: String) {
currentController?.loadUrl(url)
}
override fun goBack() { currentController?.goBack() }
override fun goForward() { currentController?.goForward() }
override fun reload() { currentController?.reload() }
override fun loadUrl(url: String) { currentController?.loadUrl(url) }
override fun postMessageToWeb(js: String) { currentController?.postMessageToWeb(js) }
}

查看文件

@ -6,6 +6,8 @@ data class XWebViewConfig(
val hideToolbar: Boolean = false,
val hideStatusBar: Boolean = false,
val userAgent: String? = null,
val injectedJavaScript: String? = null,
val onMessage: ((String) -> Unit)? = null,
)
interface XWebViewController {
@ -16,4 +18,5 @@ interface XWebViewController {
fun goForward()
fun reload()
fun loadUrl(url: String)
fun postMessageToWeb(js: String)
}

查看文件

@ -1,19 +1,122 @@
package com.xuqm.sdk.webview
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebViewClient
import android.webkit.WebView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import java.io.File
// JS injected into every page to bridge dialog APIs and download interception.
// Uses addJavascriptInterface("ReactNativeWebView") for the JS→Native channel.
private val DIALOG_OVERRIDE_JS = """
(function() {
function post(obj) {
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
}
window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); };
window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; };
window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; };
window.open = function(url) {
if (url) { window.location.href = url; }
return null;
};
URL.revokeObjectURL = function() {};
function readBlobAndPost(blobUrl, filename) {
fetch(blobUrl)
.then(function(r) { return r.blob(); })
.then(function(blob) {
var reader = new FileReader();
reader.onloadend = function() {
var b64 = reader.result.split(',')[1];
post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 });
};
reader.readAsDataURL(blob);
})
.catch(function(err) {
post({ __xwv: 'bloberror', msg: String(err) });
});
}
var DL_RE = "\\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\\?|#|${'$'})";
var dlRe = new RegExp(DL_RE, 'i');
function tryInterceptAnchor(el, e) {
if (!el || el.tagName !== 'A') return false;
var href = el.href || el.getAttribute('href') || '';
if (!href || href.indexOf('javascript') === 0) return false;
var hasDownloadAttr = el.hasAttribute('download');
var dlName = el.getAttribute('download') || '';
var isDL = hasDownloadAttr || dlRe.test(href);
if (isDL) {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (href.startsWith('blob:')) {
readBlobAndPost(href, dlName);
} else {
post({ __xwv: 'download', url: href, filename: dlName });
}
return true;
}
return false;
}
document.addEventListener('click', function(e) {
var el = e.target;
while (el && el.tagName !== 'A') { el = el.parentElement; }
tryInterceptAnchor(el, e);
}, true);
var _origClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function() {
if (!tryInterceptAnchor(this, null)) {
_origClick.call(this);
}
};
})();
true;
""".trimIndent()
private fun buildInjectedJs(config: XWebViewConfig): String =
DIALOG_OVERRIDE_JS + "\n" + (config.injectedJavaScript ?: "") + "\ntrue;"
// Routes window.ReactNativeWebView.postMessage() calls to [onMessage].
// @JavascriptInterface methods are called on a background thread; we post to main.
private class XWebViewJsBridge(
private val mainHandler: Handler,
private val onMessage: () -> ((String) -> Unit)?,
) {
@JavascriptInterface
fun postMessage(data: String) {
mainHandler.post { onMessage()?.invoke(data) }
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
@ -21,8 +124,70 @@ fun XWebViewView(
modifier: Modifier = Modifier,
config: XWebViewConfig = getXWebViewConfig(),
) {
val context = LocalContext.current
var webView by remember { mutableStateOf<WebView?>(null) }
var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { null }) }
// Keep onMessage ref up-to-date across recompositions without recreating the bridge object.
val onMessageRef = remember { mutableStateOf(config.onMessage) }
SideEffect { onMessageRef.value = config.onMessage }
val mainHandler = remember { Handler(Looper.getMainLooper()) }
// WebRTC getUserMedia() camera permission
val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) }
val webRtcPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
val req = pendingWebRtcRequest.value
pendingWebRtcRequest.value = null
if (granted) req?.grant(req.resources) else req?.deny()
}
// <input type="file" capture> camera capture
val pendingFileCallback = remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
val pendingCameraUri = remember { mutableStateOf<Uri?>(null) }
val takePictureLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
) { success ->
val cb = pendingFileCallback.value
val uri = pendingCameraUri.value
pendingFileCallback.value = null
pendingCameraUri.value = null
cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null)
}
val pickContentLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
val cb = pendingFileCallback.value
pendingFileCallback.value = null
cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null)
}
val shouldLaunchCamera = remember { mutableStateOf(false) }
val fileCameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
val launch = shouldLaunchCamera.value
shouldLaunchCamera.value = false
if (granted && launch) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", imageFile)
pendingCameraUri.value = uri
takePictureLauncher.launch(uri)
}.onFailure {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = null
}
} else {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = null
}
}
DisposableEffect(Unit) {
onDispose {
if (getXWebViewController() != null) {
@ -35,21 +200,82 @@ fun XWebViewView(
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
factory = { ctx ->
WebView(ctx).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
config.userAgent?.let { settings.userAgentString = it }
// JS → Native bridge. Must be added before loadUrl.
addJavascriptInterface(
XWebViewJsBridge(mainHandler) { onMessageRef.value },
"ReactNativeWebView",
)
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
currentUrl = url ?: view?.url?.toString()
currentUrl = url ?: view?.url
super.onPageFinished(view, url)
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
view?.evaluateJavascript(buildInjectedJs(config), null)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false
}
}
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources) {
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
request.grant(request.resources)
} else {
pendingWebRtcRequest.value = request
webRtcPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} else {
request.deny()
}
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams,
): Boolean {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = filePathCallback
val isCameraCapture = fileChooserParams.isCaptureEnabled &&
fileChooserParams.acceptTypes.any { it.contains("image") }
if (isCameraCapture) {
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", ctx.cacheDir)
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", imageFile)
pendingCameraUri.value = uri
takePictureLauncher.launch(uri)
}.onFailure {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = null
}
} else {
shouldLaunchCamera.value = true
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} else {
val mimeType = fileChooserParams.acceptTypes
.firstOrNull { it.isNotBlank() } ?: "image/*"
pickContentLauncher.launch(mimeType)
}
return true
}
}
if (config.url.isNotBlank()) {
loadUrl(config.url)
}
@ -81,6 +307,9 @@ fun XWebViewView(
override fun loadUrl(url: String) {
view.loadUrl(url)
}
override fun postMessageToWeb(js: String) {
view.evaluateJavascript(js, null)
}
})
}
}