完善生产查询与登录权限配置

This commit is contained in:
2026-09-03 19:56:19 +08:00
parent 793f140a45
commit 02c8fb7021
438 changed files with 1595 additions and 573 deletions
@@ -0,0 +1,61 @@
# 更新日志
## 1.3.0(2026-08-29)
### 新增
- `scanStyle.cornerStyle` 新增 `rounded` 完整圆角边框和 `rounded-corner` 四角圆角边框。
- 新增 `scanStyle.cornerRadius`,单位 dp,范围 `0 ~ 160`,默认 `20`。
- 两种圆角扫描框启用遮罩时,遮罩缺口和扫描线同步按同一圆角路径裁剪。
### 兼容性
- 保留 `corner` 四角边框与 `full` 完整矩形边框的原有绘制和调用方式。
- 圆角半径会自动限制在扫描框短边的一半以内,适配窄条码扫描框。
## 1.2.0(2026-08-28)
### 新增
- 新增独立的 `scanStyle` 配置,不再将视觉表现与 `scanRegion` 识别区域混合。
- 支持 `showMask`,可控制扫描框外遮罩的显示与隐藏。
- 支持 `showBeam`,可控制扫描线的显示与隐藏。
- 支持 `borderColor`、`maskColor`、`beamColor` 自定义边框、遮罩和扫描线颜色。
- 支持 `beamDuration` 配置扫描线移动周期,原生层限制为 `100 ~ 60000` 毫秒。
- 支持 `cornerStyle`:`corner` 四角边框、`full` 完整矩形边框。
- README 补充扫描区域、视觉样式、扫码页交互、生命周期回调和错误码说明。
- 新增 `9021005 ~ 9021011` 细化错误码,覆盖相机占用/初始化、相册读取与无条码、非法 `formats` 和单次扫码超时。
- 新增可选 `scanTimeout`,单次扫码可设置 `1000 ~ 600000` 毫秒超时;连续扫码不启用超时。
### 兼容性
- 保持原有 `openCodeScan` 调用方式不变。
- 未传 `scanStyle` 时使用默认样式:绿色边框、半透明黑色遮罩、绿色扫描线、四角边框。
- `scanStyle` 仅影响 Android 扫描页绘制,不改变条码识别逻辑。
- 同屏多码定格后的绿色点选标记增加呼吸波纹动画,帮助用户快速定位可点击目标。
## 1.1.0(2026-07-22)
### 新增
- 增加相册识图能力,支持打开扫码页后直接进入相册,也支持扫码页内手动选图。
- 增加 `formats` 码制过滤,支持二维码、一维码、商品码、Data Matrix、PDF417、Aztec 等格式。
- 增加 `vibrate` 成功震动开关。
- 增加 `continuous` 连续扫码和 `showPauseButton` 暂停/继续控制。
- 增加 `torch` 手电筒策略:`off`、`on`、`auto`。
- 增加 `resultMode` 结果处理策略:`immediate`、`confirm`、`pause`。
- 区分 Activity 获取失败、相机权限拒绝、用户取消和无有效结果等错误码。
### 增强
- 支持同屏多个条码定格并点击选择。
- 增加 `smartZoom` 智能变焦和双指手势缩放。
- 拆分 Kotlin 原生实现,改善扫码页、相册识图、结果处理和码制解析的维护性。
## 1.0.0(2026-07-22)
### 新增
- 初始化 Android 扫码 UTS API 插件。
- 基于 CameraX + Google ML Kit 实现实时条码识别。
- 支持通过 `success`、`fail`、`complete` 回调接收扫码结果和流程状态。
@@ -0,0 +1,101 @@
{
"id": "mas-code-scan",
"displayName": "android端扫码功能,uts插件,支持多二维码",
"version": "1.3.0",
"description": "Android 扫码 UTS 插件:全格式、多码点选、相册识图、连续扫、码制过滤、圆角扫描框视觉配置、错误码细分和扫码超时",
"keywords": [
"code-scan",
"扫码",
"scan-code",
"android"
],
"repository": "",
"engines": {
"HBuilderX": "^3.6.8",
"uni-app": "^5.0",
"uni-app-x": "^5.0"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "20871209"
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "相机、震动"
},
"npmurl": "",
"darkmode": "x",
"i18n": "x",
"widescreen": "√"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "x",
"chrome": "x"
},
"app": {
"vue": "√",
"nvue": "x",
"android": "√",
"ios": "x",
"harmony": "x"
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x",
"xhs": "x"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}
@@ -0,0 +1,751 @@
# mas-code-scan
Android 端扫码 **UTS API 插件**(非 Vue 组件)。
基于 CameraX + Google ML Kit,支持全格式条码/二维码、多码同屏点选、识别区域过滤、扫描框视觉配置、智能变焦、手势缩放、相册识图、连续扫码、手电筒策略、结果确认和暂停控制。
插件对外只提供 `openCodeScan` 一个 UTS API。扫码页由原生 Android Activity 提供,识别结果通过 `success`、`fail`、`complete` 回调返回。
支持:**Android App(uni-app Vue2/Vue3)**。
暂不支持:iOS / 鸿蒙 / H5 / 小程序 / nvue。
> 依赖原生三方库,**必须使用自定义调试基座或正式云打包**后才能真机调用。
---
## 插件定位与工作流程
`mas-code-scan` 是一个打开原生 Android 扫码 Activity 的 UTS API 插件,不是 Vue 组件,也不是对 `uni.scanCode` 的简单包装。一次扫码流程如下:
1. 业务页面调用 `openCodeScan(options)`。
2. 插件启动全屏竖屏原生扫码页,并根据参数初始化 CameraX、ML Kit 和界面控件。
3. 相机帧交给 ML Kit 识别;如果配置了 `scanRegion`,实时结果还会经过扫描框中心点过滤。
4. 单码按照 `resultMode` 返回;多码进入定格点选;连续模式通过 `success` 多次上报。
5. Activity 结束后通过 `complete` 收尾;失败场景同时通过 `fail` 和 `complete` 通知。
### 能力边界
- 识别引擎:Google ML Kit Barcode Scanning。
- 相机管线:CameraX,使用后置摄像头,预览采用 `FILL_CENTER` 填充显示。
- 图片来源:实时相机帧、系统相册图片。
- 原生页面:全屏、竖屏、无标题栏;关闭、相册、手电筒和连续扫码暂停按钮由插件绘制。
- 生命周期:每次调用创建一个扫码会话;关闭、成功、权限失败、超时或初始化失败后会话结束。
## 目录
1. [功能一览](#功能一览)
2. [安装](#安装)
3. [制作自定义基座(必做)](#制作自定义基座必做)
4. [快速上手](#快速上手)
5. [进阶用法](#进阶用法)
6. [API](#api)
7. [错误码](#错误码)
8. [扫码页交互说明](#扫码页交互说明)
9. [码制 formats 取值](#码制-formats-取值)
10. [常见问题](#常见问题)
11. [注意事项](#注意事项)
12. [目录结构](#目录结构)
13. [版本](#版本)
---
## 功能一览
| 能力 | 说明 |
| --- | --- |
| 全格式识别 | QR / EAN / CODE_128 / PDF417 / AZTEC 等 |
| 码制过滤 | `formats` 只扫指定类型 |
| 扫描区域 | `scanRegion` 配置扫描框尺寸、位置,并过滤框外条码 |
| 扫描框样式 | `scanStyle` 配置遮罩、扫描线、边框颜色、动画周期、边框形态和圆角大小 |
| 单码自动返回 | 识别成功后关闭并回调 |
| 多码点选 | 同屏多个码定格,点选其中一个 |
| 智能变焦 | `smartZoom`:码偏小时自动放大 |
| 手势缩放 | 双指捏合 |
| 手电筒 | 扫码页右下角开关,支持关闭、启动时开启和低照度自动补光 |
| 相册识图 | 页内相册按钮,或 `fromAlbum: true` |
| 连续扫码 | `continuous: true`,多次 `success`,点关闭结束;可显示暂停/继续按钮 |
| 结果处理 | 单次扫码支持立即返回、确认后返回、暂停后选择 |
| 成功震动 | `vibrate`,可关 |
| 错误码区分 | 权限拒绝 `9021002` ≠ 用户取消 `9021003` |
| 原生回收 | 页面结束时停止分析、释放相机、关闭 ML Kit Scanner、关闭手电筒并清理临时图片 |
---
## 安装
将插件拷贝到业务项目:
```text
你的项目/uni_modules/mas-code-scan/
```
UTS 插件需 **import 后调用**,不会 easycom 自动注册组件。
要求:
- HBuilderX ≥ 3.6.8(建议较新版本)
- uni-app Vue2 / Vue3 均可
- **仅 Android**;需制作自定义基座
本仓库演示页:`frontend/scanapp/pages/scan/prototype.vue`,用于在 Android 自定义基座中验证插件能力。页面包含:
- 智能变焦、成功震动、手电筒策略、结果处理模式和连续扫码暂停按钮。
- 扫码页标题、扫描引导语、成功提示语、无结果提示语自定义。
- 连续扫码成功提示停留时长、单次扫码超时配置。
- 二维码正方形框、横向条码框和全屏识别三种识别区域预设;横向条码框支持上/中/下位置、自定义上下微调和三档高度。
- 扫描框遮罩、扫描线、边框颜色、遮罩颜色、扫描线颜色、扫描线周期、四种边框形态和圆角半径配置。
- 二维码、CODE 128、EAN-13 码制筛选,相册识图、单次扫码、连续扫码和本地结果列表。
演示页的“最近结果”仅保存在当前页面内,用于展示回调结果,不会提交业务接口。
---
## 制作自定义基座(必做)
插件依赖:
- `androidx.camera:*` 1.4.1
- `com.google.mlkit:barcode-scanning` 17.3.0
标准基座不含上述库,**不制作自定义基座会调用失败 / 编译不过**。
### 步骤
1. HBuilderX 菜单:**运行** → **运行到手机或模拟器** → **制作自定义调试基座**
2. 选择当前项目,填写 Android 包名、证书(可用公共测试证书)
3. 云打包,等待完成并安装到手机
4. 之后每次运行选择 **「自定义调试基座」**,再打开演示页或业务页调试扫码
正式发版:使用 **云打包 / 本地打包** 生成安装包即可(打包流程会带上 UTS 原生依赖)。
---
## 快速上手
### 1. uni-app(Vue3 `<script setup>`)
```vue
<template>
<view>
<button type="primary" @click="doScan">扫码</button>
<text>{{ tip }}</text>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { openCodeScan } from '@/uni_modules/mas-code-scan'
const tip = ref('')
const doScan = () => {
openCodeScan({
success: (res) => {
tip.value = res.content + ' / ' + res.format
},
fail: (err) => {
tip.value = err.errMsg + '(' + err.errCode + ')'
}
})
}
</script>
```
### 2. uni-app(Options API)
```js
import { openCodeScan } from '@/uni_modules/mas-code-scan'
export default {
methods: {
doScan() {
openCodeScan({
success: (res) => {
console.log(res.content, res.format, res.fromAlbum)
},
fail: (err) => {
console.log(err.errCode, err.errMsg)
},
complete: () => {
console.log('扫码流程结束')
}
})
}
}
}
```
### 3. 最少参数
```ts
import { openCodeScan } from '@/uni_modules/mas-code-scan'
openCodeScan({
success: (res) => console.log(res.content)
})
```
---
## 进阶用法
### 自定义扫码页文案
以下参数只影响原生扫码页显示文字,不影响识别逻辑:
```ts
openCodeScan({
title: '扫描商品码',
hint: '请将商品条码放入框内',
successHint: '识别成功',
emptyHint: '图片中没有商品条码',
success: (res) => console.log(res.content)
})
```
- `title`:顶部标题,默认 `扫一扫`。
- `hint`:扫描框下方引导语,默认 `请将条码放入扫描框内`。
- `successHint`:连续扫码成功提示层标题,也用于部分原生提示,默认 `扫码成功`。
- `emptyHint`:相册图片未识别到条码时的提示,默认 `未识别到有效条码`。
- 空字符串或全空格会回退到默认文案;原生层会去除首尾空格。
### 只扫二维码
```ts
openCodeScan({
formats: ['QR_CODE'],
success: (res) => console.log(res.content)
})
```
### 手电筒和结果处理
```ts
openCodeScan({
torch: 'auto', // 'off' | 'on' | 'auto'
resultMode: 'confirm', // 'immediate' | 'confirm' | 'pause'
success: (res) => console.log(res.content)
})
```
`confirm` 会弹出结果确认框;`pause` 会暂停识别并提供“继续扫描/确认”操作。两种模式下,点击确认才会结束并触发 `success`、`complete`;点击重新扫描或继续扫描会恢复相机识别,不会触发失败回调。
### 设置单次扫码超时
```ts
openCodeScan({
scanTimeout: 15000,
success: (res) => console.log(res.content),
fail: (err) => {
if (err.errCode === 9021011) console.log('15 秒内未识别到条码')
}
})
```
`scanTimeout` 单位为毫秒。只有单次扫码且值在 `1000 ~ 600000` 时启用;不传、传 `0` 或小于 `1000` 时不启用。连续扫码始终不启用单次超时。
### 关闭智能变焦 / 震动
```ts
openCodeScan({
smartZoom: false,
vibrate: false,
success: (res) => console.log(res.content)
})
```
### 配置扫描区域
`scanRegion` 同时决定扫描框的位置和实时识别过滤区域。宽度、高度、中心点均使用 `0 ~ 1` 的比例值;宽高以预览宽度为参考,越界值会由原生层收敛到可显示范围。`scanRegion` 只作用于实时相机,不裁剪或过滤相册图片。
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
success: (res) => console.log(res.content)
})
```
- 不传 `scanRegion`:保持全屏识别,同时保留默认扫描框显示。
- `scanRegion: { fullScreen: true }`:整画面识别,不显示扫描框,也不进行区域过滤。
- 配置扫描区域后,条码中心点必须落在扫描框内才会被实时相机识别结果接受。
- 相册识图不受 `scanRegion` 限制。
### 配置扫描框视觉样式
`scanStyle` 只控制扫描页视觉表现,不改变识别区域。颜色支持 `#RRGGBB`、`#AARRGGBB`;遮罩还支持 `rgba(r, g, b, a)`。
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
scanStyle: {
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880',
beamDuration: 1800,
cornerStyle: 'rounded',
cornerRadius: 20
},
success: (res) => console.log(res.content)
})
```
- `showMask: false` 仅隐藏框外遮罩,不会扩大或改变识别范围。
- `showBeam: false` 隐藏扫描线;扫描线周期 `beamDuration` 单位为毫秒,原生层限制在 `100 ~ 60000`。
- `cornerStyle: 'corner'` 显示直角四角边框,`'full'` 显示完整矩形边框,`'rounded'` 显示完整圆角边框,`'rounded-corner'` 显示四角圆角边框。
- `cornerRadius` 为圆角半径,单位为 dp,范围 `0 ~ 160`,在两种圆角边框样式下均生效;圆角遮罩缺口和扫描线会同步裁剪为相同圆角。
- `rounded` 是四边完整的圆角边框;`rounded-corner` 只绘制四个带圆弧过渡的角,中间不连接四条边。
- 圆角半径最终还会限制在扫描框短边的一半以内,适合二维码框和横向条码框。
- 当未启用 `scanRegion` 时,视觉样式仅作用于默认扫描框;`fullScreen: true` 时不会显示扫描框。
### 打开后直接进相册
```ts
openCodeScan({
fromAlbum: true,
success: (res) => {
console.log(res.content, res.fromAlbum) // fromAlbum === true
}
})
```
扫码页内也可随时点左下角 **相册** 按钮选图识别。
### 连续扫码
页内不关闭,每识别一次触发一次 `success`;用户点左上角关闭(或系统返回)后走 `complete`(若已有命中,**不会**再走 `fail`)。
```ts
const list = []
openCodeScan({
continuous: true,
showPauseButton: true,
vibrate: true,
success: (res) => {
list.push(res.content)
console.log('命中', res.content, res.format)
},
fail: (err) => {
// 一次都没扫到就关闭 → 一般是 9021003 取消
console.log('失败', err.errCode, err.errMsg)
},
complete: (res) => {
if (res && res.closed) {
console.log('结束,共', res.hitCount, '次', list)
}
}
})
```
### 组合:仅二维码 + 连续扫 + 相册入口
```ts
openCodeScan({
formats: ['QR_CODE'],
continuous: true,
smartZoom: true,
vibrate: true,
success: (res) => console.log(res),
complete: (res) => console.log('done', res)
})
```
---
## API
### openCodeScan(options?)
打开全屏扫码页。
#### CodeScanOptions
| 参数 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | :---: | --- | --- |
| `title` | `string` | 否 | `扫一扫` | 原生扫码页顶部标题 |
| `hint` | `string` | 否 | `请将条码放入扫描框内` | 扫描框下方引导语 |
| `successHint` | `string` | 否 | `扫码成功` | 连续扫码成功提示层标题 |
| `emptyHint` | `string` | 否 | `未识别到有效条码` | 图片无条码时提示语 |
| `smartZoom` | `boolean` | 否 | `true` | 条码占画面较小时自动平滑放大;最多尝试 6 次 |
| `formats` | `string[]` | 否 | 全部 | 限定码制,见下方取值表;空/不传 = 全格式 |
| `vibrate` | `boolean` | 否 | `true` | 识别成功后是否短震动约 40ms |
| `scanRegion` | `ScanRegion` | 否 | 未启用 | 实时相机识别区域,控制扫描框尺寸、位置和中心点过滤 |
| `scanStyle` | `CodeScanStyle` | 否 | 见下表 | 扫描框视觉样式,不改变识别区域 |
| `torch` | `'off' \| 'on' \| 'auto'` | 否 | `'off'` | 手电筒策略;`on` 启动后开启,`auto` 低照度连续满足条件后自动开启 |
| `resultMode` | `'immediate' \| 'confirm' \| 'pause'` | 否 | `'immediate'` | 单次扫码结果处理方式;连续扫码固定按立即上报处理 |
| `continuous` | `boolean` | 否 | `false` | 连续扫:页内不关闭,每次命中触发一次 `success` |
| `continuousSuccessDuration` | `number` | 否 | `1000` | 连续扫码成功提示停留时长,单位毫秒,范围 `0 ~ 600000` |
| `showPauseButton` | `boolean` | 否 | `false` | 仅 `continuous: true` 时显示右上角暂停/继续按钮 |
| `fromAlbum` | `boolean` | 否 | `false` | `true` 时打开扫码页后立即拉起系统相册 |
| `scanTimeout` | `number` | 否 | 不启用 | 单次扫码超时,单位毫秒,范围 `1000 ~ 600000` |
| `success` | `(res: CodeScanSuccess) => void` | 否 | - | 成功;连续模式下可多次触发 |
| `fail` | `(err: CodeScanFail) => void` | 否 | - | 失败或取消;连续模式已有命中后关闭时不触发 |
| `complete` | `(res: any) => void` | 否 | - | 会话结束;成功、失败、取消和连续扫码关闭都会触发 |
#### ScanRegion
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `fullScreen` | `boolean` | `false` | 为 `true` 时整画面识别,不显示扫描框,也不做中心点过滤;优先级高于其他区域参数 |
| `width` | `number` | `0.74` | 扫描框宽度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
| `height` | `number` | `0.74` | 扫描框高度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
| `centerX` | `number` | `0.5` | 扫描框中心横坐标比例,范围 `0 ~ 1`,越界会收敛 |
| `centerY` | `number` | `0.5` | 扫描框中心纵坐标比例,范围 `0 ~ 1`,越界会收敛 |
区域映射会补偿 `PreviewView` 的 `FILL_CENTER` 裁剪偏移和图像旋转;最终判断的是 ML Kit 包围盒的中心点是否在区域内,而不是要求整个条码完全位于框内。
建议:二维码使用接近正方形的区域,横向条码使用较宽且较矮的区域;如果业务只需要识别、不需要框选提示,可使用 `fullScreen: true`。
#### 组合约束与默认行为
| 组合 | 实际行为 |
| --- | --- |
| 不传 `scanRegion` | 实时相机按全画面识别;使用原生默认扫描框布局 |
| `scanRegion.fullScreen: true` | 全画面识别,不显示扫描框,不过滤框外结果 |
| `fromAlbum: true` | 先打开相册;选图识别失败后,在相机可用时仍可继续相机扫码 |
| `continuous: true` | 页内持续识别,`resultMode` 不参与单码确认;关闭页结束会话 |
| `continuous: true` + `showPauseButton: true` | 右上角显示暂停/继续按钮,暂停期间不处理相机帧 |
| `continuous: true` + `scanTimeout` | 忽略 `scanTimeout`,连续扫码没有会话超时 |
| `continuous: true` + `resultMode` | 连续扫码固定立即上报,每次命中通过 `success` 返回 |
| `formats` 含未知值和有效值 | 忽略未知值,使用有效码制 |
| `formats` 非空且全部未知 | 立即失败并返回 `9021010` |
| 单次识别到多个有定位框的条码 | 定格当前画面,显示绿色点选标记,点击后返回选中条码 |
| 连续扫码短时间重复同一内容 | 约 1.2 秒内去重,避免重复触发 `success` |
### CodeScanStyle
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `showMask` | `boolean` | `true` | 是否显示扫描框外遮罩 |
| `showBeam` | `boolean` | `true` | 是否显示扫描线 |
| `borderColor` | `string` | `#19be6b` | 边框颜色,支持 `#RRGGBB`、`#AARRGGBB` |
| `maskColor` | `string` | `rgba(0, 0, 0, 0.45)` | 遮罩颜色和透明度,支持 `rgba()` |
| `beamColor` | `string` | `#12e880` | 扫描线颜色,支持 `#RRGGBB`、`#AARRGGBB` |
| `beamDuration` | `number` | `1800` | 扫描线移动周期,单位为毫秒,范围 `100 ~ 60000` |
| `cornerStyle` | `'corner' \| 'full' \| 'rounded' \| 'rounded-corner'` | `'corner'` | `corner` 为直角四角边框,`full` 为完整边框,`rounded` 为完整圆角边框,`rounded-corner` 为四角圆角边框 |
| `cornerRadius` | `number` | `20` | 圆角半径,单位 dp,范围 `0 ~ 160`,两种圆角样式均生效 |
默认值:`showMask: true`、`showBeam: true`、`borderColor: '#19be6b'`、`maskColor: 'rgba(0, 0, 0, 0.45)'`、`beamColor: '#12e880'`、`beamDuration: 1800`、`cornerStyle: 'corner'`、`cornerRadius: 20`。非法颜色会回退到默认值,`beamDuration` 会限制在 `100 ~ 60000` 毫秒,`cornerRadius` 会限制在 `0 ~ 160` dp。
示例:
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
scanStyle: {
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880',
beamDuration: 1800,
cornerStyle: 'rounded',
cornerRadius: 20
},
success: (res) => console.log(res.content)
})
```
`scanRegion` 控制实时识别区域,`scanStyle` 只控制扫描页的视觉表现;两者可以独立配置。不传 `scanRegion` 时实时相机按全画面识别,但仍显示原生默认扫描框;需要隐藏扫描框时请传 `scanRegion: { fullScreen: true }`。
#### 回调返回值与生命周期
单次扫码成功:
```ts
success({ content: '...', format: 'QR_CODE', fromAlbum: false })
complete({ content: '...', format: 'QR_CODE', fromAlbum: false })
```
单次扫码失败或用户取消:
```ts
fail({ errCode: 9021003, errMsg: '用户取消扫码', errSubject: 'mas-code-scan' })
complete({ errCode: 9021003, ... })
```
连续扫码关闭:
```ts
complete({ closed: true, hitCount: 3 })
```
连续扫码如果一次命中都没有就关闭,通常会先触发 `fail(9021003)`,随后触发 `complete({ closed: true, hitCount: 0, failed: true, errCode: 9021003 })`。已有命中后关闭只触发 `complete`,不会触发 `fail`。
| 回调 | 触发时机 | 注意 |
| --- | --- | --- |
| `success` | 单次确认成功;或连续模式每次命中 | 多码点选只有点击某个码后触发 |
| `fail` | Activity 无法启动、权限失败、相机失败、相册失败、超时、用户取消等 | 用户取消错误码为 `9021003` |
| `complete` | 每次会话结束 | 不能只依赖 `success` 清理页面状态 |
#### CodeScanSuccess
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `content` | `string` | 识别到的文本内容 |
| `format` | `string` | 码制名,如 `QR_CODE`、`CODE_128` |
| `fromAlbum` | `boolean` | 是否来自相册识图(可选) |
#### CodeScanFail
继承 `UniError`:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `errCode` | `number` | 见错误码表 |
| `errMsg` | `string` | 错误描述 |
| `errSubject` | `string` | 固定 `"mas-code-scan"` |
回调规则:
- 单次扫码成功:依次调用 `success`、`complete`,成功对象包含 `content`、`format` 和 `fromAlbum`。
- 单次扫码失败或取消:依次调用 `fail`、`complete`。
- 连续扫码:每次命中调用一次 `success`;关闭页面时调用 `complete({ closed: true, hitCount })`。
- 连续扫码在已有命中后关闭:不会再调用 `fail`,只调用 `complete`。
- 相册识图成功:`fromAlbum` 为 `true`;相机实时识别成功时为 `false`。
#### 类型定义(摘录)
```ts
type CodeScanOptions = {
title?: string
hint?: string
successHint?: string
emptyHint?: string
smartZoom?: boolean
formats?: string[]
vibrate?: boolean
torch?: 'off' | 'on' | 'auto'
resultMode?: 'immediate' | 'confirm' | 'pause'
continuous?: boolean
continuousSuccessDuration?: number
showPauseButton?: boolean
fromAlbum?: boolean
scanRegion?: {
fullScreen?: boolean
width?: number
height?: number
centerX?: number
centerY?: number
}
scanStyle?: {
showMask?: boolean
showBeam?: boolean
borderColor?: string
maskColor?: string
beamColor?: string
beamDuration?: number
cornerStyle?: 'corner' | 'full' | 'rounded' | 'rounded-corner'
cornerRadius?: number
}
// 单次扫码超时(毫秒);小于 1000 或不传则不启用,最大 600000;连续扫码不启用超时
scanTimeout?: number
success?: (res: CodeScanSuccess) => void
fail?: (res: CodeScanFail) => void
complete?: (res: any) => void
}
type CodeScanSuccess = {
content: string
format: string
fromAlbum?: boolean
}
```
---
## 错误码
| 错误码 | 说明 | 常见场景 |
| --- | --- | --- |
| `9021001` | 无法获取页面 Activity / 平台不支持 | 非 Android、页面上下文异常 |
| `9021002` | 相机权限被拒绝 | 用户拒绝相机权限(非「仅相册」场景) |
| `9021003` | 用户取消扫码 | 点关闭、系统返回、未扫就退出 |
| `9021004` | 未获取到有效扫码结果 | 成功关页但内容为空(少见) |
| `9021005` | 相机被其他应用占用或当前不可用 | 相机被其他应用、系统功能或设备状态占用 |
| `9021006` | 设备不支持手电筒 | 点击手电筒时提示;不结束当前扫码会话 |
| `9021007` | 相机初始化失败 | CameraX 初始化、绑定预览或分析管线失败 |
| `9021008` | 相册图片无法读取 | 所选 URI 无法读取、复制或解码图片 |
| `9021009` | 图片中未识别到条码 | `fromAlbum: true` 且无可回退相机时,图片中不存在有效码 |
| `9021010` | 指定码制不支持 | `formats` 非空但其中没有插件支持的码制 |
| `9021011` | 扫码超时 | 单次扫码设置了有效 `scanTimeout` 且超时未成功;连续扫码不启用 |
说明:
- `9021001` ~ `9021004` 为已有错误码,含义保持不变;新增错误码从 `9021005` 开始。
- `fail` 与 `complete` 在失败时都会触发(连续扫「已有命中后关闭」除外:只 `complete`)。
- 打开即相册且无相机权限时,仍可用相册;点关闭且无命中 → 一般为 `9021003`。
- 无闪光灯不影响扫码,因此 `9021006` 仅通过扫码页提示展示,不会触发 `fail`。
---
## 扫码页交互说明
| 控件 / 手势 | 说明 |
| --- | --- |
| 左上关闭 | 退出;连续扫时结束会话 |
| 左下相册 | 选图识别;多码时同样可点选 |
| 右下手电筒 | 有闪光灯时可用;可由 `torch` 设置初始策略,`auto` 会在低照度时自动开启 |
| 右上暂停按钮 | 仅连续扫码且 `showPauseButton: true` 时显示,暂停/继续识别 |
| 结果确认框 | `resultMode: 'confirm'` 或 `'pause'` 时,确认返回或继续扫描 |
| 双指捏合 | 手动变焦 |
| 多码绿点 | 同屏多个有效码时定格,显示绿色呼吸波纹,点击选中 |
| 系统返回键 | 等同关闭 |
---
## 码制 formats 取值
传入字符串数组;空/缺省时按全格式。若 `formats` 非空但全部不受支持,调用失败并返回 `9021010`。
| 取值 | 说明 |
| --- | --- |
| `QR_CODE` / `QR` | 二维码 |
| `EAN_13` / `EAN_8` | 商品条码 |
| `CODE_128` / `CODE_39` / `CODE_93` | 一维码 |
| `CODABAR` | Codabar |
| `DATA_MATRIX` | Data Matrix |
| `UPC_A` / `UPC_E` | UPC |
| `ITF` | ITF |
| `PDF417` | PDF417 |
| `AZTEC` | Aztec |
| `ALL` | 全格式(与具体码制混用时按全格式处理) |
补充规则:插件会忽略未知 token;只要数组中存在一个有效 token 就使用有效 token。数组非空但全部无效时返回 `9021010`。`ALL` 与其他有效码制同时传入时最终使用全格式。
示例:
```ts
formats: ['QR_CODE', 'CODE_128', 'EAN_13']
```
---
## 常见问题
### 1. 点击没反应 / 报找不到类?
未使用自定义基座。请按上文制作并选择「自定义调试基座」再运行。
### 2. 提示相机权限被拒绝(9021002)?
到系统设置打开应用相机权限后重试。
### 3. 连续扫只回调一次?
确认传了 `continuous: true`。同一内容约 1.2 秒内会去重,避免狂刷;换个码或稍等再扫。
### 4. 相册选了图但提示未识别?
图中可能没有清晰条码,或被 `formats` 过滤掉。可先不传 `formats` 试全格式;打开即相册且没有可用相机时,会以 `9021009` 返回无条码错误。
### 5. 为什么会提示扫码超时(9021011)?
仅在单次扫码传入 `scanTimeout`(1000 ~ 600000 毫秒)后启用。超时不会影响连续扫码;不需要限制时不传该参数或传 `0`。
### 6. iOS / 鸿蒙能用吗?
当前版本仅 Android。目录未包含 iOS/鸿蒙实现。
### 7. 能否当组件标签用?
不能。这是 **UTS API**,请 `import { openCodeScan } from '@/uni_modules/mas-code-scan'` 后调用。
### 8. 演示页可以直接当业务页面使用吗?
`prototype.vue` 是能力验证页面,结果列表是本地状态,不包含业务接口提交、权限引导和业务字段转换。正式业务建议复制 `openScan` 调用方式,在自己的页面中处理 `success`、`fail` 和 `complete`,并在 `complete` 中恢复 loading、页面状态等资源。
### 9. `scanStyle` 不生效怎么办?
确认调用的是包含当前插件的自定义调试基座或正式打包应用。修改 UTS 原生代码后需要重新制作自定义基座或重新云打包,普通标准基座不会加载插件原生依赖和最新实现。
### 10. 扫描框显示了但框外条码没有识别?
这是 `scanRegion` 的预期行为。扫描框不仅是视觉提示,也会作为实时识别过滤区域;如需整屏识别,请不传 `scanRegion` 或传入 `fullScreen: true`。
### 11. `resultMode` 和 `continuous` 一起传会怎样?
连续扫码以连续模式为准,不显示单次扫码确认框,每次识别成功都会通过 `success` 上报;如需单个结果确认,请不要传 `continuous: true`。
### 12. `fromAlbum` 和相机权限有什么关系?
Android 13 及以上优先使用系统 Photo Picker;Android 12 及以下使用系统图片选择器,可能需要读取相册权限。打开即相册时,即使没有相机权限,也可以先完成相册识图;相册无结果且无法继续使用相机时返回 `9021009`。
### 13. 为什么识别框位置和相机画面看起来不完全一致?
预览采用 `FILL_CENTER`,相机画面会按比例填充并裁剪边缘;插件会在识别过滤时补偿裁剪和旋转,但不同设备的相机传感器比例、状态栏和厂商裁剪策略可能存在少量视觉差异。
---
## 注意事项
1. 仅 Android App-Plus;发布市场包请走正式打包,勿依赖标准基座调试结果。
2. 首次使用实时相机需要相机权限;插件不会主动跳转系统设置,权限被拒绝后由业务决定是否引导用户开启。
3. 震动权限由插件 Manifest 声明;设置 `vibrate: false` 可关闭成功震动。
4. 相册选图走系统选择器;Android 12 及以下可能需要读取相册权限,Android 13 及以上优先使用 Photo Picker。
5. `scanRegion` 只过滤实时相机,且按条码包围盒中心点判断;相册识图始终分析整张图片。
6. 连续扫依赖原生命中队列和 UTS 桥接轮询,业务应在 `complete` 中收尾,不要只在 `success` 中恢复状态。
7. 多码点选依赖 ML Kit 返回定位框;多码中只有一个可解码码时会直接返回,无法定位的结果不能作为可点击目标。
8. 智能变焦和双指缩放共享相机变焦能力;不支持变焦的设备会保持原始比例。
9. 大尺寸相册图片会先缩放到最长边约 1920 像素再识别,以降低内存压力。
10. 文档与演示不保证覆盖所有机型相机差异,建议使用真实目标机型回归。
---
## 目录结构
```text
mas-code-scan/
├── package.json
├── readme.md
├── changelog.md
└── utssdk/
├── interface.uts # 对外类型与 API 声明
├── unierror.uts # 错误实现
└── app-android/
├── index.uts # JS/UTS 桥接
├── config.json # 原生依赖
├── AndroidManifest.xml
├── CodeCaptureActivity.kt
├── MultiCodePickView.kt
├── ScanFrameDecorView.kt
├── YuvFrames.kt
├── SymbologyUtil.kt
├── CodeScanHitBus.kt
└── res/drawable/ # 关闭/相册等资源
```
---
## 版本
| 版本 | 说明 |
| --- | --- |
| 1.3.0 | 新增 `rounded` 完整圆角边框、`rounded-corner` 四角圆角边框和自定义 `cornerRadius`,遮罩与扫描线同步适配圆角 |
| 1.2.0 | 新增 `scanStyle` 扫描框视觉配置,支持遮罩、扫描线、颜色、动画周期、四角/完整边框;完善 `scanRegion` 与回调说明 |
| 1.1.0 | 权限码区分、关闭/相册、码制过滤、震动、连续扫、Kotlin 拆分 |
| 1.0.0 | Android 扫码初版 |
更多变更见 `changelog.md`。
## 相关链接
- [UTS 语法](https://uniapp.dcloud.net.cn/tutorial/syntax-uts.html)
- [UTS API 插件](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html)
- [制作自定义调试基座](https://uniapp.dcloud.net.cn/tutorial/run/run-app.html#customplayground)
@@ -0,0 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="uts.sdk.modules.masCodeScan">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application>
<activity
android:name="uts.sdk.modules.masCodeScan.CodeCaptureActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
package uts.sdk.modules.masCodeScan
import java.util.concurrent.ConcurrentLinkedQueue
/**
* 连续扫码命中总线:Activity 入队,index.uts 轮询出队并触发 success。
*/
object CodeScanHitBus {
private val queue = ConcurrentLinkedQueue<Array<String>>()
@JvmStatic
fun detach() {
queue.clear()
}
@JvmStatic
fun emitHit(content: String, format: String, fromAlbum: Boolean) {
queue.offer(
arrayOf(
content,
format,
if (fromAlbum) "1" else "0"
)
)
}
/** 取出一条命中:[content, format, fromAlbumFlag];无数据返回 null */
@JvmStatic
fun pollHit(): Array<String>? {
return queue.poll()
}
}
@@ -0,0 +1,96 @@
package uts.sdk.modules.masCodeScan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.view.View
import kotlin.math.min
/**
* 连续扫码成功提示层:覆盖扫描框中部,显示成功状态和本次扫码值。
*/
class ContinuousSuccessOverlayView(
context: Context,
private val successHint: String
) : View(context) {
private val density = resources.displayMetrics.density
private val scaledDensity = resources.displayMetrics.scaledDensity
private val panelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(218, 48, 48, 48)
style = Paint.Style.FILL
}
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 16f * scaledDensity
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 15f * scaledDensity
textAlign = Paint.Align.CENTER
}
private val panelRect = RectF()
private var frameRect = RectF()
private var stillFrame: Bitmap? = null
private var value = ""
fun showSuccess(scanFrame: RectF, content: String, bitmap: Bitmap?) {
stillFrame?.recycle()
stillFrame = bitmap
frameRect = RectF(scanFrame)
value = content
visibility = VISIBLE
invalidate()
}
fun hideSuccess() {
stillFrame?.recycle()
stillFrame = null
value = ""
visibility = GONE
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (value.isBlank() || width <= 0 || height <= 0) return
stillFrame?.takeIf { !it.isRecycled && it.width > 0 && it.height > 0 }?.let { bitmap ->
val bitmapWidth = bitmap.width.toFloat()
val bitmapHeight = bitmap.height.toFloat()
val scale = maxOf(width / bitmapWidth, height / bitmapHeight)
val drawWidth = bitmapWidth * scale
val drawHeight = bitmapHeight * scale
val left = (width - drawWidth) / 2f
val top = (height - drawHeight) / 2f
canvas.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), null)
}
val availableWidth = if (frameRect.isEmpty) width.toFloat() else frameRect.width()
val panelWidth = min(300f * density, maxOf(0f, availableWidth - 24f * density))
val panelHeight = 68f * density
val centerX = if (frameRect.isEmpty) width / 2f else frameRect.centerX()
val centerY = if (frameRect.isEmpty) height / 2f else frameRect.centerY()
panelRect.set(
centerX - panelWidth / 2f,
centerY - panelHeight / 2f,
centerX + panelWidth / 2f,
centerY + panelHeight / 2f
)
panelRect.left = panelRect.left.coerceAtLeast(12f * density)
panelRect.right = panelRect.right.coerceAtMost(width - 12f * density)
panelRect.top = panelRect.top.coerceAtLeast(24f * density)
panelRect.bottom = panelRect.bottom.coerceAtMost(height - 24f * density)
canvas.drawRoundRect(panelRect, 12f * density, 12f * density, panelPaint)
val titleBaseline = panelRect.centerY() - 3f * density
val valueBaseline = panelRect.centerY() + 19f * density
canvas.drawText(successHint, panelRect.centerX(), titleBaseline, titlePaint)
canvas.drawText(value, panelRect.centerX(), valueBaseline, valuePaint)
}
}
@@ -0,0 +1,267 @@
package uts.sdk.modules.masCodeScan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.os.SystemClock
import android.view.View
import com.google.mlkit.vision.barcode.common.Barcode
import kotlin.math.max
import kotlin.math.sin
data class HitMark(
val rawValue: String,
val format: Int,
val formatName: String,
val screenRect: Rect
) {
val centerX: Float get() = screenRect.exactCenterX()
val centerY: Float get() = screenRect.exactCenterY()
}
/**
* 多码点选覆盖层:定格帧 + 圆点标记。
*/
class MultiCodePickView(context: Context) : View(context) {
private var stillFrame: Bitmap? = null
private var rawHits: List<Barcode> = emptyList()
private var hitMarks: List<HitMark> = emptyList()
private var visualCrop: Rect = Rect()
private var stillW: Int = 0
private var stillH: Int = 0
private var layerW: Int = 0
private var layerH: Int = 0
private var recognizing: Boolean = false
private var recognizingStartedAt: Long = 0L
private val density: Float = resources.displayMetrics.density
private val dotOuterR: Float = 18f * density
private val dotInnerR: Float = 6f * density
private val touchR: Float = 28f * density
private val breatheDurationMs = 1500L
private val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.argb(60, 7, 193, 96)
}
private val shellPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.WHITE
}
private val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.rgb(7, 193, 96)
}
private val shadePaint = Paint().apply {
color = Color.argb(40, 0, 0, 0)
}
private val promptBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(190, 0, 0, 0)
}
private val promptTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 16f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
private val loadingTrackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 3f * density
strokeCap = Paint.Cap.ROUND
color = Color.argb(100, 255, 255, 255)
}
private val loadingArcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 3f * density
strokeCap = Paint.Cap.ROUND
color = Color.rgb(7, 193, 96)
}
private val loadingTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 15f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
layerW = right - left
layerH = bottom - top
if (changed && rawHits.isNotEmpty()) {
rebuildHitMarks()
}
}
fun showRecognizing(bitmap: Bitmap) {
if (stillFrame !== bitmap) {
stillFrame?.recycle()
}
stillFrame = bitmap
rawHits = emptyList()
hitMarks = emptyList()
visualCrop.setEmpty()
stillW = bitmap.width
stillH = bitmap.height
recognizing = true
recognizingStartedAt = SystemClock.elapsedRealtime()
visibility = VISIBLE
invalidate()
}
fun presentStillFrame(bitmap: Bitmap, barcodes: List<Barcode>, visualCropRect: Rect) {
if (stillFrame !== bitmap) {
stillFrame?.recycle()
}
stillFrame = bitmap
rawHits = barcodes
visualCrop = Rect(visualCropRect)
stillW = bitmap.width
stillH = bitmap.height
recognizing = false
rebuildHitMarks()
visibility = VISIBLE
invalidate()
}
private fun rebuildHitMarks() {
if (layerW <= 0 || layerH <= 0 || stillW <= 0 || stillH <= 0) {
hitMarks = emptyList()
return
}
hitMarks = rawHits.mapNotNull { barcode ->
val box = barcode.boundingBox ?: return@mapNotNull null
val adjustedBox = Rect(
box.left - visualCrop.left,
box.top - visualCrop.top,
box.right - visualCrop.left,
box.bottom - visualCrop.top
)
val clippedBox = Rect(adjustedBox)
if (!clippedBox.intersect(0, 0, stillW, stillH)) {
return@mapNotNull null
}
val screenRect = mapBoxToScreen(clippedBox, stillW, stillH, layerW, layerH)
HitMark(
rawValue = barcode.rawValue ?: "",
format = barcode.format,
formatName = SymbologyUtil.nameOf(barcode.format),
screenRect = screenRect
)
}
}
fun clear() {
stillFrame?.recycle()
stillFrame = null
rawHits = emptyList()
hitMarks = emptyList()
visualCrop.setEmpty()
stillW = 0
stillH = 0
recognizing = false
recognizingStartedAt = 0L
visibility = GONE
invalidate()
}
fun pickHitAt(x: Float, y: Float): HitMark? {
return hitMarks.minByOrNull {
val dx = x - it.centerX
val dy = y - it.centerY
dx * dx + dy * dy
}?.takeIf {
val dx = x - it.centerX
val dy = y - it.centerY
dx * dx + dy * dy <= touchR * touchR
}
}
private fun mapBoxToScreen(
boundingBox: Rect,
sourceW: Int, sourceH: Int,
viewW: Int, viewH: Int
): Rect {
val scale = max(viewW.toFloat() / sourceW, viewH.toFloat() / sourceH)
val offsetX = (viewW - sourceW * scale) / 2f
val offsetY = (viewH - sourceH * scale) / 2f
return Rect(
(boundingBox.left * scale + offsetX).toInt(),
(boundingBox.top * scale + offsetY).toInt(),
(boundingBox.right * scale + offsetX).toInt(),
(boundingBox.bottom * scale + offsetY).toInt()
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
stillFrame?.let { bmp ->
if (layerW <= 0 || layerH <= 0) return@let
val bmpW = bmp.width.toFloat()
val bmpH = bmp.height.toFloat()
val scale = max(layerW.toFloat() / bmpW, layerH.toFloat() / bmpH)
val drawW = (bmpW * scale).toInt()
val drawH = (bmpH * scale).toInt()
val left = (layerW - drawW) / 2
val top = (layerH - drawH) / 2
val destRect = Rect(left, top, left + drawW, top + drawH)
canvas.drawBitmap(bmp, null, destRect, null)
if (!recognizing) {
canvas.drawRect(destRect, shadePaint)
}
}
if (recognizing) {
drawRecognizing(canvas)
postInvalidateDelayed(16)
return
}
if (hitMarks.size >= 2) {
val prompt = "已识别到多个码,请选择一个"
val horizontalPadding = 18f * density
val verticalPadding = 10f * density
val textBounds = Rect()
promptTextPaint.getTextBounds(prompt, 0, prompt.length, textBounds)
val promptWidth = textBounds.width() + horizontalPadding * 2
val promptHeight = textBounds.height() + verticalPadding * 2
val promptLeft = (layerW - promptWidth) / 2f
// 放在底部操作按钮上方,避免遮挡条码和顶部关闭按钮。
val promptTop = layerH - promptHeight - 100f * density
canvas.drawRoundRect(
promptLeft,
promptTop,
promptLeft + promptWidth,
promptTop + promptHeight,
promptHeight / 2,
promptHeight / 2,
promptBackgroundPaint
)
val baseline = promptTop + verticalPadding - textBounds.top
canvas.drawText(prompt, layerW / 2f, baseline, promptTextPaint)
}
val breatheProgress = (SystemClock.elapsedRealtime() % breatheDurationMs).toFloat() / breatheDurationMs
val breatheWave = ((sin(breatheProgress * Math.PI * 2.0) + 1.0) / 2.0).toFloat()
for (mark in hitMarks) {
haloPaint.alpha = (34 + breatheWave * 70).toInt()
canvas.drawCircle(mark.centerX, mark.centerY, touchR + breatheWave * 8f * density, haloPaint)
canvas.drawCircle(mark.centerX, mark.centerY, dotOuterR, shellPaint)
canvas.drawCircle(mark.centerX, mark.centerY, dotInnerR, corePaint)
}
if (hitMarks.isNotEmpty()) {
postInvalidateDelayed(16)
}
}
private fun drawRecognizing(canvas: Canvas) {
val cx = layerW / 2f
val cy = layerH / 2f
val radius = 24f * density
val bounds = android.graphics.RectF(cx - radius, cy - radius, cx + radius, cy + radius)
val elapsed = SystemClock.elapsedRealtime() - recognizingStartedAt
val startAngle = (elapsed % 1000L) * 360f / 1000f - 90f
canvas.drawCircle(cx, cy, radius, loadingTrackPaint)
canvas.drawArc(bounds, startAngle, 110f, false, loadingArcPaint)
canvas.drawText("识别中", cx, cy + radius + 30f * density, loadingTextPaint)
}
}
@@ -0,0 +1,328 @@
package uts.sdk.modules.masCodeScan
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Path
import android.graphics.Shader
import android.view.View
import kotlin.math.max
import kotlin.math.min
/**
* 扫描框视觉样式。颜色支持 #RRGGBB、#AARRGGBB 和 rgba()。
*/
data class ScanStyleConfig(
val showMask: Boolean = true,
val showBeam: Boolean = true,
val borderColor: Int = Color.rgb(25, 190, 107),
val maskColor: Int = Color.argb(115, 0, 0, 0),
val beamColor: Int = Color.rgb(18, 232, 128),
val beamDuration: Long = 1800L,
val cornerStyle: String = "corner",
val cornerRadius: Float = 20f
) {
companion object {
private const val DEFAULT_BORDER_COLOR = "#19be6b"
private const val DEFAULT_MASK_COLOR = "rgba(0, 0, 0, 0.45)"
private const val DEFAULT_BEAM_COLOR = "#12e880"
private const val DEFAULT_DURATION = 1800L
fun safe(
showMask: Boolean,
showBeam: Boolean,
borderColor: String,
maskColor: String,
beamColor: String,
beamDuration: Int,
cornerStyle: String,
cornerRadius: Int
): ScanStyleConfig {
return ScanStyleConfig(
showMask = showMask,
showBeam = showBeam,
borderColor = parseColor(borderColor, DEFAULT_BORDER_COLOR),
maskColor = parseColor(maskColor, DEFAULT_MASK_COLOR),
beamColor = parseColor(beamColor, DEFAULT_BEAM_COLOR),
beamDuration = beamDuration.toLong().coerceIn(100L, 60000L),
cornerStyle = when (cornerStyle) {
"full" -> "full"
"rounded" -> "rounded"
"rounded-corner" -> "rounded-corner"
else -> "corner"
},
cornerRadius = cornerRadius.toFloat().coerceIn(0f, 160f)
)
}
private fun parseColor(value: String, fallback: String): Int {
val input = value.trim()
if (input.startsWith("rgba(") && input.endsWith(")")) {
val parts = input.substring(5, input.length - 1).split(',').map { it.trim() }
if (parts.size == 4) {
val red = parts[0].toIntOrNull()
val green = parts[1].toIntOrNull()
val blue = parts[2].toIntOrNull()
val alpha = parts[3].toFloatOrNull()
if (red != null && green != null && blue != null && alpha != null) {
return Color.argb(
(alpha.coerceIn(0f, 1f) * 255f).toInt(),
red.coerceIn(0, 255),
green.coerceIn(0, 255),
blue.coerceIn(0, 255)
)
}
}
}
return try {
Color.parseColor(input)
} catch (_: IllegalArgumentException) {
Color.parseColor(fallback)
}
}
}
}
/**
* 取景框光条装饰层。
*/
class ScanFrameDecorView(
context: Context,
private val scanRegion: ScanRegionConfig,
private val scanStyle: ScanStyleConfig,
private val hint: String
) : View(context) {
private val density = resources.displayMetrics.density
private val beamH = 4f * density
private val beamInset = 12f * density
private val cornerLength = 24f * density
private val finderRect = RectF()
private val roundedFinderPath = Path()
private var beamProgress = 0f
private var beamAnimator: ValueAnimator? = null
private val beamPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val regionMaskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = scanStyle.maskColor
style = Paint.Style.FILL
}
private val regionBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = scanStyle.borderColor
style = Paint.Style.STROKE
strokeWidth = 2f * density
strokeCap = Paint.Cap.SQUARE
}
private val beamGlowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(
(Color.alpha(scanStyle.beamColor) * 0.2f).toInt(),
Color.red(scanStyle.beamColor),
Color.green(scanStyle.beamColor),
Color.blue(scanStyle.beamColor)
)
style = Paint.Style.FILL
maskFilter = BlurMaskFilter(18f * density, BlurMaskFilter.Blur.NORMAL)
}
private val hintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 14f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
}
init {
setLayerType(LAYER_TYPE_SOFTWARE, null)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
runBeamLoop()
}
override fun onDetachedFromWindow() {
haltBeamLoop()
super.onDetachedFromWindow()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
layoutFinder(w, h)
updateFinderPath()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width <= 0 || height <= 0) return
if (scanRegion.enabled) {
if (scanStyle.showMask) {
paintRegionMask(canvas)
}
paintRegionBorder(canvas)
}
if (scanStyle.showBeam) {
paintBeam(canvas)
}
if (hint.isNotBlank()) {
val hintBaseline = min(height - 24f * density, finderRect.bottom + 30f * density)
canvas.drawText(hint, finderRect.centerX(), hintBaseline, hintPaint)
}
}
fun haltBeamLoop() {
beamAnimator?.cancel()
beamAnimator = null
}
private fun runBeamLoop() {
if (beamAnimator != null) return
beamAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = scanStyle.beamDuration
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
addUpdateListener {
beamProgress = it.animatedValue as Float
invalidate()
}
start()
}
}
private fun layoutFinder(w: Int, h: Int) {
if (scanRegion.enabled) {
finderRect.set(scanRegion.resolveViewRect(w, h))
return
}
// 未配置 scanRegion 时保持旧版默认扫描框,避免影响现有调用方。
val horizontalMargin = 36f * density
val maxFrameWidth = min(w * 0.74f, 320f * density)
val frameWidth = min(maxFrameWidth, w - horizontalMargin * 2f)
val frameHeight = frameWidth
val left = (w - frameWidth) / 2f
val top = (h - frameHeight) / 2f - 44f * density
finderRect.set(left, max(24f * density, top), left + frameWidth, max(24f * density, top) + frameHeight)
}
private fun updateFinderPath() {
val maxRadius = min(finderRect.width(), finderRect.height()) / 2f
val radius = min(scanStyle.cornerRadius * density, maxRadius)
roundedFinderPath.reset()
roundedFinderPath.addRoundRect(finderRect, radius, radius, Path.Direction.CW)
roundedFinderPath.close()
}
private fun paintRegionMask(canvas: Canvas) {
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.drawColor(scanStyle.maskColor)
canvas.drawPath(roundedFinderPath, Paint(Paint.ANTI_ALIAS_FLAG).apply {
xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.CLEAR)
})
return
}
canvas.drawRect(0f, 0f, width.toFloat(), finderRect.top, regionMaskPaint)
canvas.drawRect(0f, finderRect.bottom, width.toFloat(), height.toFloat(), regionMaskPaint)
canvas.drawRect(0f, finderRect.top, finderRect.left, finderRect.bottom, regionMaskPaint)
canvas.drawRect(finderRect.right, finderRect.top, width.toFloat(), finderRect.bottom, regionMaskPaint)
}
private fun paintRegionBorder(canvas: Canvas) {
if (scanStyle.cornerStyle == "full") {
canvas.drawRect(finderRect, regionBorderPaint)
return
}
if (scanStyle.cornerStyle == "rounded") {
canvas.drawPath(roundedFinderPath, regionBorderPaint)
return
}
if (scanStyle.cornerStyle == "rounded-corner") {
paintRoundedCorners(canvas)
return
}
val left = finderRect.left
val top = finderRect.top
val right = finderRect.right
val bottom = finderRect.bottom
canvas.drawLine(left, top, left + cornerLength, top, regionBorderPaint)
canvas.drawLine(left, top, left, top + cornerLength, regionBorderPaint)
canvas.drawLine(right - cornerLength, top, right, top, regionBorderPaint)
canvas.drawLine(right, top, right, top + cornerLength, regionBorderPaint)
canvas.drawLine(left, bottom - cornerLength, left, bottom, regionBorderPaint)
canvas.drawLine(left, bottom, left + cornerLength, bottom, regionBorderPaint)
canvas.drawLine(right - cornerLength, bottom, right, bottom, regionBorderPaint)
canvas.drawLine(right, bottom - cornerLength, right, bottom, regionBorderPaint)
}
private fun paintRoundedCorners(canvas: Canvas) {
val radius = min(scanStyle.cornerRadius * density, min(finderRect.width(), finderRect.height()) / 2f)
val left = finderRect.left
val top = finderRect.top
val right = finderRect.right
val bottom = finderRect.bottom
val segmentLength = min(this.cornerLength, radius + this.cornerLength)
val cornerOnlyPath = Path()
val topLeft = RectF(left, top, left + radius * 2f, top + radius * 2f)
val topRight = RectF(right - radius * 2f, top, right, top + radius * 2f)
val bottomLeft = RectF(left, bottom - radius * 2f, left + radius * 2f, bottom)
val bottomRight = RectF(right - radius * 2f, bottom - radius * 2f, right, bottom)
cornerOnlyPath.moveTo(left + segmentLength, top)
cornerOnlyPath.lineTo(left + radius, top)
cornerOnlyPath.arcTo(topLeft, 270f, -90f)
cornerOnlyPath.lineTo(left, top + segmentLength)
cornerOnlyPath.moveTo(right - segmentLength, top)
cornerOnlyPath.lineTo(right - radius, top)
cornerOnlyPath.arcTo(topRight, 270f, 90f)
cornerOnlyPath.lineTo(right, top + segmentLength)
cornerOnlyPath.moveTo(left, bottom - segmentLength)
cornerOnlyPath.lineTo(left, bottom - radius)
cornerOnlyPath.arcTo(bottomLeft, 180f, -90f)
cornerOnlyPath.lineTo(left + segmentLength, bottom)
cornerOnlyPath.moveTo(right, bottom - segmentLength)
cornerOnlyPath.lineTo(right, bottom - radius)
cornerOnlyPath.arcTo(bottomRight, 0f, 90f)
cornerOnlyPath.lineTo(right - segmentLength, bottom)
canvas.drawPath(cornerOnlyPath, regionBorderPaint)
}
private fun paintBeam(canvas: Canvas) {
val availableHeight = finderRect.height() - beamInset * 2f
if (availableHeight <= 0f) return
val y = finderRect.top + beamInset + availableHeight * beamProgress
val lineTop = y - beamH / 2f
val lineBottom = y + beamH / 2f
val lineLeft = finderRect.left + beamInset
val lineRight = finderRect.right - beamInset
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.save()
canvas.clipPath(roundedFinderPath)
}
beamPaint.shader = LinearGradient(
lineLeft, y, lineRight, y,
intArrayOf(
scanStyle.beamColor and 0x00ffffff,
scanStyle.beamColor,
scanStyle.beamColor and 0x00ffffff
),
floatArrayOf(0f, 0.5f, 1f),
Shader.TileMode.CLAMP
)
canvas.drawRect(
lineLeft, lineTop - 10f * density, lineRight, lineBottom + 10f * density, beamGlowPaint
)
canvas.drawRect(lineLeft, lineTop, lineRight, lineBottom, beamPaint)
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.restore()
}
}
}
@@ -0,0 +1,108 @@
package uts.sdk.modules.masCodeScan
import android.graphics.Rect
import android.graphics.RectF
import kotlin.math.max
import kotlin.math.min
/**
* 相对于实时预览层的有效扫码区域配置。
* 由 Activity 统一校验后同时用于扫描框绘制和条码结果过滤。
*/
data class ScanRegionConfig(
val enabled: Boolean,
val widthRatio: Float = DEFAULT_SIZE_RATIO,
val heightRatio: Float = DEFAULT_SIZE_RATIO,
val centerXRatio: Float = DEFAULT_CENTER_RATIO,
val centerYRatio: Float = DEFAULT_CENTER_RATIO
) {
companion object {
const val DEFAULT_SIZE_RATIO = 0.74f
const val DEFAULT_CENTER_RATIO = 0.5f
private const val MIN_SIZE_RATIO = 0.1f
private const val MAX_SIZE_RATIO = 1f
fun safe(
enabled: Boolean,
widthRatio: Float,
heightRatio: Float,
centerXRatio: Float,
centerYRatio: Float
): ScanRegionConfig {
val safeWidth = widthRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
val safeHeight = heightRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
return ScanRegionConfig(
enabled = enabled,
widthRatio = safeWidth,
heightRatio = safeHeight,
centerXRatio = centerXRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f),
centerYRatio = centerYRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f)
)
}
private fun Float.sanitize(defaultValue: Float): Float {
return if (isFinite()) this else defaultValue
}
}
/** 返回扫描框在预览 View 坐标系中的实际位置,越界部分会收敛到 View 内。 */
fun resolveViewRect(viewWidth: Int, viewHeight: Int): RectF {
if (viewWidth <= 0 || viewHeight <= 0) return RectF()
// width / height 都以预览宽度为基准,并使用同一缩放因子收敛,确保始终保持配置比例。
val desiredWidth = viewWidth * widthRatio
val desiredHeight = viewWidth * heightRatio
val sizeScale = min(1f, min(viewWidth / desiredWidth, viewHeight / desiredHeight))
val frameWidth = desiredWidth * sizeScale
val frameHeight = desiredHeight * sizeScale
val wantedLeft = viewWidth * centerXRatio - frameWidth / 2f
val wantedTop = viewHeight * centerYRatio - frameHeight / 2f
val left = wantedLeft.coerceIn(0f, max(0f, viewWidth - frameWidth))
val top = wantedTop.coerceIn(0f, max(0f, viewHeight - frameHeight))
return RectF(left, top, left + frameWidth, top + frameHeight)
}
}
/**
* 将 PreviewView 的扫描框映射到 ML Kit 返回 boundingBox 所在的旋转后图像坐标。
* PreviewView 使用 FILL_CENTER,因此必须补偿等比填充产生的裁剪偏移。
*/
object ScanRegionMapper {
fun mapViewRectToImage(
viewRect: RectF,
viewWidth: Int,
viewHeight: Int,
imageWidth: Int,
imageHeight: Int,
cropRect: Rect,
rotationDegrees: Int
): Rect? {
if (viewRect.isEmpty || viewWidth <= 0 || viewHeight <= 0 || imageWidth <= 0 || imageHeight <= 0) {
return null
}
val visualCrop = YuvFrames.rectToVisualSpace(cropRect, imageWidth, imageHeight, rotationDegrees)
val cropWidth = visualCrop.width()
val cropHeight = visualCrop.height()
if (cropWidth <= 0 || cropHeight <= 0) return null
val scale = max(viewWidth.toFloat() / cropWidth, viewHeight.toFloat() / cropHeight)
if (!scale.isFinite() || scale <= 0f) return null
val offsetX = (viewWidth - cropWidth * scale) / 2f
val offsetY = (viewHeight - cropHeight * scale) / 2f
val mapped = Rect(
((viewRect.left - offsetX) / scale).toInt() + visualCrop.left,
((viewRect.top - offsetY) / scale).toInt() + visualCrop.top,
((viewRect.right - offsetX) / scale).toInt() + visualCrop.left,
((viewRect.bottom - offsetY) / scale).toInt() + visualCrop.top
)
return mapped.apply {
left = left.coerceIn(visualCrop.left, visualCrop.right)
top = top.coerceIn(visualCrop.top, visualCrop.bottom)
right = right.coerceIn(left, visualCrop.right)
bottom = bottom.coerceIn(top, visualCrop.bottom)
}.takeIf { it.width() > 0 && it.height() > 0 }
}
fun containsBarcodeCenter(region: Rect, barcodeBox: Rect): Boolean {
return region.contains(barcodeBox.centerX(), barcodeBox.centerY())
}
}
@@ -0,0 +1,67 @@
package uts.sdk.modules.masCodeScan
import com.google.mlkit.vision.barcode.common.Barcode
object SymbologyUtil {
fun nameOf(format: Int): String = when (format) {
Barcode.FORMAT_QR_CODE -> "QR_CODE"
Barcode.FORMAT_EAN_13 -> "EAN_13"
Barcode.FORMAT_EAN_8 -> "EAN_8"
Barcode.FORMAT_CODE_128 -> "CODE_128"
Barcode.FORMAT_CODE_39 -> "CODE_39"
Barcode.FORMAT_CODE_93 -> "CODE_93"
Barcode.FORMAT_CODABAR -> "CODABAR"
Barcode.FORMAT_DATA_MATRIX -> "DATA_MATRIX"
Barcode.FORMAT_UPC_A -> "UPC_A"
Barcode.FORMAT_UPC_E -> "UPC_E"
Barcode.FORMAT_ITF -> "ITF"
Barcode.FORMAT_PDF417 -> "PDF417"
Barcode.FORMAT_AZTEC -> "AZTEC"
else -> "UNKNOWN"
}
fun hasSupportedFormat(csv: String?): Boolean {
if (csv.isNullOrBlank()) return true
return csv.split(',').any { tokenToFormat(it.trim().uppercase()) != null }
}
fun parseFormatList(csv: String?): IntArray {
if (csv.isNullOrBlank()) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
val mapped = csv.split(',')
.map { it.trim().uppercase() }
.mapNotNull { tokenToFormat(it) }
.distinct()
if (mapped.isEmpty()) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
// ALL 与具体码制混用时以全格式为准,避免 ML Kit 参数异常
if (mapped.contains(Barcode.FORMAT_ALL_FORMATS)) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
return mapped.toIntArray()
}
private fun tokenToFormat(token: String): Int? = when (token) {
"QR_CODE", "QR" -> Barcode.FORMAT_QR_CODE
"EAN_13" -> Barcode.FORMAT_EAN_13
"EAN_8" -> Barcode.FORMAT_EAN_8
"CODE_128" -> Barcode.FORMAT_CODE_128
"CODE_39" -> Barcode.FORMAT_CODE_39
"CODE_93" -> Barcode.FORMAT_CODE_93
"CODABAR" -> Barcode.FORMAT_CODABAR
"DATA_MATRIX" -> Barcode.FORMAT_DATA_MATRIX
"UPC_A" -> Barcode.FORMAT_UPC_A
"UPC_E" -> Barcode.FORMAT_UPC_E
"ITF" -> Barcode.FORMAT_ITF
"PDF417" -> Barcode.FORMAT_PDF417
"AZTEC" -> Barcode.FORMAT_AZTEC
"ALL", "FORMAT_ALL_FORMATS" -> Barcode.FORMAT_ALL_FORMATS
else -> null
}
fun isDecodedText(barcode: Barcode): Boolean {
return !barcode.rawValue.isNullOrBlank()
}
}
@@ -0,0 +1,136 @@
package uts.sdk.modules.masCodeScan
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageFormat
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.YuvImage
import android.media.Image
import java.io.ByteArrayOutputStream
object YuvFrames {
fun imageToBitmap(
image: Image,
width: Int,
height: Int,
cropRect: Rect,
rotationDegrees: Int
): Bitmap? {
return try {
val planes = image.planes
val yPlane = planes[0]
val uPlane = planes[1]
val vPlane = planes[2]
val yRowStride = yPlane.rowStride
val yPixelStride = yPlane.pixelStride
val uRowStride = uPlane.rowStride
val uPixelStride = uPlane.pixelStride
val vRowStride = vPlane.rowStride
val vPixelStride = vPlane.pixelStride
val nv21Size = width * height * 3 / 2
val nv21 = ByteArray(nv21Size)
var destOffset = 0
if (yPixelStride == 1) {
for (row in 0 until height) {
yPlane.buffer.position(row * yRowStride)
yPlane.buffer.get(nv21, destOffset, width)
destOffset += width
}
} else {
val yBuffer = yPlane.buffer
for (row in 0 until height) {
for (col in 0 until width) {
nv21[destOffset++] = yBuffer.get(row * yRowStride + col * yPixelStride)
}
}
}
var uvOffset = width * height
if (uPixelStride == 1 && vPixelStride == 1) {
for (row in 0 until height / 2) {
vPlane.buffer.position(row * vRowStride)
vPlane.buffer.get(nv21, uvOffset + row * width, width / 2)
}
for (row in 0 until height / 2) {
uPlane.buffer.position(row * uRowStride)
uPlane.buffer.get(nv21, uvOffset + width / 2 + row * width, width / 2)
}
} else {
val uBuffer = uPlane.buffer
val vBuffer = vPlane.buffer
for (row in 0 until height / 2) {
for (col in 0 until width / 2) {
val uvIndex = row * uRowStride + col * uPixelStride
nv21[uvOffset++] = vBuffer.get(uvIndex)
nv21[uvOffset++] = uBuffer.get(uvIndex)
}
}
}
val safeCropRect = Rect(cropRect).apply {
left = left.coerceIn(0, width)
top = top.coerceIn(0, height)
right = right.coerceIn(left, width)
bottom = bottom.coerceIn(top, height)
}
val jpegRect = if (safeCropRect.width() > 0 && safeCropRect.height() > 0) {
safeCropRect
} else {
Rect(0, 0, width, height)
}
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
val baos = ByteArrayOutputStream()
yuvImage.compressToJpeg(jpegRect, 85, baos)
val jpegData = baos.toByteArray()
baos.close()
var bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size)
if (bitmap != null && rotationDegrees != 0) {
val matrix = Matrix()
matrix.postRotate(rotationDegrees.toFloat())
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotated != bitmap) {
bitmap.recycle()
}
bitmap = rotated
}
bitmap
} catch (_: Exception) {
null
}
}
fun rectToVisualSpace(
rect: Rect,
sourceWidth: Int,
sourceHeight: Int,
rotationDegrees: Int
): Rect {
return when (rotationDegrees) {
90 -> Rect(
sourceHeight - rect.bottom,
rect.left,
sourceHeight - rect.top,
rect.right
)
180 -> Rect(
sourceWidth - rect.right,
sourceHeight - rect.bottom,
sourceWidth - rect.left,
sourceHeight - rect.top
)
270 -> Rect(
rect.top,
sourceWidth - rect.right,
rect.bottom,
sourceWidth - rect.left
)
else -> Rect(rect)
}
}
}
@@ -0,0 +1,12 @@
{
"dependencies": [
"androidx.activity:activity:1.8.0",
"androidx.camera:camera-core:1.4.1",
"androidx.camera:camera-camera2:1.4.1",
"androidx.camera:camera-lifecycle:1.4.1",
"androidx.camera:camera-view:1.4.1",
"androidx.appcompat:appcompat:1.7.0",
"com.google.mlkit:barcode-scanning:17.3.0"
],
"minSdkVersion": 21
}
@@ -0,0 +1,356 @@
import Activity from 'android.app.Activity'
import Intent from 'android.content.Intent'
import { OpenCodeScan, CodeScanErrorCode, CodeScanOptions, CodeScanSuccess } from '../interface.uts'
import { CodeScanFailImpl } from '../unierror.uts'
const LAUNCH_REQ: Int = 53011
const EXTRA_PAYLOAD = 'mas_content'
const EXTRA_FORMAT = 'mas_format'
const EXTRA_TITLE = 'mas_title'
const EXTRA_HINT = 'mas_hint'
const EXTRA_SUCCESS_HINT = 'mas_success_hint'
const EXTRA_EMPTY_HINT = 'mas_empty_hint'
const EXTRA_SMART_ZOOM = 'mas_smart_zoom'
const EXTRA_TORCH_MODE = 'mas_torch_mode'
const EXTRA_RESULT_MODE = 'mas_result_mode'
const EXTRA_SHOW_PAUSE_BUTTON = 'mas_show_pause_button'
const EXTRA_VIBRATE = 'mas_vibrate'
const EXTRA_CONTINUOUS = 'mas_continuous'
const EXTRA_FROM_ALBUM = 'mas_from_album'
const EXTRA_FORMATS = 'mas_formats'
const EXTRA_SCAN_REGION_ENABLED = 'mas_scan_region_enabled'
const EXTRA_SCAN_REGION = 'mas_scan_region'
const EXTRA_SCAN_STYLE_SHOW_MASK = 'mas_scan_style_show_mask'
const EXTRA_SCAN_STYLE_SHOW_BEAM = 'mas_scan_style_show_beam'
const EXTRA_SCAN_STYLE_BORDER_COLOR = 'mas_scan_style_border_color'
const EXTRA_SCAN_STYLE_MASK_COLOR = 'mas_scan_style_mask_color'
const EXTRA_SCAN_STYLE_BEAM_COLOR = 'mas_scan_style_beam_color'
const EXTRA_SCAN_STYLE_BEAM_DURATION = 'mas_scan_style_beam_duration'
const EXTRA_SCAN_STYLE_CORNER = 'mas_scan_style_corner'
const EXTRA_SCAN_STYLE_CORNER_RADIUS = 'mas_scan_style_corner_radius'
const EXTRA_SCAN_TIMEOUT = 'mas_scan_timeout'
const EXTRA_CONTINUOUS_SUCCESS_DURATION = 'mas_continuous_success_duration'
const EXTRA_ERR = 'mas_err'
const EXTRA_FROM_ALBUM_RESULT = 'mas_result_from_album'
const EXTRA_HAD_HITS = 'mas_had_hits'
const CAPTURE_ACTIVITY = 'uts.sdk.modules.masCodeScan.CodeCaptureActivity'
let activityResultHook: ((requestCode: Int, resultCode: Int, data?: Intent) => void) | null = null
let activeOptions: CodeScanOptions | null = null
let continuousHitCount: Int = 0
let pollTimerId: number = -1
let sessionContinuous: boolean = false
let continuousSuccess: ((res: CodeScanSuccess) => void) | null = null
@UTSJS.keepAlive
export function openCodeScan(options?: CodeScanOptions | null): void {
const opts = options != null ? options : null
const host = UTSAndroid.getUniActivity()
if (host == null) {
emitFailure(opts, 9021001)
return
}
launchCapture(host, opts)
}
function clearPollTimer() {
if (pollTimerId >= 0) {
clearInterval(pollTimerId)
pollTimerId = -1
}
}
function drainHits() {
const current = activeOptions
const success = continuousSuccess
if (current == null || success == null) return
while (true) {
const hit = CodeScanHitBus.pollHit()
if (hit == null) break
const content = hit[0]
const format = hit[1]
const fromAlbumFlag = hit[2]
if (content == null || format == null || fromAlbumFlag == null) continue
continuousHitCount++
const ok: CodeScanSuccess = {
content: content,
format: format,
fromAlbum: fromAlbumFlag == '1'
}
success(ok)
}
}
function launchCapture(host: Activity, opts: CodeScanOptions | null): void {
if (activityResultHook != null) {
UTSAndroid.offAppActivityResult(activityResultHook)
activityResultHook = null
}
clearPollTimer()
CodeScanHitBus.detach()
activeOptions = opts
continuousHitCount = 0
sessionContinuous = opts != null && opts.continuous == true
continuousSuccess = sessionContinuous && opts != null ? opts.success : null
if (sessionContinuous) {
pollTimerId = setInterval(() => {
drainHits()
}, 120)
}
const onResult = (requestCode: Int, resultCode: Int, data?: Intent) => {
if (requestCode != LAUNCH_REQ) {
return
}
UTSAndroid.offAppActivityResult()
activityResultHook = null
drainHits()
clearPollTimer()
CodeScanHitBus.detach()
const current = activeOptions
const wasContinuous = sessionContinuous
sessionContinuous = false
if (resultCode != Activity.RESULT_OK) {
let errCode: CodeScanErrorCode = 9021003
if (data != null) {
const nativeErr = data.getIntExtra(EXTRA_ERR, 0)
if (nativeErr >= 9021002 && nativeErr <= 9021011) {
errCode = nativeErr as CodeScanErrorCode
}
}
// 连续扫已有命中:关闭只 complete,不 fail
if (wasContinuous && continuousHitCount > 0) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous && data != null && data.getBooleanExtra(EXTRA_HAD_HITS, false)) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous && current != null) {
emitFailure(current, errCode)
const complete = current.complete
if (complete != null) {
complete({ closed: true, hitCount: continuousHitCount, failed: true, errCode: errCode })
}
activeOptions = null
continuousSuccess = null
return
}
emitFailure(current, errCode)
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
let payload = ''
let formatName = ''
let fromAlbum = false
if (data != null) {
const rawPayload = data.getStringExtra(EXTRA_PAYLOAD)
const rawFormat = data.getStringExtra(EXTRA_FORMAT)
payload = rawPayload != null ? rawPayload : ''
formatName = rawFormat != null ? rawFormat : ''
fromAlbum = data.getBooleanExtra(EXTRA_FROM_ALBUM_RESULT, false)
}
if (payload == '') {
emitFailure(current, 9021004)
activeOptions = null
continuousSuccess = null
return
}
const ok: CodeScanSuccess = {
content: payload,
format: formatName,
fromAlbum: fromAlbum
}
if (current != null) {
current.success?.(ok)
current.complete?.(ok)
}
activeOptions = null
continuousSuccess = null
}
activityResultHook = onResult
UTSAndroid.onAppActivityResult(onResult)
let pageTitle = '扫一扫'
let scanHint = '请将条码放入扫描框内'
let successHint = '扫码成功'
let emptyHint = '未识别到有效条码'
let useSmartZoom = true
let useTorchMode = 'off'
let useResultMode = 'immediate'
let useShowPauseButton = false
let useVibrate = true
let useContinuous = false
let useFromAlbum = false
let useScanRegion = false
let regionWidth = 0.74
let regionHeight = 0.74
let regionCenterX = 0.5
let regionCenterY = 0.5
let formatsCsv = ''
let styleShowMask = true
let styleShowBeam = true
let styleBorderColor = '#19be6b'
let styleMaskColor = 'rgba(0, 0, 0, 0.45)'
let styleBeamColor = '#12e880'
let styleBeamDuration = 1800
let styleCorner = 'corner'
let styleCornerRadius = 20
let scanTimeout = 0
let continuousSuccessDuration = 1000
if (opts != null) {
if (opts.title != null && opts.title!.trim().length > 0) {
pageTitle = opts.title!.trim()
}
if (opts.hint != null && opts.hint!.trim().length > 0) {
scanHint = opts.hint!.trim()
}
if (opts.successHint != null && opts.successHint!.trim().length > 0) {
successHint = opts.successHint!.trim()
}
if (opts.emptyHint != null && opts.emptyHint!.trim().length > 0) {
emptyHint = opts.emptyHint!.trim()
}
if (opts.smartZoom != null) {
useSmartZoom = opts.smartZoom!
}
if (opts.torch == 'on' || opts.torch == 'auto' || opts.torch == 'off') {
useTorchMode = opts.torch!
}
if (opts.resultMode == 'confirm' || opts.resultMode == 'pause' || opts.resultMode == 'immediate') {
useResultMode = opts.resultMode!
}
if (opts.showPauseButton != null) {
useShowPauseButton = opts.showPauseButton!
}
if (opts.vibrate != null) {
useVibrate = opts.vibrate!
}
if (opts.continuous != null) {
useContinuous = opts.continuous!
}
if (opts.fromAlbum != null) {
useFromAlbum = opts.fromAlbum!
}
if (opts.scanRegion != null) {
useScanRegion = opts.scanRegion!.fullScreen != true
if (opts.scanRegion!.width != null) {
regionWidth = opts.scanRegion!.width!
}
if (opts.scanRegion!.height != null) {
regionHeight = opts.scanRegion!.height!
}
if (opts.scanRegion!.centerX != null) {
regionCenterX = opts.scanRegion!.centerX!
}
if (opts.scanRegion!.centerY != null) {
regionCenterY = opts.scanRegion!.centerY!
}
}
if (opts.scanTimeout != null) {
scanTimeout = Math.floor(opts.scanTimeout!)
}
if (opts.continuousSuccessDuration != null) {
continuousSuccessDuration = Math.max(0, Math.min(600000, Math.floor(opts.continuousSuccessDuration!)))
}
if (opts.scanStyle != null) {
if (opts.scanStyle!.showMask != null) {
styleShowMask = opts.scanStyle!.showMask!
}
if (opts.scanStyle!.showBeam != null) {
styleShowBeam = opts.scanStyle!.showBeam!
}
if (opts.scanStyle!.borderColor != null) {
styleBorderColor = opts.scanStyle!.borderColor!
}
if (opts.scanStyle!.maskColor != null) {
styleMaskColor = opts.scanStyle!.maskColor!
}
if (opts.scanStyle!.beamColor != null) {
styleBeamColor = opts.scanStyle!.beamColor!
}
if (opts.scanStyle!.beamDuration != null) {
styleBeamDuration = opts.scanStyle!.beamDuration!
}
if (opts.scanStyle!.cornerStyle == 'full' || opts.scanStyle!.cornerStyle == 'corner' || opts.scanStyle!.cornerStyle == 'rounded' || opts.scanStyle!.cornerStyle == 'rounded-corner') {
styleCorner = opts.scanStyle!.cornerStyle!
}
if (opts.scanStyle!.cornerRadius != null) {
styleCornerRadius = Math.max(0, Math.min(160, Math.floor(opts.scanStyle!.cornerRadius!)))
}
}
if (opts.formats != null && opts.formats!.length > 0) {
const parts: string[] = []
for (let i = 0; i < opts.formats!.length; i++) {
const item = opts.formats![i]
if (item != null && item.length > 0) {
parts.push(item)
}
}
formatsCsv = parts.join(',')
}
}
const intent = new Intent()
intent.setClassName(host, CAPTURE_ACTIVITY)
intent.putExtra(EXTRA_TITLE, pageTitle)
intent.putExtra(EXTRA_HINT, scanHint)
intent.putExtra(EXTRA_SUCCESS_HINT, successHint)
intent.putExtra(EXTRA_EMPTY_HINT, emptyHint)
intent.putExtra(EXTRA_SMART_ZOOM, useSmartZoom)
intent.putExtra(EXTRA_TORCH_MODE, useTorchMode)
intent.putExtra(EXTRA_RESULT_MODE, useResultMode)
intent.putExtra(EXTRA_SHOW_PAUSE_BUTTON, useShowPauseButton)
intent.putExtra(EXTRA_VIBRATE, useVibrate)
intent.putExtra(EXTRA_CONTINUOUS, useContinuous)
intent.putExtra(EXTRA_FROM_ALBUM, useFromAlbum)
intent.putExtra(EXTRA_FORMATS, formatsCsv)
intent.putExtra(EXTRA_SCAN_REGION_ENABLED, useScanRegion)
intent.putExtra(EXTRA_SCAN_REGION, `${regionWidth},${regionHeight},${regionCenterX},${regionCenterY}`)
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_MASK, styleShowMask)
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_BEAM, styleShowBeam)
intent.putExtra(EXTRA_SCAN_STYLE_BORDER_COLOR, styleBorderColor)
intent.putExtra(EXTRA_SCAN_STYLE_MASK_COLOR, styleMaskColor)
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_COLOR, styleBeamColor)
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_DURATION, styleBeamDuration)
intent.putExtra(EXTRA_SCAN_STYLE_CORNER, styleCorner)
intent.putExtra(EXTRA_SCAN_STYLE_CORNER_RADIUS, styleCornerRadius)
intent.putExtra(EXTRA_SCAN_TIMEOUT, scanTimeout)
intent.putExtra(EXTRA_CONTINUOUS_SUCCESS_DURATION, continuousSuccessDuration)
host.startActivityForResult(intent, LAUNCH_REQ)
}
function emitFailure(opts: CodeScanOptions | null, code: CodeScanErrorCode): void {
const err = new CodeScanFailImpl(code)
if (opts != null) {
opts.fail?.(err)
opts.complete?.(err)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,90 @@
export type CodeScanSuccess = {
content : string
format : string
fromAlbum ?: boolean
}
export type CodeScanErrorCode = 9021001 | 9021002 | 9021003 | 9021004 | 9021005 | 9021006 | 9021007 | 9021008 | 9021009 | 9021010 | 9021011
export type CodeScanTorchMode = 'off' | 'on' | 'auto'
export type CodeScanResultMode = 'immediate' | 'confirm' | 'pause'
export type CodeScanCornerStyle = 'corner' | 'full' | 'rounded' | 'rounded-corner'
export type CodeScanStyle = {
/** 是否显示扫描框外遮罩,默认 true */
showMask ?: boolean
/** 是否显示扫描线,默认 true */
showBeam ?: boolean
/** 扫描框边框颜色,支持 #RRGGBB / #AARRGGBB */
borderColor ?: string
/** 遮罩颜色,支持 #RRGGBB / #AARRGGBB 或 rgba() */
maskColor ?: string
/** 扫描线颜色,支持 #RRGGBB / #AARRGGBB */
beamColor ?: string
/** 扫描线移动周期,单位毫秒,默认 1800 */
beamDuration ?: number
/** 扫描框样式:corner 四角边框,full 完整边框,rounded 完整圆角边框,rounded-corner 四角圆角边框,默认 corner */
cornerStyle ?: CodeScanCornerStyle
/** 圆角半径,单位 dp,rounded 和 rounded-corner 样式生效,默认 20,范围 0 ~ 160 */
cornerRadius ?: number
}
export interface CodeScanFail extends IUniError {
errCode : CodeScanErrorCode
}
/**
* 实时相机扫描区域。宽度和高度均以预览宽度为参考比例,因此可稳定锁定宽高比。
* 不传时保持原有整画面识别;相册识图不受该配置限制。
*/
export type ScanRegion = {
/** 全屏识别:不绘制扫描框、不进行区域过滤 */
fullScreen ?: boolean
/** 扫描框宽度比例,建议 0.1 ~ 1 */
width ?: number
/** 扫描框高度比例,建议 0.1 ~ 1 */
height ?: number
/** 扫描框中心横坐标比例,0 为最左、1 为最右 */
centerX ?: number
/** 扫描框中心纵坐标比例,0 为顶部、1 为底部 */
centerY ?: number
}
export type CodeScanOptions = {
/** 扫码页标题,默认“扫一扫” */
title ?: string
/** 扫描框下方引导语,默认“请将条码放入扫描框内” */
hint ?: string
/** 识别成功提示语,默认“扫码成功” */
successHint ?: string
/** 未识别到条码时的提示语,默认“未识别到有效条码” */
emptyHint ?: string
/** 条码偏小时自动放大,默认 true */
smartZoom ?: boolean
/** 手电筒策略:off 关闭、on 默认开启、auto 根据环境亮度自动补光,默认 off */
torch ?: CodeScanTorchMode
/** 单次扫码结果策略:immediate 立即返回、confirm 确认后返回、pause 暂停后可继续扫描,默认 immediate */
resultMode ?: CodeScanResultMode
/** 连续扫码页是否显示暂停/继续按钮,默认 false */
showPauseButton ?: boolean
/** 限定码制,如 ['QR_CODE','CODE_128'];空/缺省为全格式 */
formats ?: string[]
/** 识别成功是否震动,默认 true */
vibrate ?: boolean
/** 连续扫码:页内不关闭,多次 success;点关闭结束;可通过 showPauseButton 显示暂停按钮 */
continuous ?: boolean
/** 连续扫码成功提示停留时长,单位毫秒,默认 1000,范围 0 ~ 600000 */
continuousSuccessDuration ?: number
/** 打开后直接进入相册选图 */
fromAlbum ?: boolean
/** 实时相机有效识别区域;fullScreen 为 true 时整画面均可识别 */
scanRegion ?: ScanRegion
/** 扫描框视觉样式 */
scanStyle ?: CodeScanStyle
/** 扫描会话超时毫秒数;不传或小于 1000 时不启用,最大 600000 */
scanTimeout ?: number
success ?: (res : CodeScanSuccess) => void
fail ?: (res : CodeScanFail) => void
complete ?: (res : any) => void
}
export type OpenCodeScan = (options ?: CodeScanOptions | null) => void
@@ -0,0 +1,28 @@
import { CodeScanErrorCode, CodeScanFail } from './interface.uts'
export const CodeScanErrorSubject = 'mas-code-scan'
export const CodeScanErrorText: Map<CodeScanErrorCode, string> = new Map([
[9021001, '当前无法获取页面 Activity'],
[9021002, '相机权限被拒绝'],
[9021003, '用户取消扫码'],
[9021004, '未获取到有效的扫码结果'],
[9021005, '相机被其他应用占用或当前不可用'],
[9021006, '设备不支持手电筒'],
[9021007, '相机初始化失败'],
[9021008, '相册图片无法读取'],
[9021009, '图片中未识别到条码'],
[9021010, '指定码制不支持'],
[9021011, '扫码超时']
])
export class CodeScanFailImpl extends UniError implements CodeScanFail {
override errCode: CodeScanErrorCode
constructor(errCode: CodeScanErrorCode) {
super()
this.errSubject = CodeScanErrorSubject
this.errCode = errCode
this.errMsg = CodeScanErrorText.get(errCode) ?? ''
}
}