feat:Android notification
This commit is contained in:
58
README.md
58
README.md
@@ -73,3 +73,61 @@
|
|||||||
- 引入 OCR / LLM 能力(Google Vision、Gemini 等)补齐票据识别与消费分析的真实数据链路。
|
- 引入 OCR / LLM 能力(Google Vision、Gemini 等)补齐票据识别与消费分析的真实数据链路。
|
||||||
- 补充自动化测试:Vitest(前端组件/状态)、Jest + Supertest(后端 API)、Playwright(端到端流程)。
|
- 补充自动化测试: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`(或接入自定义插件)将捕获的通知调用上述 webhook(HMAC 签名规则一致),即可验证完整通知→后端→前端流程。
|
||||||
|
|
||||||
|
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` 查看最新入库情况。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import groovy.json.JsonOutput
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
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 {
|
android {
|
||||||
namespace "com.bill.ai"
|
namespace "com.bill.ai"
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
@@ -15,6 +20,11 @@ android {
|
|||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// 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:!*~'
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
@@ -36,6 +46,7 @@ dependencies {
|
|||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|||||||
@@ -33,9 +33,20 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</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>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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
2404
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user