9 Commits 1388138ebf ... 73b8a524a3

Autor SHA1 Mensagem Data
  cc12458 73b8a524a3 Merge branch 'release/2.2.1' 2 semanas atrás
  cc12458 31a76c607d 重庆医药杭州代煎中心 2 semanas atrás
  cc12458 008201b524 [pda] 添加生产环境 2 semanas atrás
  cc12458 1b8fd8ed26 优化项目配置 2 semanas atrás
  cc12458 540016f95c Merge tag '2.1.1' into develop 2 semanas atrás
  cc12458 d2798b52c8 Merge branch 'feature/print' into develop 3 semanas atrás
  cc12458 73a17a4cbd 对接佳博打印机 SDK 3 semanas atrás
  cc12458 c2b62e05ae 迁移 Message 类 3 semanas atrás
  cc12458 1b0ca95fce 1. 创建 printer 模块 3 semanas atrás
31 arquivos alterados com 767 adições e 225 exclusões
  1. 1 0
      .idea/gradle.xml
  2. 54 19
      app/build.gradle.kts
  3. 5 2
      app/src/main/java/com/hzliuzhi/applet/container/MainActivity.kt
  4. 6 0
      app/src/pda-CQHZDJZX/res/values/application.xml
  5. 10 0
      app/src/pda-CQHZDJZX/res/values/browser_update.xml
  6. 6 0
      app/src/pda-HZ/res/values/application.xml
  7. 10 0
      app/src/pda-HZ/res/values/browser_update.xml
  8. 6 0
      app/src/pda-prod/res/values/application.xml
  9. 10 0
      app/src/pda-prod/res/values/browser_update.xml
  10. 31 0
      core/src/main/java/com/hzliuzhi/applet/core/shared/Message.kt
  11. 12 1
      gradle.properties
  12. 1 1
      gradle/libs.versions.toml
  13. 2 0
      library/browser/src/main/assets/browser/bridge.js
  14. 0 14
      library/browser/src/main/java/com/hzliuzhi/applet/browser/print/Print.kt
  15. 0 151
      library/browser/src/main/java/com/hzliuzhi/applet/browser/print/PrintEventHandler.kt
  16. 23 37
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewBridge.kt
  17. 1 0
      library/device/printer/.gitignore
  18. 44 0
      library/device/printer/build.gradle.kts
  19. 0 0
      library/device/printer/consumer-rules.pro
  20. 21 0
      library/device/printer/proguard-rules.pro
  21. 24 0
      library/device/printer/src/androidTest/java/com/hzliuzhi/applet/printer/ExampleInstrumentedTest.kt
  22. 4 0
      library/device/printer/src/main/AndroidManifest.xml
  23. 42 0
      library/device/printer/src/main/java/com/hzliuzhi/applet/printer/JsonUtil.kt
  24. 29 0
      library/device/printer/src/main/java/com/hzliuzhi/applet/printer/PrintDevice.kt
  25. 15 0
      library/device/printer/src/main/java/com/hzliuzhi/applet/printer/PrintParams.kt
  26. 135 0
      library/device/printer/src/main/java/com/hzliuzhi/applet/printer/Printer.kt
  27. 108 0
      library/device/printer/src/main/java/com/hzliuzhi/applet/printer/impl/GPrinter.kt
  28. 85 0
      library/device/printer/src/main/java/com/hzliuzhi/applet/printer/impl/PrintPdfDocumentAdapter.kt
  29. 60 0
      library/device/printer/src/main/java/com/hzliuzhi/applet/printer/impl/Tspl.kt
  30. 17 0
      library/device/printer/src/test/java/com/hzliuzhi/applet/printer/ExampleUnitTest.kt
  31. 5 0
      settings.gradle.kts

+ 1 - 0
.idea/gradle.xml

@@ -15,6 +15,7 @@
             <option value="$PROJECT_DIR$/library" />
             <option value="$PROJECT_DIR$/library" />
             <option value="$PROJECT_DIR$/library/browser" />
             <option value="$PROJECT_DIR$/library/browser" />
             <option value="$PROJECT_DIR$/library/device" />
             <option value="$PROJECT_DIR$/library/device" />
+            <option value="$PROJECT_DIR$/library/device/printer" />
             <option value="$PROJECT_DIR$/library/device/pulse" />
             <option value="$PROJECT_DIR$/library/device/pulse" />
             <option value="$PROJECT_DIR$/library/device/scanner" />
             <option value="$PROJECT_DIR$/library/device/scanner" />
           </set>
           </set>

+ 54 - 19
app/build.gradle.kts

@@ -1,6 +1,7 @@
 import com.android.build.gradle.internal.api.BaseVariantOutputImpl
 import com.android.build.gradle.internal.api.BaseVariantOutputImpl
 import java.text.SimpleDateFormat
 import java.text.SimpleDateFormat
 import java.util.Date
 import java.util.Date
+import java.util.Properties
 
 
 fun getVersionCode(): Int {
 fun getVersionCode(): Int {
   val dateFormat = SimpleDateFormat("yyyyMMdd")
   val dateFormat = SimpleDateFormat("yyyyMMdd")
@@ -15,6 +16,35 @@ plugins {
   alias(libs.plugins.kotlin.serialization)
   alias(libs.plugins.kotlin.serialization)
 }
 }
 
 
+// 读取 gradle.properties 并解析 project.xx 字段
+fun buildTypeChain(): Map<String, List<String>> {
+  val properties = Properties()
+  val file = File(rootDir, "gradle.properties")
+  if (file.exists()) {
+    properties.load(file.inputStream())
+  }
+
+  // 只取 project.xx 字段
+  val projectMap = properties.entries
+    .filter { (k, _) -> k.toString().startsWith("project.") }
+    .associate { (k, v) -> k.toString().removePrefix("project.") to v.toString() }
+
+  // 递归查找链
+  fun resolveChain(key: String): List<String> {
+    val next = projectMap[key]
+    return if (next == null || next == "debug" || next == "release") {
+      listOfNotNull(key, next)
+    } else {
+      listOf(key) + resolveChain(next)
+    }
+  }
+
+  // 构建所有链并返回
+  return projectMap.keys.associateWith { resolveChain(it) }
+}
+
+val buildTypeChain = buildTypeChain()
+
 android {
 android {
   namespace = "com.hzliuzhi.applet.container"
   namespace = "com.hzliuzhi.applet.container"
   compileSdk = 35
   compileSdk = 35
@@ -24,7 +54,7 @@ android {
     minSdk = 26
     minSdk = 26
     targetSdk = 35
     targetSdk = 35
     versionCode = getVersionCode()
     versionCode = getVersionCode()
-    versionName = "2.0.0"
+    versionName = "2.2.1"
 
 
     testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
     testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
   }
   }
@@ -34,27 +64,31 @@ android {
       isMinifyEnabled = false
       isMinifyEnabled = false
       proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
       proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
     }
     }
-    create("aio") {
-      initWith(getByName("debug"))
-      matchingFallbacks += listOf("debug")
-    }
-    create("aio-test") {
-      initWith(getByName("aio"))
-      matchingFallbacks += listOf("aio", "debug")
-    }
-    create("aio-CQ") {
-      initWith(getByName("aio-test"))
-      matchingFallbacks += listOf("aio-test", "aio", "debug")
-    }
+    buildTypeChain.values.sortedBy { it.size }.forEach { chain ->
+      when (val name = chain.first()) {
+        "debug",
+        "release",
+          -> return@forEach
 
 
-    create("pda") {
-      initWith(getByName("debug"))
-      matchingFallbacks += listOf("debug")
+        else -> create(name) {
+          // initWith 取 chain 的下一个(即父级)
+          initWith(getByName(chain.getOrNull(1) ?: "debug"))
+          // matchingFallbacks 取除自己外的所有
+          matchingFallbacks += chain.drop(1)
+        }
+      }
     }
     }
+  }
 
 
-    create("pda-test") {
-      initWith(getByName("pda"))
-      matchingFallbacks += listOf("pda", "debug")
+  sourceSets {
+    buildTypeChain.forEach { (name, chain) ->
+      maybeCreate(name).apply {
+        // 不包含 main,只合并链上的自定义类型
+        val dirs = chain.reversed()
+        dirs.forEach { dir ->
+          assets.srcDir("src/$dir/assets")
+        }
+      }
     }
     }
   }
   }
 
 
@@ -111,6 +145,7 @@ dependencies {
   implementation(project(":core"))
   implementation(project(":core"))
   implementation(project(":library:browser"))
   implementation(project(":library:browser"))
   implementation(project(":library:device:pulse"))
   implementation(project(":library:device:pulse"))
+  implementation(project(":library:device:printer"))
   implementation(project(":library:device:scanner"))
   implementation(project(":library:device:scanner"))
   implementation(libs.gson)
   implementation(libs.gson)
 }
 }

+ 5 - 2
app/src/main/java/com/hzliuzhi/applet/container/MainActivity.kt

@@ -13,11 +13,12 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.navigation.NavHostController
 import androidx.navigation.NavHostController
 import androidx.navigation.compose.rememberNavController
 import androidx.navigation.compose.rememberNavController
-import com.hzliuzhi.applet.browser.webview.WebViewBridge
 import com.hzliuzhi.applet.container.navigation.Host
 import com.hzliuzhi.applet.container.navigation.Host
+import com.hzliuzhi.applet.core.shared.Message
 import com.hzliuzhi.applet.core.shared.Payload
 import com.hzliuzhi.applet.core.shared.Payload
 import com.hzliuzhi.applet.core.shared.SharedFlowHub
 import com.hzliuzhi.applet.core.shared.SharedFlowHub
 import com.hzliuzhi.applet.core.theme.SixTheme
 import com.hzliuzhi.applet.core.theme.SixTheme
+import com.hzliuzhi.applet.printer.Printer
 import com.hzliuzhi.applet.scanner.Scanner
 import com.hzliuzhi.applet.scanner.Scanner
 
 
 class MainActivity : AndroidActivity() {
 class MainActivity : AndroidActivity() {
@@ -55,12 +56,14 @@ class MainActivity : AndroidActivity() {
             else -> Payload.data(data = result).toJson()
             else -> Payload.data(data = result).toJson()
           }
           }
         }?.also {
         }?.also {
-          val event = WebViewBridge.Message(type = "scan", payload = it).toEvent()
+          val event = Message(type = "scan", payload = it).toWebEvent()
           SharedFlowHub.emit(event)
           SharedFlowHub.emit(event)
         }
         }
       }
       }
       Scanner.getInstance(context).eventHandle(rememberCoroutineScope())
       Scanner.getInstance(context).eventHandle(rememberCoroutineScope())
 
 
+      LocalActivity.current?.also { Printer.getInstance(it).eventHandle(rememberCoroutineScope()) }
+
       val tag = context.resources.getString(R.string.app_id)
       val tag = context.resources.getString(R.string.app_id)
       when {
       when {
         tag.startsWith("AIO") -> run {
         tag.startsWith("AIO") -> run {

+ 6 - 0
app/src/pda-CQHZDJZX/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">PDA</string>
+  <string name="app_name">中药处方煎配朔源管理(重庆医药杭州代煎中心)</string>
+  <string name="app_screen">browser/kiosk?url=http://115.236.184.98:29110/pharmacy/pda/</string>
+</resources>

+ 10 - 0
app/src/pda-CQHZDJZX/res/values/browser_update.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="browser_min_version">111</string>
+  <bool name="browser_update_force">true</bool>
+  <string-array name="browser_update_package">
+    <item>com.google.android.webview</item>
+    <item>com.android.webview</item>
+    <item>com.android.chrome</item>
+  </string-array>
+</resources>

+ 6 - 0
app/src/pda-HZ/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">PDA</string>
+  <string name="app_name">中药处方煎配朔源管理(区域版.HZ)</string>
+  <string name="app_screen">browser/kiosk?url=http://8.139.252.178:8001/pharmacy/pda/</string>
+</resources>

+ 10 - 0
app/src/pda-HZ/res/values/browser_update.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="browser_min_version">111</string>
+  <bool name="browser_update_force">true</bool>
+  <string-array name="browser_update_package">
+    <item>com.google.android.webview</item>
+    <item>com.android.webview</item>
+    <item>com.android.chrome</item>
+  </string-array>
+</resources>

+ 6 - 0
app/src/pda-prod/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">PDA</string>
+  <string name="app_name">中药处方煎配朔源管理</string>
+  <string name="app_screen">browser/kiosk?url=https://wx.hzliuzhi.com/pharmacy/pda/</string>
+</resources>

+ 10 - 0
app/src/pda-prod/res/values/browser_update.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="browser_min_version">111</string>
+  <bool name="browser_update_force">true</bool>
+  <string-array name="browser_update_package">
+    <item>com.google.android.webview</item>
+    <item>com.android.webview</item>
+    <item>com.android.chrome</item>
+  </string-array>
+</resources>

+ 31 - 0
core/src/main/java/com/hzliuzhi/applet/core/shared/Message.kt

@@ -0,0 +1,31 @@
+package com.hzliuzhi.applet.core.shared
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+
+data class Message(
+  val type: String,
+  val payload: JsonElement? = Gson().toJsonTree(""),
+  val callbackId: String? = null,
+) {
+  companion object {
+    fun fromJson(json: String) = try {
+      Gson().fromJson(json, Message::class.java)
+    } catch (_: Throwable) {
+      null
+    }
+
+    fun toJson(message: Message) = try {
+      Gson().toJson(message)
+    } catch (_: Throwable) {
+      null
+    }
+  }
+
+  fun toWebEvent() = Event<Message, String>(
+    type = "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:js",
+    payload = this
+  )
+
+  val isPayloadNull: Boolean get() = payload?.isJsonNull ?: true
+}

+ 12 - 1
gradle.properties

@@ -21,4 +21,15 @@ kotlin.code.style=official
 # Enables namespacing of each library's R class so that its R class includes only the
 # Enables namespacing of each library's R class so that its R class includes only the
 # resources declared in the library itself and none from the library's dependencies,
 # resources declared in the library itself and none from the library's dependencies,
 # thereby reducing the size of the R class for that library
 # thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
+android.nonTransitiveRClass=true
+
+project.aio=debug
+project.aio-test=aio
+project.aio-CQ=aio-test
+
+project.pda=debug
+project.pda-test=pda
+project.pda-prod=pda-test
+project.pda-HZ=pda-prod
+# ??????????????
+project.pda-CQHZDJZX=pda-prod

+ 1 - 1
gradle/libs.versions.toml

@@ -13,7 +13,7 @@ appcompat = "1.7.1"
 
 
 gson = "2.13.1"
 gson = "2.13.1"
 nanohttpd = "2.3.1"
 nanohttpd = "2.3.1"
-navigationCompose = "2.9.0"
+navigationCompose = "2.9.1"
 okhttp = "4.12.0"
 okhttp = "4.12.0"
 webkit = "1.14.0"
 webkit = "1.14.0"
 
 

+ 2 - 0
library/browser/src/main/assets/browser/bridge.js

@@ -60,6 +60,8 @@ class Bridge extends EventTarget {
     return () => super.removeEventListener(type, callback);
     return () => super.removeEventListener(type, callback);
   }
   }
 
 
+  postMessage(...args) { return this.#postMessage(...args); }
+
   #postMessage(type, payload, resolvers) {
   #postMessage(type, payload, resolvers) {
     const callbackId = `${type}:${Bridge.UUID}`;
     const callbackId = `${type}:${Bridge.UUID}`;
     this.#pool.set(callbackId, resolvers);
     this.#pool.set(callbackId, resolvers);

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

@@ -1,14 +0,0 @@
-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)
-    }
-  }
-}

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

@@ -1,151 +0,0 @@
-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.toJson()?.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}") }
-        }
-      })
-    }
-  }
-}
-
-

+ 23 - 37
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewBridge.kt

@@ -1,13 +1,14 @@
 package com.hzliuzhi.applet.browser.webview
 package com.hzliuzhi.applet.browser.webview
 
 
 import android.annotation.SuppressLint
 import android.annotation.SuppressLint
+import android.print.PrintDocumentAdapter
 import android.util.Log
 import android.util.Log
 import android.webkit.JavascriptInterface
 import android.webkit.JavascriptInterface
 import android.webkit.WebView
 import android.webkit.WebView
 import com.google.gson.Gson
 import com.google.gson.Gson
 import com.google.gson.JsonElement
 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.Event
+import com.hzliuzhi.applet.core.shared.Message
 import com.hzliuzhi.applet.core.shared.SharedFlowHub
 import com.hzliuzhi.applet.core.shared.SharedFlowHub
 import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
 import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineScope
@@ -21,30 +22,6 @@ import org.json.JSONObject
 import java.io.InputStreamReader
 import java.io.InputStreamReader
 
 
 class WebViewBridge(private val coroutineScope: CoroutineScope) {
 class WebViewBridge(private val coroutineScope: CoroutineScope) {
-  data class Message(
-    val type: String,
-    val payload: JsonElement = Gson().toJsonTree(""),
-    val callbackId: String? = null,
-  ) {
-    companion object {
-      fun fromJson(json: String) = try {
-        Gson().fromJson(json, Message::class.java)
-      } catch (_: Throwable) {
-        null
-      }
-
-      fun toJson(message: Message) = try {
-        Gson().toJson(message)
-      } catch (_: Throwable) {
-        null
-      }
-    }
-
-    fun toEvent() = Event<Message, String>(
-      type = "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:js",
-      payload = this
-    )
-  }
 
 
 
 
   private var lastScriptText: String? = null
   private var lastScriptText: String? = null
@@ -65,8 +42,6 @@ class WebViewBridge(private val coroutineScope: CoroutineScope) {
         webview.evaluateJavascript("Bridge.getInstance().dispatch(${JSONObject.quote(payload)})", it.callback)
         webview.evaluateJavascript("Bridge.getInstance().dispatch(${JSONObject.quote(payload)})", it.callback)
       }
       }
     }.launchIn(coroutineScope)
     }.launchIn(coroutineScope)
-
-    PrintEventHandle(webview, coroutineScope)
   }
   }
 
 
 
 
@@ -75,19 +50,30 @@ class WebViewBridge(private val coroutineScope: CoroutineScope) {
   @SuppressLint("JavascriptInterface")
   @SuppressLint("JavascriptInterface")
   internal suspend fun WebView.handleBridge(): Nothing = withContext(Dispatchers.Main) {
   internal suspend fun WebView.handleBridge(): Nothing = withContext(Dispatchers.Main) {
     addJavascriptInterface(this@WebViewBridge, "AndroidBridge")
     addJavascriptInterface(this@WebViewBridge, "AndroidBridge")
-    messages.collect { SharedFlowHub.webViewEmit(it) }
+    messages.collect { SharedFlowHub.webViewEmit(this@handleBridge, it) }
   }
   }
 
 
-  private fun SharedFlowHub.webViewEmit(message: Message) {
+  private fun SharedFlowHub.webViewEmit(webview: WebView, message: Message) {
     Log.d("log:bridge", "接收消息: $message")
     Log.d("log:bridge", "接收消息: $message")
-    Event<JsonElement, JsonElement>(
-      type = "$WEBVIEW_BRIDGE_EVENT:${message.type}",
-      payload = message.payload,
-      callback = { payload ->
-        val event = message.copy(payload = payload).toEvent()
-        emit(event)
-      }
-    ).also { emit(it) }
+    if (message.type == "print" && message.isPayloadNull) {
+      Event<PrintDocumentAdapter, JsonElement>(
+        type = message.type,
+        payload = webview.createPrintDocumentAdapter("WebViewDocument"),
+        callback = { payload ->
+          val event = message.copy(payload = payload).toWebEvent()
+          emit(event)
+        }
+      ).also { emit(it) }
+    } else {
+      Event<JsonElement, JsonElement>(
+        type = "$WEBVIEW_BRIDGE_EVENT:${message.type}",
+        payload = message.payload,
+        callback = { payload ->
+          val event = message.copy(payload = payload).toWebEvent()
+          emit(event)
+        }
+      ).also { emit(it) }
+    }
   }
   }
 
 
   /* 注入到 JavaScript 中的方法 */
   /* 注入到 JavaScript 中的方法 */

+ 1 - 0
library/device/printer/.gitignore

@@ -0,0 +1 @@
+/build

+ 44 - 0
library/device/printer/build.gradle.kts

@@ -0,0 +1,44 @@
+plugins {
+  alias(libs.plugins.android.library)
+  alias(libs.plugins.kotlin.android)
+}
+
+android {
+  namespace = "com.hzliuzhi.applet.scanner"
+  compileSdk = 35
+
+  defaultConfig {
+    minSdk = 26
+
+    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+    consumerProguardFiles("consumer-rules.pro")
+  }
+
+  buildTypes {
+    release {
+      isMinifyEnabled = false
+      proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+    }
+  }
+  compileOptions {
+    sourceCompatibility = JavaVersion.VERSION_11
+    targetCompatibility = JavaVersion.VERSION_11
+  }
+  kotlinOptions {
+    jvmTarget = "11"
+  }
+}
+
+dependencies {
+
+  implementation(libs.androidx.core.ktx)
+  implementation(libs.androidx.appcompat)
+  testImplementation(libs.junit)
+  androidTestImplementation(libs.androidx.junit)
+  androidTestImplementation(libs.androidx.espresso.core)
+
+  implementation(project(":core"))
+  implementation(libs.gson)
+  implementation(libs.okhttp)
+  implementation("com.gainscha:sdk2:2.0.0")
+}

+ 0 - 0
library/device/printer/consumer-rules.pro


+ 21 - 0
library/device/printer/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 24 - 0
library/device/printer/src/androidTest/java/com/hzliuzhi/applet/printer/ExampleInstrumentedTest.kt

@@ -0,0 +1,24 @@
+package com.hzliuzhi.applet.printer
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+  @Test
+  fun useAppContext() {
+    // Context of the app under test.
+    val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+    assertEquals("com.hzliuzhi.applet.printer.test", appContext.packageName)
+  }
+}

+ 4 - 0
library/device/printer/src/main/AndroidManifest.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+</manifest>

+ 42 - 0
library/device/printer/src/main/java/com/hzliuzhi/applet/printer/JsonUtil.kt

@@ -0,0 +1,42 @@
+package com.hzliuzhi.applet.printer
+
+import android.util.Log
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonParseException
+import com.google.gson.JsonPrimitive
+import com.google.gson.JsonSerializationContext
+import com.google.gson.JsonSerializer
+
+object JsonUtil {
+  val gson: Gson by lazy {
+    GsonBuilder()
+      .registerTypeAdapter(PrintDevice.Type::class.java, PrintDeviceTypeAdapter())
+      .create()
+  }
+}
+
+private class PrintDeviceTypeAdapter : JsonDeserializer<PrintDevice.Type>, JsonSerializer<PrintDevice.Type> {
+  override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): PrintDevice.Type {
+    return when {
+      json.isJsonPrimitive && json.asJsonPrimitive.isString -> {
+        val label = json.asString
+        PrintDevice.Type.entries.find { it.label == label } ?: throw JsonParseException("Unknown label: $label")
+      }
+
+      json.isJsonPrimitive && json.asJsonPrimitive.isNumber -> {
+        val value = json.asInt
+        PrintDevice.Type.entries.find { it.value == value } ?: throw JsonParseException("Unknown value: $value")
+      }
+
+      else -> throw JsonParseException("Invalid type for PrintDevice.Type: $json")
+    }
+  }
+
+  override fun serialize(src: PrintDevice.Type, typeOfSrc: java.lang.reflect.Type, context: JsonSerializationContext): JsonElement {
+    return JsonPrimitive(src.label)
+  }
+}

+ 29 - 0
library/device/printer/src/main/java/com/hzliuzhi/applet/printer/PrintDevice.kt

@@ -0,0 +1,29 @@
+package com.hzliuzhi.applet.printer
+
+import com.gainscha.sdk2.model.PrinterDevice
+import com.gainscha.sdk2.model.WifiPrinterDevice
+import com.google.gson.JsonElement
+
+data class PrintDevice(
+  val type: Type,
+
+  val ip: String?,
+  val port: Int?,
+) {
+  enum class Type(val value: Int, val label: String) {
+    WIFI(1, "wifi"),
+    ;
+  }
+
+  constructor(ip: String, port: Int) : this(type = Type.WIFI, ip = ip, port = port)
+
+
+  companion object {
+    fun fromJson(json: JsonElement): PrintDevice? = JsonUtil.gson.fromJson(json, PrintDevice::class.java)
+
+    fun fromJson(json: String): PrintDevice? = JsonUtil.gson.fromJson(json, PrintDevice::class.java)
+  }
+
+  fun toJson(): String = JsonUtil.gson.toJson(this)
+}
+

+ 15 - 0
library/device/printer/src/main/java/com/hzliuzhi/applet/printer/PrintParams.kt

@@ -0,0 +1,15 @@
+package com.hzliuzhi.applet.printer
+
+import com.google.gson.JsonElement
+
+data class PrintParams(
+  val url: String?,
+  val device: PrintDevice?,
+  val tspl: String?,
+) {
+  companion object {
+    fun fromJson(element: JsonElement): PrintParams? = JsonUtil.gson.fromJson(element, PrintParams::class.java)
+  }
+}
+
+

+ 135 - 0
library/device/printer/src/main/java/com/hzliuzhi/applet/printer/Printer.kt

@@ -0,0 +1,135 @@
+package com.hzliuzhi.applet.printer
+
+import android.app.Activity
+import android.content.Context
+import android.print.PrintAttributes
+import android.print.PrintDocumentAdapter
+import android.print.PrintManager
+import androidx.activity.ComponentActivity
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+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 com.hzliuzhi.applet.printer.impl.GPrinter
+import com.hzliuzhi.applet.printer.impl.PrintPdfDocumentAdapter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class Printer(context: Context) : DefaultLifecycleObserver {
+  private val applicationContext = context.applicationContext
+
+  private val manager by lazy {
+    if (context is Activity) context.getSystemService(Context.PRINT_SERVICE) as PrintManager
+    else null
+  }
+
+
+  private var callback: ((JsonElement) -> Unit)? = null
+
+  companion object {
+    @Volatile
+    private var instance: Printer? = null
+    fun getInstance(context: Context): Printer {
+      return instance ?: synchronized(this) {
+        Printer(context).also {
+          instance = it
+          (context as? ComponentActivity)?.lifecycle?.addObserver(it)
+        }
+      }
+    }
+  }
+
+  override fun onDestroy(owner: LifecycleOwner) {
+    super.onDestroy(owner)
+    GPrinter.onDestroy()
+  }
+
+  fun eventHandle(scope: CoroutineScope) {
+    SharedFlowHub.events
+      .filter { it.type.contains("print") }
+      .onEach { event ->
+        when (event.type) {
+          "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:print" -> run {
+            event.cast<JsonElement, JsonElement>()?.also { event ->
+              callback = event.callback
+              event.payload
+                ?.let { PrintParams.fromJson(it) }
+                ?.also { print(it) }
+                ?: invoke(Payload.error<Unit>(message = "[print] 参数解析错误"))
+            }
+          }
+
+          "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:print:connect" -> run {
+            event.cast<JsonElement, JsonElement>()?.also { event ->
+              callback = event.callback
+              event.payload
+                ?.let { PrintDevice.fromJson(it) }
+                ?.also { device ->
+                  GPrinter.connect(device) {
+                    val payload = if (it.second) Payload.data(device, message = "连接成功") else Payload.error<Unit>(message = "连接失败")
+                    invoke(payload)
+                  }
+                }
+                ?: invoke(Payload.error<Unit>(message = "[print:connect] 参数解析错误"))
+            }
+          }
+
+          "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:print:disconnect" -> run {
+            event.cast<JsonElement, JsonElement>()?.also { event ->
+              callback = event.callback
+              event.payload
+                ?.let { PrintDevice.fromJson(it) }
+                ?.also { device ->
+                  GPrinter.disconnect(device) {
+                    val payload = if (!it.second) Payload.data(device, message = "断开成功") else Payload.error<Unit>(message = "断开失败")
+                    invoke(payload)
+                  }
+                }
+                ?: invoke(Payload.error<Unit>(message = "[print:disconnect] 参数解析错误"))
+            }
+          }
+
+          "print" -> run {
+            event.cast<PrintDocumentAdapter, JsonElement>()?.also { event ->
+              callback = event.callback
+              event.payload?.also { print(adapter = it) } ?: invoke(Payload.error<Unit>(message = "[print] 参数解析错误"))
+            }
+          }
+
+          else -> event.callbackAs<Payload<Unit>>()?.invoke(Payload.error(message = "[print] 未实现 ${event.type}"))
+        }
+      }
+      .launchIn(scope)
+  }
+
+  fun print(params: PrintParams) {
+    if (!params.url.isNullOrEmpty()) {
+      PrintPdfDocumentAdapter.download(
+        applicationContext, params.url,
+        { file -> print(file.name, PrintPdfDocumentAdapter(file, { invoke(Payload.data(true)); true }, { invoke(Payload.error<Unit>(message = it)); true })) },
+        { message -> Payload.error<Unit>(message = message) }
+      )
+    } else if (!params.tspl.isNullOrEmpty()) {
+      runCatching { GPrinter.print(device = params.device, text = params.tspl) }
+        .onSuccess { invoke(Payload.data(true)) }
+        .onFailure { invoke(Payload.error<Unit>(message = it.message)) }
+    }
+  }
+
+
+  private fun print(jobName: String? = null, adapter: PrintDocumentAdapter, attributes: PrintAttributes? = null) {
+    manager?.print(jobName ?: "${applicationContext.getString(android.R.string.untitled)}_page", adapter, attributes) ?: run {
+      invoke(Payload.error<Unit>(message = "[print] PrintManager 未找到"))
+    }
+  }
+
+  private fun invoke(payload: Payload<*>) {
+    JsonUtil.gson.toJsonTree(payload, Payload::class.java).also { callback?.invoke(it) }
+    callback = null
+  }
+}

+ 108 - 0
library/device/printer/src/main/java/com/hzliuzhi/applet/printer/impl/GPrinter.kt

@@ -0,0 +1,108 @@
+package com.hzliuzhi.applet.printer.impl
+
+import com.gainscha.sdk2.ConnectionListener
+import com.gainscha.sdk2.Printer
+import com.gainscha.sdk2.command.Tspl
+import com.gainscha.sdk2.model.PrinterDevice
+import com.gainscha.sdk2.model.WifiPrinterDevice
+import com.hzliuzhi.applet.core.shared.Message
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
+import com.hzliuzhi.applet.printer.JsonUtil
+import com.hzliuzhi.applet.printer.PrintDevice
+
+object GPrinter {
+  init {
+    object : ConnectionListener {
+      override fun onPrinterConnected(printer: Printer?) {
+        printer?.printerDevice?.to().also {
+          callback[it]?.invoke(Pair(printer, true))
+          callback.remove(it)
+        } ?: emit("print:connect", Payload.data(printer?.printerDevice?.to() ?: true, message = "自动连接成功 (${Printer.getConnectedPrinters().size})"))
+      }
+
+      override fun onPrinterConnectFail(printer: Printer?) {
+        printer?.printerDevice?.to().also {
+          callback[it]?.invoke(Pair(printer, false))
+          callback.remove(it)
+        }
+      }
+
+      override fun onPrinterDisconnect(printer: Printer?) {
+        printer?.printerDevice?.to()?.takeIf { callback.containsKey(it) }?.also {
+          callback[it]?.invoke(Pair(printer, false))
+          callback.remove(it)
+        } ?: emit("print:disconnect", Payload.data(printer?.printerDevice?.to() ?: true, message = "自动断开成功 (${Printer.getConnectedPrinters().size})"))
+      }
+    }.also { Printer.addConnectionListener(it) }
+  }
+
+  fun onDestroy() {
+    Printer.removeAllConnectionListener()
+    Printer.getConnectedPrinters().onEach { it.disconnect() }
+  }
+
+  private val callback = mutableMapOf<PrintDevice, (Pair<Printer?, Boolean>) -> Unit>()
+
+  fun connect(device: PrintDevice, onResult: (Pair<Printer?, Boolean>) -> Unit) {
+    device.getConnected()?.takeIf { it.isConnected }?.also { onResult(Pair(it, true)) } ?: run {
+      callback[device] = onResult
+      Printer.connect(device.toGPrinter())
+    }
+  }
+
+  fun disconnect(device: PrintDevice, onResult: (Pair<Printer?, Boolean>) -> Unit) {
+    callback[device] = onResult
+    device.getConnected()?.also { printer ->
+      printer.disconnect()
+    }
+  }
+
+  private fun emit(type: String, payload: Payload<*>) {
+    Message(
+      type = type,
+      payload = JsonUtil.gson.toJsonTree(payload, Payload::class.java)
+    ).toWebEvent().also { SharedFlowHub.emit(it) }
+  }
+
+  fun print(text: String, device: PrintDevice?) {
+    fun doPrint(printer: Printer?) {
+      if (printer == null) throw IllegalArgumentException("打印机(${device ?: ""})未连接")
+
+      val tspl = Tspl().fromString(text)
+      printer.print(tspl.bytes)
+    }
+
+    val printer = if (device == null) {
+      Printer.getConnectedPrinters().firstOrNull { it.isConnected }
+    } else {
+      device.getConnected()?.takeIf { it.isConnected }
+    }
+
+    if (printer != null) {
+      doPrint(printer)
+    } else if (device != null) {
+      connect(device) { (printer) -> doPrint(printer) }
+    } else {
+      throw IllegalArgumentException("未指定打印机且无已连接打印机")
+    }
+  }
+}
+
+private fun PrintDevice.getConnected(): Printer? {
+  return Printer.getConnectedPrinters().firstOrNull { printer -> printer.printerDevice.to() == this }
+}
+
+private fun PrintDevice.toGPrinter() = when (type) {
+  PrintDevice.Type.WIFI -> WifiPrinterDevice().apply {
+    ip = this@toGPrinter.ip
+    port = this@toGPrinter.port!!
+  }
+}
+
+private fun PrinterDevice.to(): PrintDevice? {
+  return when (this) {
+    is WifiPrinterDevice -> PrintDevice(ip = ip, port = port)
+    else -> null
+  }
+}

+ 85 - 0
library/device/printer/src/main/java/com/hzliuzhi/applet/printer/impl/PrintPdfDocumentAdapter.kt

@@ -0,0 +1,85 @@
+package com.hzliuzhi.applet.printer.impl
+
+
+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 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 PrintPdfDocumentAdapter(
+  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 { throwable ->
+      callback?.onWriteFailed(throwable.message)
+      onError("打印错误: ${throwable.message ?: "失败"}").takeIf { it }?.also { file.delete() }
+    }
+  }
+
+  override fun onFinish() {
+    super.onFinish()
+    onFinished().takeIf { it }?.also { file.delete() }
+  }
+
+  companion object {
+    fun download(context: Context, url: String, onFinished: (File) -> Unit, onError: (String) -> Unit) {
+      val request = Request.Builder().url(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 { sink ->
+                response.body?.source()?.let { sink.writeAll(it) }
+              }
+            }
+          }.onSuccess { onFinished(it) }.onFailure { onError("下载失败: ${it.message}") }
+        }
+      })
+    }
+  }
+}

+ 60 - 0
library/device/printer/src/main/java/com/hzliuzhi/applet/printer/impl/Tspl.kt

@@ -0,0 +1,60 @@
+package com.hzliuzhi.applet.printer.impl
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Base64
+import android.util.Log
+import com.gainscha.sdk2.command.Tspl
+import com.gainscha.sdk2.command.Tspl.BITMAP_MODE
+
+fun Tspl.fromString(value: String?) = apply {
+  value.takeUnless { it.isNullOrEmpty() }?.trimIndent()?.lines()?.forEach {
+    val line = it.trim()
+    // Log.d("log:printer", "G line:-> $line")
+    when {
+      line.startsWith("BITMAP", true) -> runCatching {
+        /**
+         * BITMAP x,y,width,height,mode,base64_data
+         *  - x,y : 坐标,表示图片左上角在标签纸上的起始位置(单位:点,1mm ≈ 8点,具体看打印机DPI)。
+         *  - width : 图片的宽度(单位:字节,以 8 点为单位,1字节=8点),描述每一行有多少个字节(每个字节8点)
+         *  - height : 图片的高度(单位:点),描述总共有多少行。
+         *  - mode,位图模式:
+         *    - 0:OVERWRITE (覆盖,即直接覆盖原有内容)。
+         *    - 1:OR(按位或,叠加)。
+         *    - 2:XOR(按位异或)。
+         *    - 3:OVERWRITE_COMPRESS
+         *  - bitmap_data : 图片数据,需要转换成 bitmap_data
+         */
+        Regex("""BITMAP\s+(\d+),(\d+),(\d+),(\d+),(\d+),(.+)""").find(line)?.also { match ->
+          val (x, y, width, height, mode, data) = match.destructured
+          addBitmap(
+            x.toInt(),
+            y.toInt(),
+            getBitmapFromString(data),
+            width.toInt() * 8, // width 参数为字节数,实际像素宽度=width*8
+            getBitmapModeFromInt(mode.toInt()),
+            false
+          )
+        } ?: throw IllegalArgumentException("BITMAP 指令格式错误 (BITMAP x,y,width,height,mode,base64_data)")
+      }.onFailure { Log.e("TSPL", "BITMAP 指令解析异常: $line, error: ${it.message}") }
+
+      line.isNotBlank() -> addData("$line\r\n")
+    }
+  }
+}
+
+private fun getBitmapFromString(value: String): Bitmap {
+  val base64 = value.substringAfterLast("base64,", value).trim()
+  val bytes = Base64.decode(base64, Base64.DEFAULT)
+  return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+}
+
+private fun getBitmapModeFromInt(value: Int): BITMAP_MODE {
+  return when (value) {
+    0 -> BITMAP_MODE.OVERWRITE
+    1 -> BITMAP_MODE.OR
+    2 -> BITMAP_MODE.XOR
+    3 -> BITMAP_MODE.OVERWRITE_COMPRESS
+    else -> BITMAP_MODE.OVERWRITE
+  }
+}

+ 17 - 0
library/device/printer/src/test/java/com/hzliuzhi/applet/printer/ExampleUnitTest.kt

@@ -0,0 +1,17 @@
+package com.hzliuzhi.applet.printer
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+  @Test
+  fun addition_isCorrect() {
+    assertEquals(4, 2 + 2)
+  }
+}

+ 5 - 0
settings.gradle.kts

@@ -16,6 +16,10 @@ dependencyResolutionManagement {
   repositories {
   repositories {
     google()
     google()
     mavenCentral()
     mavenCentral()
+    maven {
+      url = uri("http://118.31.6.84:8081/repository/maven-public/")
+      isAllowInsecureProtocol = true
+    }
     maven { url = uri("https://jitpack.io") }
     maven { url = uri("https://jitpack.io") }
     maven { url = uri("${rootProject.projectDir}/local-repo") }
     maven { url = uri("${rootProject.projectDir}/local-repo") }
   }
   }
@@ -26,4 +30,5 @@ include(":app")
 include(":core")
 include(":core")
 include(":library:browser")
 include(":library:browser")
 include(":library:device:pulse")
 include(":library:device:pulse")
+include(":library:device:printer")
 include(":library:device:scanner")
 include(":library:device:scanner")