feat:Android notification

This commit is contained in:
2025-11-03 11:25:39 +08:00
parent 8d513ad702
commit d6339b746b
5 changed files with 2595 additions and 30 deletions

View File

@@ -73,3 +73,61 @@
- 引入 OCR / LLM 能力Google Vision、Gemini 等)补齐票据识别与消费分析的真实数据链路。
- 补充自动化测试Vitest前端组件/状态、Jest + Supertest后端 API、Playwright端到端流程
- 优化移动端体验:离线缓存、渐进式加载、通知权限引导流程。
## 功能调试指南
### 通知监听接口调试
后端已提供 `POST /api/transactions/notification` 接口,通过 HMAC-SHA256 校验签名后写入交易。调试步骤:
1. 在后端启动(`pnpm dev:backend`)前,确认 `.env.local` 中的 `NOTIFICATION_WEBHOOK_SECRET`、`HOST=0.0.0.0` 配置,前端使用相同局域网地址;
2. 构造签名:`payload = packageName|title|body|timestamp``signature = HMAC-SHA256(payload, secret)`,示例脚本:
```bash
secret="38f71dbf99ab510d970165e43979f945f992848406117c8597a892837331a853"
package="com.eg.android.AlipayGphone"
title="支付宝到账"
body="到账 88.88 元"
timestamp=$(date +%s000)
payload="${package}|${title}|${body}|${timestamp}"
signature=$(printf '%s' "$payload" | openssl dgst -sha256 -hex -hmac "$secret" | awk '{print $2}')
curl -X POST "http://<后端IP>:4000/api/transactions/notification" \
-H "Content-Type: application/json" \
-d "{
\"packageName\": \"${package}\",
\"title\": \"${title}\",
\"body\": \"${body}\",
\"receivedAt\": ${timestamp},
\"signature\": \"${signature}\"
}"
```
3. 返回 `202` 表示接口验证成功;前端「设置中心 → 通知监听」卡片会显示入库次数、最近时间,并在交易列表里出现新的 `pending` 记录。
4. 若要模拟多条通知,可调整 `packageName`、金额或时间戳;若签名不匹配,后端会返回 `401` 并在日志输出 “Notification signature mismatch”。
### Android 打包与真机测试
前端目前为 Web 工程,可通过 Capacitor 集成 Android 壳。建议步骤:
1. 在 `apps/frontend` 下安装 Capacitor
```bash
pnpm add @capacitor/core @capacitor/cli @capacitor/android
pnpm add -D @capacitor/assets
pnpm cap init # 或使用现有配置
pnpm cap add android
```
在 `capacitor.config.ts` 中配置开发服务器地址、`webDir: 'dist'`。
新增通知监听后,需要在 `apps/frontend/android/gradle.properties`(或运行 `gradlew` 时的命令行)写入:
```
API_BASE_URL=http://<你的后端IP>:4000/api
NOTIFICATION_WEBHOOK_SECRET=<与后端一致的 HMAC 密钥>
```
2. 开发模式:`pnpm dev:frontend -- --host 0.0.0.0` + `pnpm dev:backend`,将手机/模拟器连接同一局域网Capacitor `server.url` 指向 `http://<电脑IP>:5173`,即可在真机上加载开发版 Web 页面。
3. 生产版打包:
```bash
pnpm --filter frontend build
pnpm cap copy android
```
打开 `apps/frontend/android/`,在 Android Studio 中构建(`Build > Build APK(s)`),或生成签名 APK。
4. 原生通知监听:在 Android 工程里实现 `NotificationListenerService`(或接入自定义插件)将捕获的通知调用上述 webhookHMAC 签名规则一致),即可验证完整通知→后端→前端流程。
A 自带的调试技巧包括:
- `adb reverse tcp:4000 tcp:4000` / `adb reverse tcp:5173 tcp:5173`(模拟器访问电脑服务);
- `adb shell cmd notification post -S bigtext <package> <title> <text>`(模拟通知);
- 使用 Android Studio Network Inspector/Logcat 观察上传请求;后端终端输出或 `GET /api/notifications/status` 查看最新入库情况。

View File

@@ -1,5 +1,10 @@
import groovy.json.JsonOutput
apply plugin: 'com.android.application'
def apiBaseUrl = project.hasProperty("API_BASE_URL") ? project.property("API_BASE_URL") : "http://10.0.2.2:4000/api"
def notificationSecret = project.hasProperty("NOTIFICATION_WEBHOOK_SECRET") ? project.property("NOTIFICATION_WEBHOOK_SECRET") : "CHANGE_ME"
android {
namespace "com.bill.ai"
compileSdk rootProject.ext.compileSdkVersion
@@ -15,6 +20,11 @@ android {
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
buildConfigField "String", "API_BASE_URL", JsonOutput.toJson(apiBaseUrl)
buildConfigField "String", "NOTIFICATION_WEBHOOK_SECRET", JsonOutput.toJson(notificationSecret)
}
buildFeatures {
buildConfig true
}
buildTypes {
release {
@@ -36,6 +46,7 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation "com.squareup.okhttp3:okhttp:4.12.0"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

@@ -33,9 +33,20 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
<service
android:name=".notification.AiBillNotificationListenerService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View File

@@ -0,0 +1,141 @@
package com.bill.ai.notification;
import android.app.Notification;
import android.os.Bundle;
import android.service.notification.NotificationListenerService;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import com.bill.ai.BuildConfig;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* Listens for system notifications and forwards selected ones to the AI Bill backend.
* Ensure the user has granted notification access permission to this app.
*/
public class AiBillNotificationListenerService extends NotificationListenerService {
private static final String TAG = "AiBillNotification";
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private final OkHttpClient httpClient = new OkHttpClient();
private final ExecutorService executor = Executors.newSingleThreadExecutor();
@Override
public void onNotificationPosted(StatusBarNotification sbn) {
if (sbn == null) {
return;
}
Notification notification = sbn.getNotification();
if (notification == null) {
return;
}
Bundle extras = notification.extras;
CharSequence titleCs = extras != null ? extras.getCharSequence(Notification.EXTRA_TITLE) : null;
CharSequence textCs = extras != null ? extras.getCharSequence(Notification.EXTRA_TEXT) : null;
final String packageName = sbn.getPackageName();
final String title = titleCs != null ? titleCs.toString() : "";
final String body = textCs != null ? textCs.toString() : "";
final long timestamp = sbn.getPostTime();
if (TextUtils.isEmpty(packageName) || (TextUtils.isEmpty(title) && TextUtils.isEmpty(body))) {
return;
}
executor.execute(() -> sendToBackend(packageName, title, body, timestamp));
}
private void sendToBackend(@NonNull String packageName,
@NonNull String title,
@NonNull String body,
long timestamp) {
String baseUrl = BuildConfig.API_BASE_URL != null ? BuildConfig.API_BASE_URL : "";
if (TextUtils.isEmpty(baseUrl)) {
Log.w(TAG, "API base URL is empty, skip notification upload");
return;
}
String endpoint = baseUrl.endsWith("/") ? baseUrl + "transactions/notification" : baseUrl + "/transactions/notification";
String payloadToSign = String.format(Locale.US, "%s|%s|%s|%d", packageName, title, body, timestamp);
String signature = computeSignature(payloadToSign, BuildConfig.NOTIFICATION_WEBHOOK_SECRET);
if (signature == null) {
Log.w(TAG, "Failed to compute signature, abort upload");
return;
}
String json = String.format(Locale.US,
"{\"packageName\":\"%s\",\"title\":\"%s\",\"body\":\"%s\",\"receivedAt\":%d,\"signature\":\"%s\"}",
escapeJson(packageName), escapeJson(title), escapeJson(body), timestamp, signature);
Request request = new Request.Builder()
.url(endpoint)
.post(RequestBody.create(json, JSON))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
Log.w(TAG, "Notification upload failed: " + response.code() + " - " + response.message());
} else {
Log.d(TAG, "Notification uploaded successfully");
}
} catch (IOException e) {
Log.e(TAG, "Notification upload error", e);
}
}
private String computeSignature(String payload, String secret) {
if (TextUtils.isEmpty(secret)) {
Log.w(TAG, "Webhook secret not configured");
return null;
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return bytesToHex(digest);
} catch (Exception e) {
Log.e(TAG, "Failed to compute signature", e);
return null;
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format(Locale.US, "%02x", b));
}
return sb.toString();
}
private String escapeJson(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
@Override
public void onDestroy() {
super.onDestroy();
executor.shutdownNow();
}
}

2404
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff