Merge branch 'dev'
This commit is contained in:
@@ -33,6 +33,17 @@
|
|||||||
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=".CardNotifListenerService"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.notification.NotificationListenerService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
package kr.sblog.slimbudget;
|
package kr.sblog.slimbudget;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// 무시
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
|||||||
import { accountApi } from '@/api/accountApi'
|
import { accountApi } from '@/api/accountApi'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
||||||
|
import { cardNotif } from '@/native/cardNotif'
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
|
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
|
||||||
|
|
||||||
@@ -17,6 +18,16 @@ const error = ref(null)
|
|||||||
const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
|
const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
|
||||||
const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지
|
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() {
|
async function loadPendingCount() {
|
||||||
try {
|
try {
|
||||||
pendingCount.value = (await accountApi.pendingCount()).count || 0
|
pendingCount.value = (await accountApi.pendingCount()).count || 0
|
||||||
@@ -491,6 +502,7 @@ onMounted(async () => {
|
|||||||
loadWallets()
|
loadWallets()
|
||||||
loadCategories()
|
loadCategories()
|
||||||
loadPendingCount()
|
loadPendingCount()
|
||||||
|
checkNotifPermission()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -501,6 +513,12 @@ onMounted(async () => {
|
|||||||
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
|
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 시) -->
|
||||||
|
<div v-if="notifNative && !notifEnabled" class="notif-banner">
|
||||||
|
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b>이 필요합니다.</span>
|
||||||
|
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="month-nav">
|
<div class="month-nav">
|
||||||
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
|
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
|
||||||
<span class="period">{{ periodLabel }}</span>
|
<span class="period">{{ periodLabel }}</span>
|
||||||
@@ -932,6 +950,29 @@ button.primary {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
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 {
|
.pending-count {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user