feat:Android notification
This commit is contained in:
58
README.md
58
README.md
@@ -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`(或接入自定义插件)将捕获的通知调用上述 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'
|
||||
|
||||
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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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