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 桥接代码,实现跨平台功能对齐
这个提交包含在:
父节点
3937c29552
当前提交
4fe7678e07
10
README.md
10
README.md
@ -42,11 +42,11 @@ NEXUS_PASSWORD=your_password
|
|||||||
引入依赖:
|
引入依赖:
|
||||||
```kotlin
|
```kotlin
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.xuqm:sdk-core:0.4.0")
|
implementation("com.xuqm:sdk-core:0.4.2")
|
||||||
implementation("com.xuqm:sdk-im:0.4.0") // 可选
|
implementation("com.xuqm:sdk-im:0.4.2") // 可选
|
||||||
implementation("com.xuqm:sdk-push:0.4.0") // 可选
|
implementation("com.xuqm:sdk-push:0.4.2") // 可选
|
||||||
implementation("com.xuqm:sdk-update:0.4.0") // 可选
|
implementation("com.xuqm:sdk-update:0.4.2") // 可选
|
||||||
implementation("com.xuqm:sdk-webview:0.4.0") // 可选
|
implementation("com.xuqm:sdk-webview:0.4.2") // 可选
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
PUBLISH_VERSION=0.4.1
|
PUBLISH_VERSION=0.4.3
|
||||||
|
|||||||
@ -25,7 +25,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
singleVariant("release")
|
singleVariant("release") {
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
singleVariant("release")
|
singleVariant("release") {
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
singleVariant("release")
|
singleVariant("release") {
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
singleVariant("release")
|
singleVariant("release") {
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
singleVariant("release")
|
singleVariant("release") {
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,19 +21,9 @@ object XWebViewControl : XWebViewController {
|
|||||||
override fun canGoBack(): Boolean = currentController?.canGoBack() ?: false
|
override fun canGoBack(): Boolean = currentController?.canGoBack() ?: false
|
||||||
override fun canGoForward(): Boolean = currentController?.canGoForward() ?: false
|
override fun canGoForward(): Boolean = currentController?.canGoForward() ?: false
|
||||||
override fun currentUrl(): String? = currentController?.currentUrl()
|
override fun currentUrl(): String? = currentController?.currentUrl()
|
||||||
override fun goBack() {
|
override fun goBack() { currentController?.goBack() }
|
||||||
currentController?.goBack()
|
override fun goForward() { currentController?.goForward() }
|
||||||
}
|
override fun reload() { currentController?.reload() }
|
||||||
|
override fun loadUrl(url: String) { currentController?.loadUrl(url) }
|
||||||
override fun goForward() {
|
override fun postMessageToWeb(js: String) { currentController?.postMessageToWeb(js) }
|
||||||
currentController?.goForward()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reload() {
|
|
||||||
currentController?.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadUrl(url: String) {
|
|
||||||
currentController?.loadUrl(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ data class XWebViewConfig(
|
|||||||
val hideToolbar: Boolean = false,
|
val hideToolbar: Boolean = false,
|
||||||
val hideStatusBar: Boolean = false,
|
val hideStatusBar: Boolean = false,
|
||||||
val userAgent: String? = null,
|
val userAgent: String? = null,
|
||||||
|
val injectedJavaScript: String? = null,
|
||||||
|
val onMessage: ((String) -> Unit)? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface XWebViewController {
|
interface XWebViewController {
|
||||||
@ -16,4 +18,5 @@ interface XWebViewController {
|
|||||||
fun goForward()
|
fun goForward()
|
||||||
fun reload()
|
fun reload()
|
||||||
fun loadUrl(url: String)
|
fun loadUrl(url: String)
|
||||||
|
fun postMessageToWeb(js: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,122 @@
|
|||||||
package com.xuqm.sdk.webview
|
package com.xuqm.sdk.webview
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
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.WebResourceRequest
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
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")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
@Composable
|
@Composable
|
||||||
@ -21,8 +124,70 @@ fun XWebViewView(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
config: XWebViewConfig = getXWebViewConfig(),
|
config: XWebViewConfig = getXWebViewConfig(),
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { 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) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
if (getXWebViewController() != null) {
|
if (getXWebViewController() != null) {
|
||||||
@ -35,21 +200,82 @@ fun XWebViewView(
|
|||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
factory = { context ->
|
factory = { ctx ->
|
||||||
WebView(context).apply {
|
WebView(ctx).apply {
|
||||||
settings.javaScriptEnabled = true
|
settings.javaScriptEnabled = true
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
|
settings.useWideViewPort = true
|
||||||
|
settings.loadWithOverviewMode = true
|
||||||
config.userAgent?.let { settings.userAgentString = it }
|
config.userAgent?.let { settings.userAgentString = it }
|
||||||
|
|
||||||
|
// JS → Native bridge. Must be added before loadUrl.
|
||||||
|
addJavascriptInterface(
|
||||||
|
XWebViewJsBridge(mainHandler) { onMessageRef.value },
|
||||||
|
"ReactNativeWebView",
|
||||||
|
)
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
currentUrl = url ?: view?.url?.toString()
|
currentUrl = url ?: view?.url
|
||||||
super.onPageFinished(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 {
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
return false
|
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()) {
|
if (config.url.isNotBlank()) {
|
||||||
loadUrl(config.url)
|
loadUrl(config.url)
|
||||||
}
|
}
|
||||||
@ -81,6 +307,9 @@ fun XWebViewView(
|
|||||||
override fun loadUrl(url: String) {
|
override fun loadUrl(url: String) {
|
||||||
view.loadUrl(url)
|
view.loadUrl(url)
|
||||||
}
|
}
|
||||||
|
override fun postMessageToWeb(js: String) {
|
||||||
|
view.evaluateJavascript(js, null)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户