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

@@ -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();
}
}