Skip to content
Closed

Master #1379

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Build Android APK

on:
push:
branches: [ "main", "master" ]
pull_request:
branches: [ "main", "master" ]
workflow_dispatch:
release:
types: [ created ] # 这里之前错写成了“类型”,已修正

env:
JAVA_VERSION: '17'
JAVA_DISTRIBUTION: 'temurin'

jobs:
build:
name: Build APK
runs-on: ubuntu-latest

steps:
# 1. 检出代码
- name: Checkout code
uses: actions/checkout@v4

# 2. 设置 JDK 17
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}

# 3. 设置 Gradle 并启用缓存
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
# 仅在主分支上写入缓存,其他分支只读,避免缓存服务报错导致构建失败
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }}

# 4. 赋予 gradlew 执行权限
- name: Grant execute permission for gradlew
run: chmod +x gradlew

# 5. 执行构建
- name: Build Debug APK
run: ./gradlew assembleDebug --stacktrace

- name: Build Release APK
run: ./gradlew assembleRelease --stacktrace

# 6. 上传 APK
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug
path: app/build/outputs/apk/debug/*.apk
retention-days: 30

- name: Upload Release APK
uses: actions/upload-artifact@v4
with:
name: app-release # 之前这里也被我写错乱码了,已修正
path: app/build/outputs/apk/release/*.apk
retention-days: 30
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />


<!-- &lt;!&ndash; 6.0–10.0 普通存储 &ndash;&gt;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import kotlinx.coroutines.withContext
*/
class IntroPagePermissionFragment : BaseIntroPageFragment<FragmentIntroPagePermissionBinding>() {

companion object {
private const val REQ_LOCATION_PERMISSION = 1002
}

// ──────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ──────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -101,6 +105,26 @@ class IntroPagePermissionFragment : BaseIntroPageFragment<FragmentIntroPagePermi
)
)

// 位置信息权限(可选):用于备注中的位置信息占位符
add(
PermItem(
iconRes = R.drawable.icon_map,
titleRes = R.string.perm_location_title,
descRes = R.string.perm_location_desc,
checkGranted = {
ContextCompat.checkSelfPermission(
ctx,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
ctx,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
},
onClick = { requestLocationPermission() },
isRequired = false
)
)

// OCR 权限(可选):仅 OCR 模式需要无障碍
if (WorkMode.isOcr()) {
add(
Expand Down Expand Up @@ -216,6 +240,36 @@ class IntroPagePermissionFragment : BaseIntroPageFragment<FragmentIntroPagePermi
refreshCardStates()
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_LOCATION_PERMISSION) {
refreshCardStates()
}
}

private fun requestLocationPermission() {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
refreshCardStates()
return
}

requestPermissions(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
REQ_LOCATION_PERMISSION
)
}

/**
* 刷新所有权限卡片的状态图标
* 权限检查可能阻塞(Shell 探测等),放到 IO 协程执行
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class RemarkFormatFragment : BaseFragment<FragmentRemarkFormatBinding>() {
"【原始资产】",
"【目标资产】",
"【渠道】",
"【位置信息】",
// 扩展信息
"【规则名称】",
"【AI】",
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/res/menu/bill_menu.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
~ limitations under the License.
-->

<menu xmlns:android="http://schemas.android.com/apk/res/android">
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:icon="@drawable/menu_icon_search"
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,9 @@
<string name="perm_overlay_title">悬浮窗权限</string>
<string name="perm_overlay_desc">允许显示悬浮窗以快速提取工具。</string>

<string name="perm_location_title">位置信息权限</string>
<string name="perm_location_desc">允许应用读取当前位置信息,以便在备注格式中填入位置信息。</string>

<string name="perm_ocr_perm_root">OCR识别(Root)</string>
<string name="perm_ocr_perm_shizuku">OCR识别(Shizuku)</string>
<string name="perm_ocr_perm_accessibility">OCR识别(无障碍)</string>
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,9 @@
<string name="perm_overlay_title">Overlay Permission</string>
<string name="perm_overlay_desc">Allows displaying floating windows for quick extraction tools.</string>

<string name="perm_location_title">Location Permission</string>
<string name="perm_location_desc">Allows the app to read your current location so the bill remark template can include location details.</string>

<string name="perm_ocr_perm_root">OCR Recognition (Root)</string>
<string name="perm_ocr_perm_shizuku">OCR Recognition (Shizuku)</string>
<string name="perm_ocr_perm_accessibility">OCR Recognition (Accessibility)</string>
Expand Down
Binary file added artifacts/AutoAccounting-debug.apk
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ data class BillInfoModel(
*/
var remark: String = "",

/**
* 当前 GPS 定位得到的地址信息,格式化后可用于备注模板。
* 例如:嘉兴市南湖区xx路xx小区6号
*/
var locationInfo: String = "",

Comment on lines +124 to +129
/**
* 是否为自动记录的账单
*/
Expand Down
63 changes: 50 additions & 13 deletions server/src/main/java/org/ezbook/server/tools/BillMerger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import java.util.Locale
*/
object BillMerger {

private const val MAX_REMARK_NORMALIZATION_LENGTH = 512
private const val MAX_REMARK_NORMALIZATION_SCAN_LENGTH = 64

/**
* 合并账单数据
*
Expand Down Expand Up @@ -206,6 +209,11 @@ object BillMerger {
val normalizedShopName = normalizeName(billInfoModel.shopName).trim()
val normalizedShopItem = normalizeName(billInfoModel.shopItem).trim()
val (shopName, shopItem) = deduplicateRemarkFields(normalizedShopName, normalizedShopItem)
val locationInfo = formatLocationAddressParts(
city = billInfoModel.locationInfo,
district = "",
detail = ""
)

// 替换占位符
return template
Expand All @@ -219,6 +227,7 @@ object BillMerger {
.replace("【原始资产】", billInfoModel.accountNameFrom)
.replace("【目标资产】", billInfoModel.accountNameTo)
.replace("【渠道】", billInfoModel.channel)
.replace("【位置信息】", locationInfo)
// 扩展信息
.replace("【规则名称】", billInfoModel.ruleName)
.replace("【AI】", getAIProvider(billInfoModel))
Expand All @@ -229,6 +238,34 @@ object BillMerger {
.replace("【时间】", formatTime(billInfoModel.time))
}

fun formatLocationAddressParts(city: String, district: String, detail: String): String {
val rawAddress = listOf(city, district, detail)
.map(String::trim)
.filter(String::isNotEmpty)
.joinToString(separator = "")

val normalizedAddress = stripCountryAndProvincePrefix(rawAddress)
.replace(Regex("[,,/\\s]+"), "")
.trim()

return normalizedAddress.ifEmpty { "未授权位置信息" }
}

private fun stripCountryAndProvincePrefix(address: String): String {
val normalized = address.trim()
if (normalized.isEmpty()) return ""

val withoutCountry = normalized
.removePrefix("中国")
.removePrefix("中华人民共和国")
.trimStart()
Comment on lines +258 to +261

val provincePrefix = Regex("^[^省自治区特别行政区]+(?:省|自治区|特别行政区)")
return provincePrefix.find(withoutCountry)?.let {
withoutCountry.substring(it.value.length).trimStart()
} ?: withoutCountry
}

/**
* 规范化名称:仅去除名称中相邻重复的子串(长度≥2),例如:
* - "京东自营京东自营旗舰店" -> "京东自营旗舰店"
Expand All @@ -239,31 +276,31 @@ object BillMerger {
* 这类包含相同日期前缀的正常备注会被误删,表现为备注被截断。
*/
internal fun normalizeName(name: String): String {
var result = name.trim()
if (result.isEmpty()) return result
val trimmed = name.trim()
if (trimmed.isEmpty()) return trimmed

// 超长文本在监听流程中可能非常频繁,直接跳过繁重的规范化,避免把 UI/监听线程拖死。
if (trimmed.length > MAX_REMARK_NORMALIZATION_LENGTH) return trimmed

var result = trimmed
while (true) {
var found = false

// 只处理相邻重复片段,从长到短扫描,优先保留更具体的结构
outer@ for (len in result.length / 2 downTo 2) {
for (i in 0..result.length - len * 2) {
// 只处理相邻重复片段,从长到短扫描,优先保留更具体的结构。
// 同时限制扫描长度,避免对超长字符串做指数级扫描。
outer@ for (len in minOf(result.length / 2, MAX_REMARK_NORMALIZATION_SCAN_LENGTH) downTo 2) {
val maxStart = result.length - len * 2
for (i in 0..maxStart) {
val sub = result.substring(i, i + len)
val nextStart = i + len
if (!result.startsWith(sub, nextStart)) continue

var duplicateEnd = nextStart
while (duplicateEnd + len <= result.length && result.startsWith(
sub,
duplicateEnd
)
) {
while (duplicateEnd + len <= result.length && result.startsWith(sub, duplicateEnd)) {
duplicateEnd += len
}

val sb = StringBuilder(result)
sb.delete(nextStart, duplicateEnd)
result = sb.toString()
result = result.removeRange(nextStart, duplicateEnd)
found = true
break@outer
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ class BillService(
accountNameFrom = json.safeGetString("accountNameFrom")
accountNameTo = json.safeGetString("accountNameTo")
channel = json.safeGetString("channel")
locationInfo = json.safeGetString("locationInfo")

// 构造 CurrencyModel:获取币种代码并查询汇率
val rawCurrency = json.safeGetString("currency").uppercase().ifEmpty { "CNY" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package org.ezbook.server.tools
import org.ezbook.server.db.model.BillInfoModel
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

class BillMergerRemarkNormalizationTest {

Expand Down Expand Up @@ -57,6 +59,49 @@ class BillMergerRemarkNormalizationTest {
)
}

@Test
fun normalizeName_handlesLargeRepeatedTextWithoutBlocking() {
val input = "abc123".repeat(500)
val executor = Executors.newSingleThreadExecutor()
try {
val future = executor.submit<String> { BillMerger.normalizeName(input) }
val result = future.get(2, TimeUnit.SECONDS)
assertEquals(input, result)
} finally {
executor.shutdownNow()
}
Comment on lines +65 to +72
}

@Test
fun formatLocationAddressParts_dropsCountryAndProvinceAndKeepsCityDistrictDetail() {
val format = BillMerger.formatLocationAddressParts(
city = "中国浙江省嘉兴市南湖区xx路xx小区6号",
district = "",
detail = ""
)

assertEquals("嘉兴市南湖区xx路xx小区6号", format)
}

@Test
fun formatLocationAddressParts_ignoresBlankSegments() {
val format = BillMerger.formatLocationAddressParts(
city = "",
district = "南湖区",
detail = ""
)

assertEquals("南湖区", format)
}

@Test
fun formatLocationAddressParts_returnsFallbackWhenAddressMissing() {
assertEquals(
"未授权位置信息",
BillMerger.formatLocationAddressParts(city = "", district = "", detail = "")
)
}

@Test
fun mergeChannelInfo_joinsDistinctSources() {
val parent = BillInfoModel(channel = "微信[招商银行]")
Expand Down