From 3be1d8792c5d71a2ff3941dea5fe4a477547c74e Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Wed, 3 Jun 2026 18:55:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=9E=90=EB=8F=99=EC=9D=B8=EC=8B=9D=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=ED=94=8C=EB=9F=AC?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CardNotifListenerService: 결제 알림(금액+승인/결제/카드) 가로채 백엔드로 전송 (토큰은 Capacitor Preferences에서, 결제성 알림만 전송) - CardNotifPlugin: 알림 접근 권한 확인/설정 열기 - AndroidManifest 서비스 등록, MainActivity 플러그인 등록 - 가계부 화면: 네이티브 미허용 시 '알림 접근 권한' 안내 배너 Co-Authored-By: Claude Opus 4.8 --- android/app/src/main/AndroidManifest.xml | 11 +++ .../slimbudget/CardNotifListenerService.java | 86 +++++++++++++++++++ .../kr/sblog/slimbudget/CardNotifPlugin.java | 39 +++++++++ .../kr/sblog/slimbudget/MainActivity.java | 10 ++- src/native/cardNotif.js | 28 ++++++ src/views/account/AccountView.vue | 41 +++++++++ 6 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/kr/sblog/slimbudget/CardNotifListenerService.java create mode 100644 android/app/src/main/java/kr/sblog/slimbudget/CardNotifPlugin.java create mode 100644 src/native/cardNotif.js diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bcc4816..5b02fac 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,6 +33,17 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"> + + + + + + + diff --git a/android/app/src/main/java/kr/sblog/slimbudget/CardNotifListenerService.java b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifListenerService.java new file mode 100644 index 0000000..41bcdce --- /dev/null +++ b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifListenerService.java @@ -0,0 +1,86 @@ +package kr.sblog.slimbudget; + +import android.app.Notification; +import android.os.Bundle; +import android.service.notification.NotificationListenerService; +import android.service.notification.StatusBarNotification; + +import org.json.JSONObject; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * 카드사 결제 푸시 알림을 가로채 백엔드로 전달한다. + * - 결제로 보이는 알림(금액+승인/결제/카드)만 전송 (개인정보 최소화) + * - 인증 토큰은 Capacitor Preferences(SharedPreferences "CapacitorStorage")의 token 사용 + * - 백엔드가 파싱·중복제거·미확인 내역 생성 + */ +public class CardNotifListenerService extends NotificationListenerService { + + private static final String API_URL = "https://app.sblog.kr/api/account/entries/notification"; + + @Override + public void onNotificationPosted(StatusBarNotification sbn) { + try { + if (sbn == null || sbn.getNotification() == null) return; + Bundle ex = sbn.getNotification().extras; + if (ex == null) return; + + CharSequence titleCs = ex.getCharSequence(Notification.EXTRA_TITLE); + CharSequence textCs = ex.getCharSequence(Notification.EXTRA_TEXT); + CharSequence bigCs = ex.getCharSequence(Notification.EXTRA_BIG_TEXT); + + String title = titleCs == null ? "" : titleCs.toString(); + String text = (bigCs != null ? bigCs : (textCs == null ? "" : textCs)).toString(); + + if (!looksLikePayment(title + " " + text)) return; + + String token = getSharedPreferences("CapacitorStorage", MODE_PRIVATE) + .getString("token", null); + if (token == null || token.isEmpty()) return; + + final String pkg = sbn.getPackageName(); + new Thread(() -> post(pkg, title, text, token)).start(); + } catch (Exception ignore) { + // 알림 처리 실패는 무시 (앱 동작에 영향 없도록) + } + } + + /** 결제 알림 후보 판별: 금액(####원) + 승인/결제/카드 키워드 */ + private boolean looksLikePayment(String s) { + boolean money = s.matches("(?s).*\\d[\\d,]{2,}\\s*원.*"); + boolean kw = s.contains("승인") || s.contains("결제") || s.contains("카드"); + return money && kw; + } + + private void post(String pkg, String title, String text, String token) { + HttpURLConnection conn = null; + try { + URL url = new URL(API_URL); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Authorization", "Bearer " + token); + conn.setConnectTimeout(8000); + conn.setReadTimeout(8000); + conn.setDoOutput(true); + + JSONObject body = new JSONObject(); + body.put("app", pkg); + body.put("title", title); + body.put("text", text); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.toString().getBytes(StandardCharsets.UTF_8)); + } + conn.getResponseCode(); // 요청 전송 (응답은 무시) + } catch (Exception ignore) { + // 네트워크 실패는 무시 + } finally { + if (conn != null) conn.disconnect(); + } + } +} diff --git a/android/app/src/main/java/kr/sblog/slimbudget/CardNotifPlugin.java b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifPlugin.java new file mode 100644 index 0000000..f5cc534 --- /dev/null +++ b/android/app/src/main/java/kr/sblog/slimbudget/CardNotifPlugin.java @@ -0,0 +1,39 @@ +package kr.sblog.slimbudget; + +import android.content.Intent; +import android.provider.Settings; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * 카드 결제 알림 자동인식 플러그인. + * - isEnabled: 알림 접근 권한(NotificationListener) 허용 여부 + * - openSettings: 알림 접근 설정 화면 열기 + * 실제 알림 수신/전송은 CardNotifListenerService 가 담당한다. + */ +@CapacitorPlugin(name = "CardNotif") +public class CardNotifPlugin extends Plugin { + + @PluginMethod + public void isEnabled(PluginCall call) { + String pkg = getContext().getPackageName(); + String flat = Settings.Secure.getString( + getContext().getContentResolver(), "enabled_notification_listeners"); + boolean enabled = flat != null && flat.contains(pkg); + JSObject ret = new JSObject(); + ret.put("enabled", enabled); + call.resolve(ret); + } + + @PluginMethod + public void openSettings(PluginCall call) { + Intent intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getContext().startActivity(intent); + call.resolve(); + } +} diff --git a/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java b/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java index 78a4bd5..5a66b49 100644 --- a/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java +++ b/android/app/src/main/java/kr/sblog/slimbudget/MainActivity.java @@ -1,5 +1,13 @@ package kr.sblog.slimbudget; +import android.os.Bundle; + import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(CardNotifPlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/src/native/cardNotif.js b/src/native/cardNotif.js new file mode 100644 index 0000000..6f65e57 --- /dev/null +++ b/src/native/cardNotif.js @@ -0,0 +1,28 @@ +// 카드 결제 알림 자동인식 — 네이티브 플러그인(CardNotif) 래퍼. +// 웹에서는 no-op. 권한 확인/설정 열기만 제공(실제 수신·전송은 네이티브 서비스가 담당). +import { Capacitor, registerPlugin } from '@capacitor/core' + +const Plugin = registerPlugin('CardNotif') + +export const cardNotif = { + isNative() { + return Capacitor.isNativePlatform() + }, + async isEnabled() { + if (!Capacitor.isNativePlatform()) return false + try { + const r = await Plugin.isEnabled() + return !!r?.enabled + } catch { + return false + } + }, + async openSettings() { + if (!Capacitor.isNativePlatform()) return + try { + await Plugin.openSettings() + } catch { + // 무시 + } + }, +} diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 7fa2399..672532f 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { accountApi } from '@/api/accountApi' import IconBtn from '@/components/ui/IconBtn.vue' import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr' +import { cardNotif } from '@/native/cardNotif' import { Capacitor } from '@capacitor/core' // @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발) @@ -17,6 +18,16 @@ const error = ref(null) const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수 const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지 +// 카드 결제 알림 자동인식 권한 (네이티브 전용) +const notifNative = cardNotif.isNative() +const notifEnabled = ref(true) // 미허용일 때만 배너 노출 +async function checkNotifPermission() { + if (notifNative) notifEnabled.value = await cardNotif.isEnabled() +} +function openNotifSettings() { + cardNotif.openSettings() +} + async function loadPendingCount() { try { pendingCount.value = (await accountApi.pendingCount()).count || 0 @@ -491,6 +502,7 @@ onMounted(async () => { loadWallets() loadCategories() loadPendingCount() + checkNotifPermission() }) @@ -501,6 +513,12 @@ onMounted(async () => { + +
+ 💳 카드 결제 알림을 자동으로 가계부에 등록하려면 알림 접근 권한이 필요합니다. + +
+
{{ periodLabel }} @@ -932,6 +950,29 @@ button.primary { cursor: pointer; white-space: nowrap; } +.notif-banner { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + padding: 0.6rem 0.8rem; + margin-bottom: 1rem; + border: 1px solid hsla(160, 100%, 37%, 0.4); + border-radius: 8px; + background: hsla(160, 100%, 37%, 0.08); + font-size: 0.83rem; +} +.notif-btn { + margin-left: auto; + padding: 0.35rem 0.8rem; + font-size: 0.82rem; + border: 1px solid hsla(160, 100%, 37%, 1); + border-radius: 4px; + background: hsla(160, 100%, 37%, 1); + color: #fff; + cursor: pointer; + white-space: nowrap; +} .pending-count { margin-left: 0.5rem; font-size: 0.72rem;