diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..e98cdff28 --- /dev/null +++ b/.github/workflows/main.yml @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea4fc146b..58e962d33 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,8 @@ + + - + 悬浮窗权限 允许显示悬浮窗以快速提取工具。 + 位置信息权限 + 允许应用读取当前位置信息,以便在备注格式中填入位置信息。 + OCR识别(Root) OCR识别(Shizuku) OCR识别(无障碍) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34b29df00..18821550d 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -512,6 +512,9 @@ Overlay Permission Allows displaying floating windows for quick extraction tools. + Location Permission + Allows the app to read your current location so the bill remark template can include location details. + OCR Recognition (Root) OCR Recognition (Shizuku) OCR Recognition (Accessibility) diff --git a/artifacts/AutoAccounting-debug.apk b/artifacts/AutoAccounting-debug.apk new file mode 100644 index 000000000..5cc79d0b2 Binary files /dev/null and b/artifacts/AutoAccounting-debug.apk differ diff --git a/server/src/main/java/org/ezbook/server/db/model/BillInfoModel.kt b/server/src/main/java/org/ezbook/server/db/model/BillInfoModel.kt index 571e25774..1af773b99 100644 --- a/server/src/main/java/org/ezbook/server/db/model/BillInfoModel.kt +++ b/server/src/main/java/org/ezbook/server/db/model/BillInfoModel.kt @@ -121,6 +121,12 @@ data class BillInfoModel( */ var remark: String = "", + /** + * 当前 GPS 定位得到的地址信息,格式化后可用于备注模板。 + * 例如:嘉兴市南湖区xx路xx小区6号 + */ + var locationInfo: String = "", + /** * 是否为自动记录的账单 */ diff --git a/server/src/main/java/org/ezbook/server/tools/BillMerger.kt b/server/src/main/java/org/ezbook/server/tools/BillMerger.kt index da5c24e71..258717309 100644 --- a/server/src/main/java/org/ezbook/server/tools/BillMerger.kt +++ b/server/src/main/java/org/ezbook/server/tools/BillMerger.kt @@ -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 + /** * 合并账单数据 * @@ -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 @@ -219,6 +227,7 @@ object BillMerger { .replace("【原始资产】", billInfoModel.accountNameFrom) .replace("【目标资产】", billInfoModel.accountNameTo) .replace("【渠道】", billInfoModel.channel) + .replace("【位置信息】", locationInfo) // 扩展信息 .replace("【规则名称】", billInfoModel.ruleName) .replace("【AI】", getAIProvider(billInfoModel)) @@ -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() + + val provincePrefix = Regex("^[^省自治区特别行政区]+(?:省|自治区|特别行政区)") + return provincePrefix.find(withoutCountry)?.let { + withoutCountry.substring(it.value.length).trimStart() + } ?: withoutCountry + } + /** * 规范化名称:仅去除名称中相邻重复的子串(长度≥2),例如: * - "京东自营京东自营旗舰店" -> "京东自营旗舰店" @@ -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 } diff --git a/server/src/main/java/org/ezbook/server/tools/BillService.kt b/server/src/main/java/org/ezbook/server/tools/BillService.kt index 4626811c1..ff586d779 100644 --- a/server/src/main/java/org/ezbook/server/tools/BillService.kt +++ b/server/src/main/java/org/ezbook/server/tools/BillService.kt @@ -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" } diff --git a/server/src/test/java/org/ezbook/server/tools/BillMergerRemarkNormalizationTest.kt b/server/src/test/java/org/ezbook/server/tools/BillMergerRemarkNormalizationTest.kt index fb43a6475..3e9523e25 100644 --- a/server/src/test/java/org/ezbook/server/tools/BillMergerRemarkNormalizationTest.kt +++ b/server/src/test/java/org/ezbook/server/tools/BillMergerRemarkNormalizationTest.kt @@ -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 { @@ -57,6 +59,49 @@ class BillMergerRemarkNormalizationTest { ) } + @Test + fun normalizeName_handlesLargeRepeatedTextWithoutBlocking() { + val input = "abc123".repeat(500) + val executor = Executors.newSingleThreadExecutor() + try { + val future = executor.submit { BillMerger.normalizeName(input) } + val result = future.get(2, TimeUnit.SECONDS) + assertEquals(input, result) + } finally { + executor.shutdownNow() + } + } + + @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 = "微信[招商银行]")