feat:Android notification
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user