Browse Source

Merge branch 'feture/browser' into develop

cc12458 1 month ago
parent
commit
0d6e5bb5ce

+ 1 - 0
app/build.gradle.kts

@@ -97,4 +97,5 @@ dependencies {
   implementation(project(":core"))
   implementation(project(":library:browser"))
   implementation(project(":library:device:pulse"))
+  implementation(libs.gson)
 }

+ 33 - 1
app/src/main/java/com/hzliuzhi/applet/container/MainActivity.kt

@@ -1,6 +1,8 @@
 package com.hzliuzhi.applet.container
 
+import android.annotation.SuppressLint
 import android.os.Bundle
+import android.view.KeyEvent
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.LocalActivity
 import androidx.activity.compose.setContent
@@ -11,12 +13,20 @@ import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
 import androidx.navigation.NavHostController
 import androidx.navigation.compose.rememberNavController
+import com.hzliuzhi.applet.browser.webview.WebViewBridge
 import com.hzliuzhi.applet.container.navigation.Host
+import com.hzliuzhi.applet.container.scanner.ScannerListener
+import com.hzliuzhi.applet.container.scanner.rememberScannerListener
+import com.hzliuzhi.applet.core.shared.Event
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
 import com.hzliuzhi.applet.core.theme.SixTheme
 import com.hzliuzhi.applet.device.pulse.PulseEventHandle
 
 class MainActivity : ComponentActivity() {
   private var navController: NavHostController? = null
+  private var scanner: ScannerListener? = null
+
   override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
     enableEdgeToEdge()
@@ -32,7 +42,29 @@ class MainActivity : ComponentActivity() {
         }
       }
 
-      LocalActivity.current?.also { PulseEventHandle(it, rememberCoroutineScope()) }
+      LocalActivity.current?.also {
+        scanner = rememberScannerListener { result ->
+          val code = result.code
+          code.takeIf { code.isNotEmpty() }?.also {
+            val message = Payload.data(data = result).toEvent()?.let { payload ->
+              WebViewBridge.Message(type = "scan", payload = payload)
+            }?.let { message ->
+              Event<WebViewBridge.Message, String>(
+                type = "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:js",
+                payload = message
+              )
+            }?.let { event ->
+              SharedFlowHub.emit(event)
+            }
+          }
+        }
+        PulseEventHandle(it, rememberCoroutineScope())
+      }
     }
   }
+
+  @SuppressLint("RestrictedApi")
+  override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+    return scanner?.dispatchKeyEvent(event)?.takeIf { it } ?: super.dispatchKeyEvent(event)
+  }
 }

+ 7 - 0
app/src/main/java/com/hzliuzhi/applet/container/scanner/ScanResult.kt

@@ -0,0 +1,7 @@
+package com.hzliuzhi.applet.container.scanner
+
+data class ScanResult(
+  val code: String,
+  val state: Int = 0,
+  val type: Int = -1,
+)

+ 40 - 0
app/src/main/java/com/hzliuzhi/applet/container/scanner/ScannerListener.kt

@@ -0,0 +1,40 @@
+package com.hzliuzhi.applet.container.scanner
+
+import android.view.KeyEvent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class ScannerListener(
+  private val interval: Long = 50L,
+  private val onResult: (ScanResult) -> Unit,
+) {
+  private val scanBuffer = StringBuilder()
+  private var lastInputTime = 0L
+
+  fun dispatchKeyEvent(event: KeyEvent): Boolean {
+    if (event.action == KeyEvent.ACTION_DOWN) {
+      val now = System.currentTimeMillis()
+      if (now - lastInputTime > interval) scanBuffer.clear()
+      lastInputTime = now
+
+      event.unicodeChar.takeUnless { it == 0 }?.toChar()?.also { char ->
+        if (char == '\n' || char == '\r') {
+          val code = scanBuffer.toString()
+          if (code.isNotEmpty()) {
+            onResult(ScanResult(code = code))
+            scanBuffer.clear()
+          }
+          return true;
+        } else {
+          scanBuffer.append(char)
+        }
+      }
+    }
+    return false
+  }
+}
+
+@Composable
+fun rememberScannerListener(onResult: (ScanResult) -> Unit): ScannerListener {
+  return remember { ScannerListener { onResult(it) } }
+}

+ 0 - 78
core/src/main/assets/browser/bridge.js

@@ -1,78 +0,0 @@
-class Bridge extends EventTarget {
-  #analysis(string) {
-    let data = { type: '', payload: null, callbackId: '' };
-    try {
-      data = JSON.parse(string);
-    } catch (e) {
-      console.log('log:bridge:js', `[analysis] 解析消息错误: ${e.message}`);
-    }
-    if (
-      data.payload
-      && typeof data.payload === 'object'
-      && data.payload['__code__'] != null
-      && data.payload['__message__'] != null
-    ) {
-      data.error = { code: data.payload['__code__'], message: data.payload['__message__'] };
-    }
-    return data;
-  }
-
-  #pool = new Map();
-
-  static getInstance() {
-    return Bridge._instance ?? (Bridge._instance = new Bridge());
-  }
-
-  static get Platform() {
-    return window['AndroidBridge'];
-  }
-
-  static get UUID() {
-    return crypto.randomUUID();
-  }
-
-  static pulse(userId) {
-    const { promise, ...resolvers } = Promise.withResolvers();
-    this.getInstance().#postMessage('pulse', { userId }, resolvers);
-    return promise;
-  }
-
-  static print(payload) {
-    const { promise, ...resolvers } = Promise.withResolvers();
-    this.getInstance().#postMessage('print', payload, resolvers);
-    return promise;
-  }
-
-  dispatch(message) {
-    console.log('log:bridge:js', `[dispatch] 接收到消息: ${message}`);
-    const { type, callbackId, payload, error } = this.#analysis(message);
-    if (callbackId) {
-      const { resolve, reject } = this.#pool.get(callbackId) ?? {};
-      if (error) { reject?.(error); } else { resolve?.(payload); }
-      this.#pool.delete(callbackId);
-    } else {
-      let event;
-      if (error) event = new ErrorEvent(type, { error, message: error.message });
-      else event = new CustomEvent(type, { detail: payload });
-      super.dispatchEvent(event);
-    }
-  }
-
-  addEventListener(type, callback, options) {
-    super.addEventListener(type, callback, options);
-    return () => super.removeEventListener(type, callback);
-  }
-
-  #postMessage(type, payload, resolvers) {
-    const callbackId = `${type}:${Bridge.UUID}`;
-    this.#pool.set(callbackId, resolvers);
-    const message = JSON.stringify({ type, payload, callbackId })
-    Bridge.Platform.postMessage(message);
-    console.log('log:bridge:js', `[post] 发送的消息: ${message}`);
-  }
-}
-
-window['Bridge'] = Bridge;
-window['bridge'] = Bridge.getInstance();
-
-window.print = Bridge.print.bind(Bridge);

+ 14 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/print/Print.kt

@@ -0,0 +1,14 @@
+package com.hzliuzhi.applet.browser.print
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+
+data class Print(
+  val url: String?,
+) {
+  companion object {
+    fun formJson(element: JsonElement): Print {
+      return Gson().fromJson(element, Print::class.java)
+    }
+  }
+}

+ 151 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/print/PrintEventHandler.kt

@@ -0,0 +1,151 @@
+package com.hzliuzhi.applet.browser.print
+
+import android.content.Context
+import android.os.Bundle
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.print.PageRange
+import android.print.PrintAttributes
+import android.print.PrintDocumentAdapter
+import android.print.PrintDocumentInfo
+import android.print.PrintManager
+import android.webkit.WebView
+import com.google.gson.JsonElement
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.callbackAs
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okio.buffer
+import okio.sink
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+
+class PrintEventHandle(webView: WebView, scope: CoroutineScope) {
+  private val printManager: PrintManager = webView.context.getSystemService(Context.PRINT_SERVICE) as PrintManager
+
+  init {
+    android.util.Log.d("log:bridge", "PrintEventHandle 被注入")
+    SharedFlowHub.events
+      .filter { it.type.contains("print") }
+      .onEach { _event ->
+        when (_event.type) {
+
+          "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:print" -> {
+            _event.cast<JsonElement, JsonElement>()?.also { event ->
+              val print = event.payload?.let { it -> Print.formJson(it) }
+              val callback = event.callback ?: {}
+              handlePrint(webView, print) { payload ->
+                payload.toEvent()?.also { callback(it) }
+              }
+            }
+          }
+
+          else -> _event.callbackAs<Payload<Unit>>()?.invoke(Payload.error(message = "[print] 未实现 ${_event.type}"))
+        }
+      }
+      .launchIn(scope)
+  }
+
+  private fun handlePrint(webView: WebView, print: Print?, callback: ((Payload<Boolean?>) -> Unit)) {
+    val attributes = PrintAttributes.Builder().build()
+
+    print?.url.takeUnless { it.isNullOrEmpty() }?.also {
+      val context = webView.context.applicationContext
+      PdfPrintAdapter.download(
+        context = context, print = print!!,
+        onFinished = { file ->
+          val jobName = "${context.getString(android.R.string.untitled)}_${file.name}"
+          val adapter = PdfPrintAdapter(file, { callback(Payload.data(true));true }, { callback(Payload.error(message = it)); true })
+          printManager.print(jobName, adapter, attributes)
+        },
+        onError = { callback(Payload.error(message = it)) }
+      )
+    } ?: webView.post {
+      val jobName = "${webView.context.getString(android.R.string.untitled)}_page"
+      val adapter = webView.createPrintDocumentAdapter("WebViewDocument")
+      printManager.print(jobName, adapter, attributes)
+      callback(Payload.data(true))
+    }
+  }
+}
+
+private class PdfPrintAdapter(
+  private val file: File,
+  private val onFinished: () -> Boolean = { true },
+  private val onError: (String) -> Boolean = { true },
+) : PrintDocumentAdapter() {
+  override fun onLayout(
+    oldAttributes: PrintAttributes?,
+    newAttributes: PrintAttributes?,
+    cancellationSignal: CancellationSignal?,
+    callback: LayoutResultCallback?,
+    extras: Bundle?,
+  ) {
+    cancellationSignal?.takeIf { it.isCanceled }?.also { callback?.onLayoutCancelled(); return }
+
+    PrintDocumentInfo.Builder(file.name).apply {
+      setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
+      setPageCount(PrintDocumentInfo.PAGE_COUNT_UNKNOWN)
+    }.build().also { callback?.onLayoutFinished(it, true) }
+  }
+
+  override fun onWrite(
+    pages: Array<out PageRange>?,
+    destination: ParcelFileDescriptor,
+    cancellationSignal: CancellationSignal?,
+    callback: WriteResultCallback?,
+  ) {
+    runCatching {
+      FileInputStream(file).use { input ->
+        FileOutputStream(destination.fileDescriptor).use { output ->
+          input.copyTo(output)
+        }
+      }
+    }.onSuccess {
+      callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES))
+    }.onFailure {
+      callback?.onWriteFailed(it.message)
+      onError("打印错误: ${it.message ?: "失败"}").takeIf { remove -> remove }?.also { file.delete() }
+    }
+  }
+
+  override fun onFinish() {
+    super.onFinish()
+    onFinished().takeIf { remove -> remove }?.also { file.delete() }
+  }
+
+  companion object {
+    fun download(context: Context, print: Print, onFinished: (File) -> Unit, onError: (String) -> Unit) {
+      val request = Request.Builder().url(print.url!!).build()
+      OkHttpClient().newCall(request).enqueue(object : Callback {
+        override fun onFailure(call: Call, e: IOException) {
+          onError("下载失败: IO 错误")
+        }
+
+        override fun onResponse(call: Call, response: Response) {
+          if (!response.isSuccessful) onError("下载失败: ${response.message}").also { return }
+          runCatching {
+            File(context.cacheDir, "temp_${System.currentTimeMillis()}.pdf").apply {
+              sink().buffer().use { sikp ->
+                response.body?.source()?.let { sikp.writeAll(it) }
+              }
+            }
+          }.onSuccess { onFinished(it) }.onFailure { onError("下载失败: ${it.message}") }
+        }
+      })
+    }
+  }
+}
+
+

+ 15 - 7
library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/KioskScreen.kt

@@ -10,6 +10,7 @@ import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.zIndex
+import com.hzliuzhi.applet.browser.ui.components.NetworkMask
 import com.hzliuzhi.applet.browser.ui.components.ProgressBar
 import com.hzliuzhi.applet.browser.webview.WebContent
 import com.hzliuzhi.applet.browser.webview.WebView
@@ -32,13 +33,20 @@ fun KioskScreen(
       if (constraints.hasFixedWidth) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT,
       if (constraints.hasFixedHeight) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT,
     )
-    WebView(
-      layoutParams = layoutParams,
-      controller = controller,
-      onCreated = { controller.connected(it, content) },
-      onDispose = { controller.unconnected(it) }
-    )
 
-    ProgressBar(state.load, modifier = Modifier.zIndex(1f))
+    NetworkMask(
+      waitSecond = 5,
+      modifier = Modifier.zIndex(2f),
+      onRefresh = { controller.navigator.reload() }
+    ) {
+      WebView(
+        layoutParams = layoutParams,
+        controller = controller,
+        onCreated = { controller.connected(it, content) },
+        onDispose = { controller.unconnected(it) }
+      )
+
+      ProgressBar(state.load, modifier = Modifier.zIndex(1f))
+    }
   }
 }

+ 149 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/components/NetworkMask.kt

@@ -0,0 +1,149 @@
+package com.hzliuzhi.applet.browser.ui.components
+
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.provider.Settings
+import android.widget.Toast
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.delay
+
+@Composable
+fun NetworkMask(
+  modifier: Modifier = Modifier,
+  onOpenSettings: (() -> Unit)? = null,
+  onRefresh: (() -> Unit)? = null,
+  waitSecond: Int = 0,
+  content: (@Composable () -> Unit)? = null,
+) {
+  val context = LocalContext.current
+  val networkAvailable by rememberNetworkAvailable(context)
+
+  var lastNetworkAvailable by remember { mutableStateOf<Boolean?>(null) }
+
+
+  var loaded by remember { mutableStateOf(false) }
+  var showDialog by remember { mutableStateOf(false) }
+  var showLoading by remember { mutableStateOf(false) }
+
+  // 默认跳转到系统设置的方法
+  val defaultOpenSettings: () -> Unit = {
+    Intent(Settings.ACTION_WIRELESS_SETTINGS).apply {
+      addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+      context.startActivity(this)
+    }
+  }
+
+  LaunchedEffect(networkAvailable, waitSecond) {
+    val last = lastNetworkAvailable
+    if (last == null && waitSecond > 0) {
+      showLoading = !networkAvailable
+      showDialog = false
+      if (showLoading) delay(waitSecond * 1000L)
+    }
+
+    lastNetworkAvailable = networkAvailable
+    showDialog = !networkAvailable
+    showLoading = false
+
+    when (last) {
+      false -> /* 网络从断开(false)变为连接(true)时 */ if (networkAvailable) onRefresh?.invoke()
+      null -> /* 首次连接时 */ loaded = true
+      else -> loaded = true
+    }
+  }
+
+  showLoading.takeIf { it }?.let {
+    Box(
+      modifier = modifier.fillMaxSize(),
+      contentAlignment = Alignment.Center
+    ) {
+      Column(horizontalAlignment = Alignment.CenterHorizontally) {
+        CircularProgressIndicator()
+        Text("正在检测网络...")
+      }
+    }
+  }
+
+  showDialog.takeIf { it }?.let {
+    AlertDialog(
+      modifier = modifier,
+      onDismissRequest = {},
+      title = { Text("无网络连接") },
+      text = { Text("请检查您的网络设置。") },
+      confirmButton = {
+        Button(onClick = onOpenSettings ?: defaultOpenSettings) {
+          Text("打开设置")
+        }
+      },
+      dismissButton = {
+        Button(onClick = {
+          if (checkNetworkConnected(context)) {
+            showDialog = false
+            onRefresh?.invoke()
+          } else {
+            Toast.makeText(context, "网络未连接", Toast.LENGTH_SHORT).show()
+          }
+        }) {
+          Text("刷新")
+        }
+      }
+    )
+  }
+
+  if (loaded) content?.invoke()
+}
+
+@Composable
+fun rememberNetworkAvailable(context: Context = LocalContext.current): MutableState<Boolean> {
+  val networkAvailable = remember { mutableStateOf(true) }
+
+  DisposableEffect(context) {
+    val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+    val callback = object : ConnectivityManager.NetworkCallback() {
+      override fun onAvailable(network: Network) {
+        networkAvailable.value = true
+      }
+
+      override fun onLost(network: Network) {
+        networkAvailable.value = checkNetworkConnected(context)
+      }
+    }
+    val request = NetworkRequest.Builder()
+      .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+      .build()
+    cm.registerNetworkCallback(request, callback)
+    networkAvailable.value = checkNetworkConnected(context)
+    onDispose {
+      cm.unregisterNetworkCallback(callback)
+    }
+  }
+  return networkAvailable
+}
+
+private fun checkNetworkConnected(context: Context): Boolean {
+  val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+  val network = cm.activeNetwork ?: return false
+  val capabilities = cm.getNetworkCapabilities(network) ?: return false
+  return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+}

+ 3 - 1
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewBridge.kt

@@ -6,6 +6,7 @@ import android.webkit.JavascriptInterface
 import android.webkit.WebView
 import com.google.gson.Gson
 import com.google.gson.JsonElement
+import com.hzliuzhi.applet.browser.print.PrintEventHandle
 import com.hzliuzhi.applet.core.shared.Event
 import com.hzliuzhi.applet.core.shared.SharedFlowHub
 import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
@@ -59,6 +60,8 @@ class WebViewBridge(private val coroutineScope: CoroutineScope) {
         webview.evaluateJavascript("Bridge.getInstance().dispatch(${JSONObject.quote(payload)})", it.callback)
       }
     }.launchIn(coroutineScope)
+
+    PrintEventHandle(webview, coroutineScope)
   }
 
 
@@ -92,4 +95,3 @@ class WebViewBridge(private val coroutineScope: CoroutineScope) {
     }
   }
 }
-

+ 16 - 1
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewNavigator.kt

@@ -4,11 +4,13 @@ import android.webkit.WebView
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import androidx.core.net.toUri
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import kotlin.random.Random
 
 class WebViewNavigator(
   private val coroutineScope: CoroutineScope,
@@ -40,7 +42,7 @@ class WebViewNavigator(
         Event.Forward -> goForward()
         Event.Reload -> reload()
         Event.Stop -> stopLoading()
-        is Event.LoadUrl -> loadUrl(it.url, it.additionalHttpHeaders ?: emptyMap())
+        is Event.LoadUrl -> loadUrl(it.url.timestamp(), it.additionalHttpHeaders ?: emptyMap())
         else -> {}
       }
     }
@@ -69,4 +71,17 @@ class WebViewNavigator(
       }
     }
   }
+
+  private fun String.timestamp(): String {
+    return try {
+      val uri = toUri()
+      val timestamp = System.currentTimeMillis()
+      return uri.buildUpon().apply {
+        appendQueryParameter("_t", timestamp.toString())
+        appendQueryParameter("_r", Random.nextInt(1000).toString())
+      }.build().toString()
+    } catch (e: Exception) {
+      this
+    }
+  }
 }

+ 1 - 1
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebViewClient.kt

@@ -30,7 +30,7 @@ open class WrapperWebViewClient : WebViewClient() {
   override fun onPageFinished(view: WebView?, url: String?) {
     super.onPageFinished(view, url)
     // 只处理首次次加载的 url
-    url?.takeUnless { finished.add(it) }?.also {
+    url?.takeIf { finished.add(it) }?.also {
       view?.also { controller.bridge.inject(it) }
     }
     controller.state.apply {