가상매매 시간봉 제거
This commit is contained in:
+5
-1
@@ -1 +1,5 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080/api
|
||||
# 웹 로컬 개발
|
||||
# VITE_API_BASE_URL=http://localhost:8080/api
|
||||
|
||||
# 모바일 APK / exdev (HTTPS 443 미사용 — http 만)
|
||||
VITE_API_BASE_URL=http://exdev.co.kr/api
|
||||
|
||||
+6
-4
@@ -34,11 +34,13 @@ npm run cap:android # or cap:ios
|
||||
|
||||
Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
|
||||
|
||||
### 로그인 시 "Failed to fetch"
|
||||
### 로그인 시 "Failed to fetch" / "서버 응답이 느립니다"
|
||||
|
||||
- APK는 빌드 시 `.env`의 `VITE_API_BASE_URL`이 번들에 박힙니다 (기본: `http://exdev.co.kr/api`).
|
||||
- `capacitor.config.ts`의 `androidScheme`은 **`http`** 여야 합니다. `https`이면 WebView가 `https://localhost`에서 뜨고 HTTP API 호출이 차단됩니다.
|
||||
- 설정에 `localhost` API가 저장돼 있으면 앱 시작 시 자동으로 제거합니다.
|
||||
- API는 **`http://exdev.co.kr/api`** 만 사용 (`https://exdev.co.kr` 은 443 미개방).
|
||||
- exdev 로그인은 **10~30초** 걸릴 수 있음 — 2분 타임아웃까지 대기.
|
||||
- APK는 `app/.env`의 `VITE_API_BASE_URL`이 빌드에 포함되어야 함.
|
||||
- `androidScheme`은 **`http`**. `CapacitorHttp`는 비활성(로그인 fetch 안정화).
|
||||
- 설정에 `localhost`·`https://` API가 저장돼 있으면 앱 시작 시 제거·`http`로 교정.
|
||||
|
||||
## FCM (Firebase)
|
||||
|
||||
|
||||
@@ -12,11 +12,18 @@
|
||||
## 2. 프로젝트에 배치
|
||||
|
||||
```bash
|
||||
# 권장: repo 루트 app/google-services.json (com.goldenchart.app 포함)
|
||||
cp ~/Downloads/google-services.json app/google-services.json
|
||||
./scripts/ensure-android-google-services.sh
|
||||
|
||||
# 또는 한 번에
|
||||
./scripts/setup-android-fcm.sh ~/Downloads/google-services.json
|
||||
# 또는
|
||||
cp ~/Downloads/google-services.json app/android/app/google-services.json
|
||||
```
|
||||
|
||||
빌드 시 `ensure-android-google-services.sh` 가 `app/google-services.json` 을
|
||||
`app/android/app/google-services.json` 으로 복사합니다. **이 파일이 없으면**
|
||||
알림 권한 허용 직후 `PushNotifications.register()` 에서 **앱이 종료**될 수 있습니다.
|
||||
|
||||
## 3. 빌드
|
||||
|
||||
```bash
|
||||
@@ -35,17 +42,21 @@ cd app/android && ./gradlew assembleRelease
|
||||
|
||||
## 5. 앱에서 확인
|
||||
|
||||
1. 로그인 후 **설정 → FCM 푸시** ON
|
||||
2. 알림 권한 허용
|
||||
3. **테스트 발송** 버튼
|
||||
4. 전략 시그널 발생 시 푸시 수신
|
||||
1. **2.4 이상 APK** 설치 (`google-services.json` 포함 빌드 필수)
|
||||
2. 크래시가 반복되면 **앱 삭제 후 재설치** (이전에 허용한 알림 권한 초기화)
|
||||
3. 로그인 → **설정 → FCM 푸시** ON
|
||||
4. **「알림 허용 · FCM 등록」** 버튼 → 시스템 팝업에서 허용 (2초 후 등록)
|
||||
5. **테스트 발송** 으로 확인
|
||||
|
||||
로그인·메인 진입 시에는 **알림 권한을 절대 요청하지 않습니다** (2.3+).
|
||||
FCM 푸시 토글 ON → **「알림 허용 · FCM 등록」** 버튼을 눌렀을 때만 권한 팝업이 뜹니다.
|
||||
|
||||
## goldenApp 과의 차이
|
||||
|
||||
| 항목 | goldenApp | GoldenChart (Capacitor) |
|
||||
|------|-----------|-------------------------|
|
||||
| 패키지 | `com.golden.app` | `com.goldenchart.app` |
|
||||
| FCM SDK | 직접 Firebase Messaging | `@capacitor/push-notifications` |
|
||||
| FCM SDK | 직접 Firebase Messaging | 앱 내장 `GoldenFcm` 플러그인 (Capacitor Push 미사용) |
|
||||
| 토큰 등록 | `POST /api/fcm/token` | 동일 (`registerFcmToken`) |
|
||||
| 알림 채널 | `golden_alerts` | `goldenchart_trade_signals` |
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.goldenchart.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 7
|
||||
versionName "1.6"
|
||||
versionCode 16
|
||||
versionName "2.5"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -40,15 +40,17 @@ dependencies {
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
implementation platform('com.google.firebase:firebase-bom:33.7.0')
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
implementation 'com.google.android.gms:play-services-base:18.5.0'
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (!servicesJSON.exists()) {
|
||||
throw new GradleException(
|
||||
'google-services.json 없음. 실행: ./scripts/ensure-android-google-services.sh'
|
||||
)
|
||||
}
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
|
||||
@@ -12,7 +12,6 @@ dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-splash-screen')
|
||||
implementation project(':capacitor-status-bar')
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "353782909389",
|
||||
"project_id": "goldenanalysisapp",
|
||||
"storage_bucket": "goldenanalysisapp.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84",
|
||||
"android_client_info": {
|
||||
"package_name": "com.golden.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:353782909389:android:62e71bbb669c64bbc75c84",
|
||||
"android_client_info": {
|
||||
"package_name": "com.goldenchart.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -18,6 +19,16 @@
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id" />
|
||||
<!-- 권한 허용·앱 시작 시 FCM 자동 초기화 방지 — JS register() 시에만 활성화 -->
|
||||
<meta-data
|
||||
android:name="firebase_messaging_auto_init_enabled"
|
||||
android:value="false"
|
||||
tools:replace="android:value" />
|
||||
|
||||
<!-- Capacitor push-notifications 잔여 병합 제거 (이중 MessagingService 크래시 방지) -->
|
||||
<service
|
||||
android:name="com.capacitorjs.plugins.pushnotifications.MessagingService"
|
||||
tools:node="remove" />
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
@@ -34,6 +45,14 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".GoldenChartMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.goldenchart.app;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FCM 수신 — JS GoldenFcm 리스너로 전달
|
||||
*/
|
||||
public class GoldenChartMessagingService extends FirebaseMessagingService {
|
||||
|
||||
@Override
|
||||
public void onNewToken(@NonNull String token) {
|
||||
super.onNewToken(token);
|
||||
GoldenFcmPlugin.emitRegistration(token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(@NonNull RemoteMessage message) {
|
||||
super.onMessageReceived(message);
|
||||
JSObject notification = new JSObject();
|
||||
RemoteMessage.Notification n = message.getNotification();
|
||||
if (n != null) {
|
||||
if (n.getTitle() != null) notification.put("title", n.getTitle());
|
||||
if (n.getBody() != null) notification.put("body", n.getBody());
|
||||
}
|
||||
JSObject data = new JSObject();
|
||||
for (Map.Entry<String, String> e : message.getData().entrySet()) {
|
||||
data.put(e.getKey(), e.getValue());
|
||||
}
|
||||
notification.put("data", data);
|
||||
GoldenFcmPlugin.emitPushReceived(notification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.goldenchart.app;
|
||||
|
||||
import android.Manifest;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.google.android.gms.common.ConnectionResult;
|
||||
import com.google.android.gms.common.GoogleApiAvailability;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* FCM — Capacitor PushNotifications 미사용 (크래시 방지)
|
||||
*/
|
||||
@CapacitorPlugin(
|
||||
name = "GoldenFcm",
|
||||
permissions = {
|
||||
@Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = GoldenFcmPlugin.PERM)
|
||||
}
|
||||
)
|
||||
public class GoldenFcmPlugin extends Plugin {
|
||||
|
||||
static final String PERM = "notifications";
|
||||
private static final String TAG = "GoldenFcm";
|
||||
private static GoldenFcmPlugin instance;
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
instance = this;
|
||||
}
|
||||
|
||||
static void emitRegistration(String token) {
|
||||
if (instance == null || token == null) return;
|
||||
JSObject data = new JSObject();
|
||||
data.put("value", token);
|
||||
instance.notifyListeners("registration", data);
|
||||
}
|
||||
|
||||
static void emitRegistrationError(String message) {
|
||||
if (instance == null) return;
|
||||
JSObject data = new JSObject();
|
||||
data.put("error", message != null ? message : "unknown");
|
||||
instance.notifyListeners("registrationError", data);
|
||||
}
|
||||
|
||||
static void emitPushReceived(JSObject notification) {
|
||||
if (instance == null) return;
|
||||
instance.notifyListeners("pushNotificationReceived", notification);
|
||||
}
|
||||
|
||||
static void emitPushAction(JSObject action) {
|
||||
if (instance == null) return;
|
||||
instance.notifyListeners("pushNotificationActionPerformed", action);
|
||||
}
|
||||
|
||||
private static String permissionStateToString(com.getcapacitor.PermissionState state) {
|
||||
if (state == com.getcapacitor.PermissionState.GRANTED) return "granted";
|
||||
if (state == com.getcapacitor.PermissionState.DENIED) return "denied";
|
||||
return "prompt";
|
||||
}
|
||||
|
||||
private boolean ensurePlayServices(PluginCall call) {
|
||||
try {
|
||||
int code = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(getContext());
|
||||
if (code != ConnectionResult.SUCCESS) {
|
||||
call.reject("Google Play 서비스 필요 (코드 " + code + ")");
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
call.reject("Play 서비스 확인 실패: " + t.getMessage());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void checkPermissions(PluginCall call) {
|
||||
JSObject r = new JSObject();
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
r.put("receive", "granted");
|
||||
call.resolve(r);
|
||||
return;
|
||||
}
|
||||
r.put("receive", permissionStateToString(getPermissionState(PERM)));
|
||||
call.resolve(r);
|
||||
}
|
||||
|
||||
/** 알림 권한만 요청 — FCM register 호출 없음 */
|
||||
@PluginMethod
|
||||
public void requestPermissions(PluginCall call) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
JSObject r = new JSObject();
|
||||
r.put("receive", "granted");
|
||||
call.resolve(r);
|
||||
return;
|
||||
}
|
||||
if (getPermissionState(PERM) == com.getcapacitor.PermissionState.GRANTED) {
|
||||
JSObject r = new JSObject();
|
||||
r.put("receive", "granted");
|
||||
call.resolve(r);
|
||||
return;
|
||||
}
|
||||
call.setKeepAlive(true);
|
||||
requestPermissionForAlias(PERM, call, "onPermResult");
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void onPermResult(PluginCall call) {
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
try {
|
||||
JSObject r = new JSObject();
|
||||
r.put("receive", permissionStateToString(getPermissionState(PERM)));
|
||||
call.resolve(r);
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "onPermResult", t);
|
||||
call.reject(t.getMessage());
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
/** 권한 허용 후 별도 호출 — Firebase getToken */
|
||||
@PluginMethod
|
||||
public void register(PluginCall call) {
|
||||
call.setKeepAlive(true);
|
||||
|
||||
if (getActivity() == null || getActivity().isFinishing()) {
|
||||
call.reject("Activity unavailable");
|
||||
return;
|
||||
}
|
||||
if (!ensurePlayServices(call)) return;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
&& getPermissionState(PERM) != com.getcapacitor.PermissionState.GRANTED) {
|
||||
call.reject("알림 권한이 필요합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
Executors.newSingleThreadExecutor().execute(() -> {
|
||||
try {
|
||||
if (FirebaseApp.getApps(getContext()).isEmpty()) {
|
||||
FirebaseApp.initializeApp(getContext());
|
||||
}
|
||||
FirebaseMessaging.getInstance().setAutoInitEnabled(true);
|
||||
FirebaseMessaging.getInstance()
|
||||
.getToken()
|
||||
.addOnCompleteListener(task -> {
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
if (!task.isSuccessful()) {
|
||||
Exception ex = task.getException();
|
||||
String msg = ex != null ? ex.getMessage() : "getToken failed";
|
||||
Log.w(TAG, "getToken failed", ex);
|
||||
emitRegistrationError(msg);
|
||||
call.reject(msg);
|
||||
return;
|
||||
}
|
||||
String token = task.getResult();
|
||||
Log.i(TAG, "FCM token ok");
|
||||
emitRegistration(token);
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("value", token);
|
||||
call.resolve(ret);
|
||||
});
|
||||
});
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "register", t);
|
||||
new Handler(Looper.getMainLooper()).post(() ->
|
||||
call.reject(t.getMessage() != null ? t.getMessage() : "register failed")
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void unregister(PluginCall call) {
|
||||
try {
|
||||
if (!FirebaseApp.getApps(getContext()).isEmpty()) {
|
||||
FirebaseMessaging.getInstance().setAutoInitEnabled(false);
|
||||
FirebaseMessaging.getInstance().deleteToken();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "unregister", t);
|
||||
}
|
||||
call.resolve();
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,37 @@ import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
|
||||
/**
|
||||
* Capacitor 메인 액티비티 — FCM 알림 채널 생성 (goldenApp / Android 8+)
|
||||
* Capacitor 메인 액티비티 — GoldenFcm 플러그인 + 알림 채널
|
||||
*/
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
private static final String TAG = "GoldenChart";
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
registerPlugin(GoldenFcmPlugin.class);
|
||||
initFirebaseSafe();
|
||||
createNotificationChannel();
|
||||
}
|
||||
|
||||
private void initFirebaseSafe() {
|
||||
try {
|
||||
if (FirebaseApp.getApps(this).isEmpty()) {
|
||||
FirebaseApp.initializeApp(this);
|
||||
Log.i(TAG, "FirebaseApp initialized");
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "FirebaseApp init failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
String channelId = getString(R.string.default_notification_channel_id);
|
||||
|
||||
@@ -11,9 +11,6 @@ project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacit
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-splash-screen'
|
||||
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android')
|
||||
|
||||
|
||||
@@ -10,17 +10,15 @@ const config: CapacitorConfig = {
|
||||
iosScheme: 'capacitor',
|
||||
},
|
||||
plugins: {
|
||||
// 네이티브 HTTP + AbortSignal 조합이 로그인 fetch 를 끊는 경우가 있어 WebView fetch 사용
|
||||
CapacitorHttp: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
SplashScreen: {
|
||||
launchAutoHide: true,
|
||||
backgroundColor: '#0f0f23',
|
||||
showSpinner: false,
|
||||
},
|
||||
PushNotifications: {
|
||||
presentationOptions: ['badge', 'sound', 'alert'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "353782909389",
|
||||
"project_id": "goldenanalysisapp",
|
||||
"storage_bucket": "goldenanalysisapp.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84",
|
||||
"android_client_info": {
|
||||
"package_name": "com.golden.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:353782909389:android:62e71bbb669c64bbc75c84",
|
||||
"android_client_info": {
|
||||
"package_name": "com.goldenchart.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
+1
-2
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"cap:sync": "npm run build && npx cap sync",
|
||||
"cap:sync": "npm run build && bash ../scripts/ensure-android-google-services.sh && npx cap sync",
|
||||
"cap:open:android": "npx cap open android",
|
||||
"cap:open:ios": "npx cap open ios"
|
||||
},
|
||||
@@ -18,7 +18,6 @@
|
||||
"@capacitor/haptics": "^7.0.0",
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/push-notifications": "^7.0.0",
|
||||
"@capacitor/splash-screen": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@goldenchart/shared": "*",
|
||||
|
||||
+2
-28
@@ -7,7 +7,6 @@ import { NavigationProvider, useNavigation } from './contexts/NavigationContext'
|
||||
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
|
||||
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
|
||||
import TabBar, { type TabId } from './components/TabBar';
|
||||
import { initFcmPush, type FcmPayload } from './services/fcm';
|
||||
import LiveSignalBridge from './components/LiveSignalBridge';
|
||||
import LoginScreen from './screens/LoginScreen';
|
||||
import '@frontend/styles/splashScreen.css';
|
||||
@@ -37,37 +36,12 @@ function ToastStack() {
|
||||
|
||||
function MainApp() {
|
||||
const { tab, setTab, openVirtualFocus, goVirtualList, goNotifyList } = useNavigation();
|
||||
const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
|
||||
const { unreadCount } = useTradeNotification();
|
||||
const { sessionKey } = useAuth();
|
||||
const { settings, isLoaded } = useAppSettings(sessionKey);
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
void initFcmPush({
|
||||
onForeground: (_title, _body, data) => {
|
||||
if (data?.market && data?.signalType) {
|
||||
addNotification({
|
||||
market: data.market,
|
||||
signalType: data.signalType === 'SELL' ? 'SELL' : 'BUY',
|
||||
price: Number(data.price) || 0,
|
||||
candleTime: Math.floor(Date.now() / 1000),
|
||||
dbId: data.signalId ? Number(data.signalId) : undefined,
|
||||
});
|
||||
} else {
|
||||
void refreshHistory();
|
||||
}
|
||||
},
|
||||
onTap: (data: FcmPayload) => {
|
||||
if (data.market) {
|
||||
setTab('virtual');
|
||||
openVirtualFocus(data.market);
|
||||
} else {
|
||||
setTab('notifications');
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [isLoaded, addNotification, refreshHistory, setTab, openVirtualFocus]);
|
||||
/** FCM/알림 권한: 로그인·메인 진입 시 호출하지 않음 — 설정 버튼에서만 */
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type LoginResponse,
|
||||
} from '../lib/shared';
|
||||
import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings';
|
||||
import { ensureMobileApiBase } from '../lib/ensureMobileApiBase';
|
||||
|
||||
function normalizeRole(role: string): AuthSession['role'] {
|
||||
return role === 'ADMIN' ? 'ADMIN' : 'USER';
|
||||
@@ -52,18 +53,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [authUser, setAuthUser] = useState<AuthSession | null>(null);
|
||||
const [guestMode, setGuestMode] = useState(false);
|
||||
const [sessionKey, setSessionKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await initStorage();
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const savedApi = storageGetSync('gc_api_base_url');
|
||||
if (savedApi && /localhost|127\.0\.0\.1/i.test(savedApi)) {
|
||||
if (
|
||||
savedApi
|
||||
&& (/localhost|127\.0\.0\.1/i.test(savedApi) || /^https:/i.test(savedApi))
|
||||
) {
|
||||
await storageRemove('gc_api_base_url');
|
||||
}
|
||||
}
|
||||
refreshApiBaseFromStorage();
|
||||
ensureMobileApiBase();
|
||||
const stored = getAuthSession();
|
||||
if (stored) {
|
||||
try {
|
||||
@@ -99,20 +102,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setAuthSession(session);
|
||||
setAuthUser(session);
|
||||
setGuestMode(false);
|
||||
setSessionKey(k => k + 1);
|
||||
invalidateAppSettingsCache();
|
||||
void reloadAppSettingsCache().finally(() => {
|
||||
setSessionKey(k => k + 1);
|
||||
});
|
||||
void reloadAppSettingsCache();
|
||||
}, []);
|
||||
|
||||
const handleGuestEnter = useCallback(() => {
|
||||
void clearAuthSession();
|
||||
setAuthUser(null);
|
||||
setGuestMode(true);
|
||||
setSessionKey(k => k + 1);
|
||||
invalidateAppSettingsCache();
|
||||
void reloadAppSettingsCache().finally(() => {
|
||||
setSessionKey(k => k + 1);
|
||||
});
|
||||
void reloadAppSettingsCache();
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { App } from '@capacitor/app';
|
||||
|
||||
/** 네이티브: build.gradle versionName / versionCode · 웹: dev */
|
||||
export function useAppVersion(): string {
|
||||
const [label, setLabel] = useState('…');
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const { version, build } = await App.getInfo();
|
||||
setLabel(build ? `${version} (build ${build})` : version);
|
||||
return;
|
||||
} catch {
|
||||
/* fallback */
|
||||
}
|
||||
}
|
||||
setLabel(
|
||||
(import.meta as ImportMeta & { env?: { VITE_APP_VERSION?: string } }).env
|
||||
?.VITE_APP_VERSION ?? 'web',
|
||||
);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import {
|
||||
normalizeApiBaseUrl,
|
||||
PRODUCTION_API_BASE,
|
||||
refreshApiBaseFromStorage,
|
||||
setApiBase,
|
||||
storageGetSync,
|
||||
} from './shared';
|
||||
|
||||
/** 네이티브 APK — 잘못된 API(localhost·https) 제거 후 exdev 고정 */
|
||||
export function ensureMobileApiBase(): string {
|
||||
refreshApiBaseFromStorage();
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return storageGetSync('gc_api_base_url') ?? PRODUCTION_API_BASE;
|
||||
}
|
||||
const saved = normalizeApiBaseUrl(storageGetSync('gc_api_base_url'));
|
||||
const base = saved ?? PRODUCTION_API_BASE;
|
||||
setApiBase(base);
|
||||
return base;
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구)
|
||||
* 모바일 로그인 — SplashScreen UI + 느린 서버(exdev) 대응
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { loginUser, type LoginResponse } from '../lib/shared';
|
||||
import {
|
||||
loginUser,
|
||||
initStorage,
|
||||
type LoginResponse,
|
||||
} from '../lib/shared';
|
||||
import { ensureMobileApiBase } from '../lib/ensureMobileApiBase';
|
||||
import { useAppVersion } from '../hooks/useAppVersion';
|
||||
import '@frontend/styles/splashScreen.css';
|
||||
|
||||
const DEFAULT_USERNAME = 'admin';
|
||||
const DEFAULT_PASSWORD = 'admin';
|
||||
const APP_VERSION = '1.2';
|
||||
|
||||
interface Props {
|
||||
onLoginSuccess: (res: LoginResponse) => void;
|
||||
@@ -15,23 +20,38 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
const appVersion = useAppVersion();
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [slowHint, setSlowHint] = useState(false);
|
||||
const [storageReady, setStorageReady] = useState(false);
|
||||
const [apiBase, setApiBase] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await initStorage();
|
||||
setApiBase(ensureMobileApiBase());
|
||||
setStorageReady(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setSlowHint(false);
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(() => setSlowHint(true), 5000);
|
||||
const t = window.setTimeout(() => setSlowHint(true), 4000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [loading]);
|
||||
|
||||
const submitLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!storageReady) {
|
||||
setError('앱 초기화 중입니다. 잠시 후 다시 시도하세요.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -60,7 +80,7 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
disabled={loading}
|
||||
disabled={loading || !storageReady}
|
||||
placeholder="ID"
|
||||
/>
|
||||
<input
|
||||
@@ -69,22 +89,29 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
disabled={loading}
|
||||
disabled={loading || !storageReady}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{error && <p className="splash-error">{error}</p>}
|
||||
{slowHint && loading && !error && (
|
||||
<p className="splash-error" style={{ opacity: 0.85 }}>
|
||||
서버 응답이 느립니다. Wi‑Fi·모바일 데이터를 확인해 주세요…
|
||||
exdev 서버가 느립니다. 첫 로그인은 <strong>최대 3분</strong> 걸릴 수 있습니다.
|
||||
버튼이 「로그인 중…」인 동안 기다려 주세요.
|
||||
<br />
|
||||
<span style={{ fontSize: 11 }}>API: {apiBase || '…'}</span>
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="splash-login-btn" disabled={loading}>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
<button
|
||||
type="submit"
|
||||
className="splash-login-btn"
|
||||
disabled={loading || !storageReady}
|
||||
>
|
||||
{!storageReady ? '준비 중…' : loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="splash-version">
|
||||
버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot
|
||||
APK {appVersion} · API {apiBase.replace(/^https?:\/\//, '')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
||||
import { initFcmPush, checkPushPermission } from '../../services/fcm';
|
||||
import { API_BASE } from '../../lib/shared';
|
||||
import { API_BASE, normalizeApiBaseUrl, PRODUCTION_API_BASE } from '../../lib/shared';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { authUser, guestMode, sessionKey, handleLogout } = useAuth();
|
||||
@@ -19,11 +18,11 @@ export default function SettingsScreen() {
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
const [apiUrl, setApiUrl] = useState(API_BASE);
|
||||
const [fcmAvailable, setFcmAvailable] = useState(false);
|
||||
const [pushPerm, setPushPerm] = useState<string>('prompt');
|
||||
const [pushPerm, setPushPerm] = useState<string>('—');
|
||||
const [fcmBusy, setFcmBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
||||
void checkPushPermission().then(setPushPerm);
|
||||
}, []);
|
||||
|
||||
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
|
||||
@@ -133,12 +132,17 @@ export default function SettingsScreen() {
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="FCM 푸시">
|
||||
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
|
||||
<SettingRow
|
||||
label="푸시 알림"
|
||||
description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정 · 1.9+ APK 필요'}
|
||||
>
|
||||
<Toggle
|
||||
checked={!!defaults.fcmPushEnabled}
|
||||
onChange={v => {
|
||||
patch({ fcmPushEnabled: v });
|
||||
if (v) void initFcmPush().then(() => checkPushPermission().then(setPushPerm));
|
||||
if (!v) {
|
||||
void import('../../services/fcm').then(m => m.unregisterFcmPush());
|
||||
}
|
||||
}}
|
||||
label="FCM 푸시"
|
||||
/>
|
||||
@@ -146,6 +150,56 @@ export default function SettingsScreen() {
|
||||
<SettingRow label="권한 상태">
|
||||
<span className="text-muted">{pushPerm}</span>
|
||||
</SettingRow>
|
||||
{defaults.fcmPushEnabled && (
|
||||
<>
|
||||
<SettingRow label="① 알림 권한" description="허용 후 앱이 꺼지면 ②는 나중에">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '8px 12px', minHeight: 36 }}
|
||||
disabled={fcmBusy}
|
||||
onClick={() => {
|
||||
setFcmBusy(true);
|
||||
void (async () => {
|
||||
const fcm = await import('../../services/fcm');
|
||||
const ok = await fcm.requestNotificationPermissionOnly();
|
||||
setPushPerm(await fcm.checkPushPermission());
|
||||
if (!ok) {
|
||||
window.alert('알림 권한이 거부되었습니다.');
|
||||
}
|
||||
})().finally(() => setFcmBusy(false));
|
||||
}}
|
||||
>
|
||||
알림 권한 허용
|
||||
</button>
|
||||
</SettingRow>
|
||||
<SettingRow label="② FCM 등록" description="① 완료 후 누르세요 (APK 2.5+)">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '8px 12px', minHeight: 36 }}
|
||||
disabled={fcmBusy}
|
||||
onClick={() => {
|
||||
setFcmBusy(true);
|
||||
void (async () => {
|
||||
const fcm = await import('../../services/fcm');
|
||||
const ok = await fcm.registerFcmTokenOnly();
|
||||
setPushPerm(await fcm.checkPushPermission());
|
||||
if (!ok) {
|
||||
window.alert(
|
||||
'FCM 등록 실패.\n'
|
||||
+ '① 알림 권한 허용 후\n'
|
||||
+ '② APK 2.5 재설치',
|
||||
);
|
||||
}
|
||||
})().finally(() => setFcmBusy(false));
|
||||
}}
|
||||
>
|
||||
FCM 토큰 등록
|
||||
</button>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingRow label="테스트 발송">
|
||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void sendFcmTest()}>테스트</button>
|
||||
</SettingRow>
|
||||
@@ -160,7 +214,18 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="적용">
|
||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px' }} onClick={() => { setApiBase(apiUrl); window.location.reload(); }}>저장</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => {
|
||||
const next = normalizeApiBaseUrl(apiUrl) ?? PRODUCTION_API_BASE;
|
||||
setApiBase(next);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</SettingRow>
|
||||
<SettingRow label="Device ID">
|
||||
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
session: VirtualSessionConfig;
|
||||
strategies: StrategyDto[];
|
||||
snapshot?: VirtualIndicatorSnapshot;
|
||||
signalLoading?: boolean;
|
||||
liveConnected: boolean;
|
||||
onBack: () => void;
|
||||
onTrade: (side: TradeSide) => void;
|
||||
@@ -20,6 +21,7 @@ interface Props {
|
||||
export default function VirtualFocusScreen({
|
||||
target,
|
||||
snapshot,
|
||||
signalLoading,
|
||||
liveConnected,
|
||||
onBack,
|
||||
onTrade,
|
||||
@@ -72,6 +74,11 @@ export default function VirtualFocusScreen({
|
||||
</div>
|
||||
|
||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>조건 목록</h3>
|
||||
{signalLoading && !(snapshot?.rows?.length) && (
|
||||
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
|
||||
지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)
|
||||
</p>
|
||||
)}
|
||||
<div className="stack-list">
|
||||
{(snapshot?.rows ?? []).map(row => (
|
||||
<div key={row.id} className="card mobile-history-row">
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
globalStrategyId: number | null;
|
||||
strategies: StrategyDto[];
|
||||
snapshot?: VirtualIndicatorSnapshot;
|
||||
signalLoading?: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
onDetail: () => void;
|
||||
onTrade: (side: TradeSide) => void;
|
||||
@@ -23,6 +24,7 @@ export default function VirtualTargetListRow({
|
||||
globalStrategyId,
|
||||
strategies,
|
||||
snapshot,
|
||||
signalLoading,
|
||||
liveStatus,
|
||||
onDetail,
|
||||
onTrade,
|
||||
@@ -42,7 +44,10 @@ export default function VirtualTargetListRow({
|
||||
<span>{target.koreanName ?? target.market}</span>
|
||||
</div>
|
||||
<div className="text-muted mobile-list-row-meta">
|
||||
{target.market} · {strategyName} · {matchPct.toFixed(0)}%
|
||||
{target.market} · {strategyName}
|
||||
{signalLoading && !snapshot?.rows?.length
|
||||
? ' · 지표 수집 중…'
|
||||
: ` · ${matchPct.toFixed(0)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-list-row-pin">
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function VirtualTradingScreen() {
|
||||
trades,
|
||||
loading,
|
||||
snapshots,
|
||||
snapshotLoadingByMarket,
|
||||
liveStatusByMarket,
|
||||
handleStart,
|
||||
handleStop,
|
||||
@@ -61,6 +62,7 @@ export default function VirtualTradingScreen() {
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
snapshot={snap}
|
||||
signalLoading={snapshotLoadingByMarket[market]}
|
||||
liveConnected={liveConnected}
|
||||
onBack={goVirtualList}
|
||||
onTrade={side => goVirtualTrade(market, side)}
|
||||
@@ -175,6 +177,7 @@ export default function VirtualTradingScreen() {
|
||||
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
|
||||
const snapKey = `${t.market}:${strategyId ?? ''}`;
|
||||
const snap = snapshots[snapKey] ?? snapshots[t.market];
|
||||
const signalLoading = snapshotLoadingByMarket[t.market];
|
||||
return (
|
||||
<VirtualTargetListRow
|
||||
key={t.market}
|
||||
@@ -182,6 +185,7 @@ export default function VirtualTradingScreen() {
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
strategies={strategies}
|
||||
snapshot={snap}
|
||||
signalLoading={signalLoading}
|
||||
liveStatus={liveStatusByMarket[t.market]}
|
||||
onDetail={() => goVirtualDetail(t.market)}
|
||||
onTrade={side => goVirtualTrade(t.market, side)}
|
||||
|
||||
+113
-58
@@ -1,12 +1,15 @@
|
||||
import {
|
||||
registerFcmToken,
|
||||
deleteFcmToken,
|
||||
storageGet,
|
||||
storageSet,
|
||||
storageRemove,
|
||||
} from '../lib/shared';
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||
|
||||
/** AndroidManifest default_notification_channel_id 와 동일 */
|
||||
export const FCM_CHANNEL_ID = 'goldenchart_trade_signals';
|
||||
export const FCM_USER_OPT_IN_KEY = 'gc_fcm_user_opt_in';
|
||||
export const FCM_REGISTERED_KEY = 'gc_fcm_registered';
|
||||
|
||||
export interface FcmPayload {
|
||||
type?: string;
|
||||
@@ -21,93 +24,145 @@ type FcmHandlers = {
|
||||
onTap?: (data: FcmPayload) => void;
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
let listenersAttached = false;
|
||||
|
||||
async function ensureAndroidNotificationChannel(): Promise<void> {
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
try {
|
||||
await PushNotifications.createChannel({
|
||||
id: FCM_CHANNEL_ID,
|
||||
name: '매매 시그널 알림',
|
||||
description: '전략 조건 일치 시 매수·매도 푸시',
|
||||
importance: 5,
|
||||
visibility: 1,
|
||||
vibration: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[FCM] createChannel failed', e);
|
||||
}
|
||||
interface GoldenFcmPlugin {
|
||||
checkPermissions(): Promise<{ receive: string }>;
|
||||
requestPermissions(): Promise<{ receive: string }>;
|
||||
register(): Promise<{ value: string }>;
|
||||
unregister(): Promise<void>;
|
||||
addListener(
|
||||
event: 'registration',
|
||||
listener: (data: { value: string }) => void,
|
||||
): Promise<{ remove: () => void }>;
|
||||
addListener(
|
||||
event: 'registrationError',
|
||||
listener: (data: { error?: string }) => void,
|
||||
): Promise<{ remove: () => void }>;
|
||||
addListener(
|
||||
event: 'pushNotificationReceived',
|
||||
listener: (data: { title?: string; body?: string; data?: FcmPayload }) => void,
|
||||
): Promise<{ remove: () => void }>;
|
||||
addListener(
|
||||
event: 'pushNotificationActionPerformed',
|
||||
listener: (data: { notification?: { data?: FcmPayload } }) => void,
|
||||
): Promise<{ remove: () => void }>;
|
||||
}
|
||||
|
||||
function attachListeners(handlers: FcmHandlers): void {
|
||||
if (listenersAttached) return;
|
||||
const GoldenFcm = registerPlugin<GoldenFcmPlugin>('GoldenFcm');
|
||||
|
||||
let tokenRegistered = false;
|
||||
let listenersAttached = false;
|
||||
|
||||
async function userOptedIn(): Promise<boolean> {
|
||||
return (await storageGet(FCM_USER_OPT_IN_KEY)) === '1';
|
||||
}
|
||||
|
||||
export async function markFcmUserOptIn(): Promise<void> {
|
||||
await storageSet(FCM_USER_OPT_IN_KEY, '1');
|
||||
}
|
||||
|
||||
async function attachListeners(handlers: {
|
||||
onForeground?: (title: string, body: string, data?: FcmPayload) => void;
|
||||
onTap?: (data: FcmPayload) => void;
|
||||
}): Promise<void> {
|
||||
if (listenersAttached || Capacitor.getPlatform() !== 'android') return;
|
||||
listenersAttached = true;
|
||||
|
||||
void PushNotifications.addListener('registration', async token => {
|
||||
await GoldenFcm.addListener('registration', async data => {
|
||||
try {
|
||||
await registerFcmToken(token.value);
|
||||
console.info('[FCM] token registered with server');
|
||||
await registerFcmToken(data.value);
|
||||
await storageSet(FCM_REGISTERED_KEY, '1');
|
||||
} catch (e) {
|
||||
console.warn('[FCM] token register failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
void PushNotifications.addListener('registrationError', err => {
|
||||
await GoldenFcm.addListener('registrationError', err => {
|
||||
console.warn('[FCM] registration error', err);
|
||||
void storageRemove(FCM_REGISTERED_KEY);
|
||||
});
|
||||
|
||||
void PushNotifications.addListener('pushNotificationReceived', notification => {
|
||||
await GoldenFcm.addListener('pushNotificationReceived', notification => {
|
||||
const title = notification.title ?? 'GoldenChart';
|
||||
const body = notification.body ?? '';
|
||||
handlers.onForeground?.(title, body, notification.data as FcmPayload);
|
||||
});
|
||||
|
||||
void PushNotifications.addListener('pushNotificationActionPerformed', action => {
|
||||
handlers.onTap?.(action.notification.data as FcmPayload);
|
||||
await GoldenFcm.addListener('pushNotificationActionPerformed', action => {
|
||||
handlers.onTap?.(action.notification?.data as FcmPayload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 토큰 등록 (로그인 후 호출 — 서버 fcm_push_enabled 와 별개로 토큰만 등록)
|
||||
*/
|
||||
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
console.info('[FCM] Web dev — native push skipped');
|
||||
/** ① 알림 권한만 (FCM 미호출 — 허용 직후 크래시 방지) */
|
||||
export async function requestNotificationPermissionOnly(): Promise<boolean> {
|
||||
if (Capacitor.getPlatform() !== 'android') return false;
|
||||
await markFcmUserOptIn();
|
||||
const perm = await GoldenFcm.checkPermissions();
|
||||
if (perm.receive === 'granted') return true;
|
||||
const req = await GoldenFcm.requestPermissions();
|
||||
return req.receive === 'granted';
|
||||
}
|
||||
|
||||
/** ② 권한 허용 후 FCM 토큰 등록 */
|
||||
export async function registerFcmTokenOnly(): Promise<boolean> {
|
||||
if (Capacitor.getPlatform() !== 'android') return false;
|
||||
if (!(await userOptedIn())) return false;
|
||||
|
||||
const perm = await GoldenFcm.checkPermissions();
|
||||
if (perm.receive !== 'granted') return false;
|
||||
|
||||
try {
|
||||
await attachListeners({});
|
||||
if (tokenRegistered) return true;
|
||||
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
const result = await GoldenFcm.register();
|
||||
tokenRegistered = true;
|
||||
if (result?.value) {
|
||||
await registerFcmToken(result.value);
|
||||
await storageSet(FCM_REGISTERED_KEY, '1');
|
||||
}
|
||||
return !!result?.value;
|
||||
} catch (e) {
|
||||
console.warn('[FCM] register failed', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await ensureAndroidNotificationChannel();
|
||||
attachListeners(handlers);
|
||||
export async function isFcmRegisteredOnDevice(): Promise<boolean> {
|
||||
return (await storageGet(FCM_REGISTERED_KEY)) === '1';
|
||||
}
|
||||
|
||||
const perm = await PushNotifications.checkPermissions();
|
||||
if (perm.receive !== 'granted') {
|
||||
const req = await PushNotifications.requestPermissions();
|
||||
if (req.receive !== 'granted') {
|
||||
console.warn('[FCM] notification permission denied');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialized) {
|
||||
await PushNotifications.register();
|
||||
initialized = true;
|
||||
}
|
||||
return true;
|
||||
/** @deprecated — requestNotificationPermissionOnly + registerFcmTokenOnly 사용 */
|
||||
export async function initFcmPush(): Promise<boolean> {
|
||||
const ok = await requestNotificationPermissionOnly();
|
||||
if (!ok) return false;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
return registerFcmTokenOnly();
|
||||
}
|
||||
|
||||
export async function unregisterFcmPush(): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
try {
|
||||
await deleteFcmToken();
|
||||
if (Capacitor.getPlatform() === 'android' && (await userOptedIn())) {
|
||||
await GoldenFcm.unregister();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
initialized = false;
|
||||
tokenRegistered = false;
|
||||
listenersAttached = false;
|
||||
await storageRemove(FCM_REGISTERED_KEY);
|
||||
await storageRemove(FCM_USER_OPT_IN_KEY);
|
||||
}
|
||||
|
||||
export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt'> {
|
||||
if (!Capacitor.isNativePlatform()) return 'prompt';
|
||||
const perm = await PushNotifications.checkPermissions();
|
||||
if (perm.receive === 'granted') return 'granted';
|
||||
if (perm.receive === 'denied') return 'denied';
|
||||
return 'prompt';
|
||||
export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unknown'> {
|
||||
if (!Capacitor.isNativePlatform()) return 'unknown';
|
||||
try {
|
||||
if (Capacitor.getPlatform() !== 'android') return 'unknown';
|
||||
const perm = await GoldenFcm.checkPermissions();
|
||||
if (perm.receive === 'granted') return 'granted';
|
||||
if (perm.receive === 'denied') return 'denied';
|
||||
return 'prompt';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const frontendRoot = path.resolve(__dirname, '../frontend/src');
|
||||
const sharedRoot = path.resolve(__dirname, '../packages/shared/src');
|
||||
|
||||
export default defineConfig({
|
||||
envDir: __dirname,
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', 'lightweight-charts'],
|
||||
|
||||
Reference in New Issue
Block a user