generated from ttipo/project-base
feat(flutter): 새 메일 알림(FCM) · 최소 판 확인 · 설정 탭
앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다. - firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다. google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다). - 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다. - 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다 (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다). - 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다. - 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다). - 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다. - 시험 932 → 1,056. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,3 +42,22 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림").
|
||||
// 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다.
|
||||
// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게.
|
||||
// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build,
|
||||
// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다.
|
||||
// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다.
|
||||
val firebaseConfig = file("google-services.json")
|
||||
if (firebaseConfig.exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
} else {
|
||||
gradle.taskGraph.whenReady {
|
||||
if (allTasks.any { it.project == project && it.name.contains("Release") }) {
|
||||
throw GradleException(
|
||||
"android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<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">
|
||||
<!-- API 서버에 닿으려면 release 판에도 있어야 한다 (debug · profile 매니페스트에만 있던 것을 옮겼다) -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!--
|
||||
새 메일 알림 — 안드로이드 13 이상은 알림을 보이려면 이 권한을 직원이 허락해야 한다(firebase_messaging 매니페스트에도 있다 — 뜻을 보이려고 적는다).
|
||||
묻는 창은 [알림 켜기]를 누를 때만 띄운다(core/push/push_controller.dart)
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- 광고 ID 를 쓰지 않는다 — 라이브러리 매니페스트가 넣어도 뺀다(Play 의 광고 ID 선언을 "아니요"로) -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/>
|
||||
<!--
|
||||
백업 끔: 리프레시 토큰은 flutter_secure_storage 가 이 기기의 Keystore 키로 암호화해 둔다.
|
||||
Google 드라이브 백업 · 기기 옮기기로 다른 기기에 복원되면 키가 없어 읽지 못하고(플러그인 README),
|
||||
@@ -49,6 +57,24 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/downloaded_file_paths" />
|
||||
</provider>
|
||||
<!--
|
||||
새 메일 알림(FCM) — 앱이 뒤에 있거나 꺼져 있으면 FCM 이 직접 띄운다. 서버 메시지에 없는 값을 여기서 채운다.
|
||||
채널: 짝 — NewMailNotifications.kt(채널을 만든다) · 서버 channel_id. 아이콘 · 색은 서버가 싣지 않아 늘 이 기본값이다
|
||||
(색: 짝 — Dart AppColors.brand #0E7C86. 작은 아이콘을 바꾸려면 drawable 의 ic_stat_ara 그림만 바꾼다 — flutter/README "앱 아이콘 · 알림 아이콘")
|
||||
-->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/new_mail_channel_id" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_ara" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/notification_accent" />
|
||||
<!-- 로그인 전에는 FCM 에 등록하지 않는다 — 앱이 register() 를 부를 때만(core/push/push_controller.dart) -->
|
||||
<meta-data
|
||||
android:name="firebase_messaging_auto_init_enabled"
|
||||
android:value="false" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -28,6 +28,8 @@ import java.util.Locale
|
||||
* 고른 뒤 Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계)로 미리 걸러 넣지 않을 파일은 읽지 않는다 — 판정은 Dart 가 다시 한다.
|
||||
* 결과는 `{outcome, files: [{name, size, status, path}]}` — 짝: Dart `PickResult.parse` · `PickedFile.fromChannel`.
|
||||
* 경로 · 주소 · 파일 이름은 로그에 남기지 않는다.
|
||||
*
|
||||
* 알림 채널(`kr.it.arasystem.app/notifications`)은 [NewMailNotifications] 가 맡는다 — 새 메일 알림 채널도 화면을 만들 때 그것이 만든다.
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
/** 고르는 창이 열려 있는 동안 기다리는 답 — 한 번에 하나 */
|
||||
@@ -49,6 +51,11 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
// 새 메일 알림 — 채널은 첫 알림보다 먼저 있어야 한다(서버의 기기 줄은 앱을 연 뒤에야 생긴다)
|
||||
NewMailNotifications(this).apply {
|
||||
createChannel()
|
||||
register(flutterEngine.dartExecutor.binaryMessenger)
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package kr.it.arasystem.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* 새 메일 알림의 채널과 알림창 — 채널 `kr.it.arasystem.app/notifications`.
|
||||
*
|
||||
* **짝: Dart `core/push/notification_system.dart` 의 `PlatformNotificationSystem`** — 채널 이름 · 부르는 이름 · 답의 칸과 값(한쪽을 고치면 둘 다).
|
||||
*
|
||||
* - 알림 채널 `new_mail`(짝: 서버 `channel_id` · 매니페스트의 기본 채널 — 값은 `R.string.new_mail_channel_id`)을 앱을 열 때마다 만든다.
|
||||
* 중요도 HIGH(소리 · 머리 알림) — 한 번 만든 채널의 중요도는 앱이 바꾸지 못한다(직원이 바꾼다). 이름 · 설명은 다시 만들 때 바뀐다.
|
||||
* 알림은 앱이 뒤에 있거나 꺼져 있을 때 FCM 이 이 채널로 띄운다(앱 코드는 돌지 않는다).
|
||||
* - `status` → `{appEnabled, channelEnabled, sdkInt}` — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다.
|
||||
* - `openSettings` → 새 메일 채널만 꺼졌으면 그 채널 설정, 아니면 이 앱의 알림 설정(24 · 25 는 앱 정보). 답은 opened · failed.
|
||||
* - `clearNewMail` → 알림창의 "새 메일 N통"(tag `new-mail` — 짝: 서버 tag. FCM 은 tag 를 주면 id 0 으로 띄운다)만 지운다.
|
||||
*
|
||||
* 주소 · 토큰 · FID 는 다루지도 로그에 남기지도 않는다.
|
||||
*/
|
||||
class NewMailNotifications(private val activity: Activity) {
|
||||
private val manager = NotificationManagerCompat.from(activity)
|
||||
|
||||
private val channelId: String
|
||||
get() = activity.getString(R.string.new_mail_channel_id)
|
||||
|
||||
/** 채널을 만든다 — 이미 있으면 이름 · 설명만 바뀐다(채널이 없는 판은 아무것도 하지 않는다) */
|
||||
fun createChannel() {
|
||||
val channel = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(activity.getString(R.string.new_mail_channel_name))
|
||||
.setDescription(activity.getString(R.string.new_mail_channel_description))
|
||||
.build()
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun register(messenger: BinaryMessenger) {
|
||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
STATUS_METHOD -> result.success(status())
|
||||
OPEN_SETTINGS_METHOD -> result.success(openSettings())
|
||||
CLEAR_NEW_MAIL_METHOD -> {
|
||||
clearNewMail()
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(): Map<String, Any> = mapOf(
|
||||
KEY_APP_ENABLED to manager.areNotificationsEnabled(),
|
||||
KEY_CHANNEL_ENABLED to isChannelEnabled(),
|
||||
KEY_SDK_INT to Build.VERSION.SDK_INT,
|
||||
)
|
||||
|
||||
/** 새 메일 채널이 켜져 있다 — 채널이 없는 판(24 · 25)이거나 채널을 아직 만들지 않았으면 참 */
|
||||
private fun isChannelEnabled(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true
|
||||
val channel = manager.getNotificationChannelCompat(channelId) ?: return true
|
||||
return channel.importance != NotificationManagerCompat.IMPORTANCE_NONE
|
||||
}
|
||||
|
||||
/** 휴대폰 설정을 연다 — 가장 알맞은 화면부터, 열리지 않으면 다음 화면 */
|
||||
private fun openSettings(): String {
|
||||
val packageName = activity.packageName
|
||||
val intents = buildList {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (manager.areNotificationsEnabled() && !isChannelEnabled()) {
|
||||
add(
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId),
|
||||
)
|
||||
}
|
||||
add(
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName),
|
||||
)
|
||||
}
|
||||
add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)))
|
||||
}
|
||||
for (intent in intents) {
|
||||
try {
|
||||
activity.startActivity(intent)
|
||||
return RESULT_OPENED
|
||||
} catch (error: ActivityNotFoundException) {
|
||||
// 이 화면이 없는 기기 — 다음 화면을 연다
|
||||
} catch (error: SecurityException) {
|
||||
// 열 수 없다 — 다음 화면을 연다
|
||||
}
|
||||
}
|
||||
return RESULT_FAILED
|
||||
}
|
||||
|
||||
private fun clearNewMail() {
|
||||
manager.cancel(NEW_MAIL_TAG, FCM_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 짝: Dart `PlatformNotificationSystem.channel` */
|
||||
const val CHANNEL = "kr.it.arasystem.app/notifications"
|
||||
const val STATUS_METHOD = "status"
|
||||
const val OPEN_SETTINGS_METHOD = "openSettings"
|
||||
const val CLEAR_NEW_MAIL_METHOD = "clearNewMail"
|
||||
|
||||
// 짝: Dart `PlatformNotificationSystem` 의 appEnabledKey · channelEnabledKey · sdkIntKey · openedResult
|
||||
const val KEY_APP_ENABLED = "appEnabled"
|
||||
const val KEY_CHANNEL_ENABLED = "channelEnabled"
|
||||
const val KEY_SDK_INT = "sdkInt"
|
||||
const val RESULT_OPENED = "opened"
|
||||
const val RESULT_FAILED = "failed"
|
||||
|
||||
/** 짝: 서버가 보내는 알림의 tag(`android.notification.tag`) — "새 메일 N통" · "알림이 멈췄습니다"가 함께 쓴다 */
|
||||
const val NEW_MAIL_TAG = "new-mail"
|
||||
|
||||
/** FCM 은 tag 를 준 알림을 id 0 으로 띄운다 */
|
||||
const val FCM_NOTIFICATION_ID = 0
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 새 메일 알림의 강조 색(알림창의 작은 아이콘 둘레 · 앱 이름) — 짝: Dart AppColors.brand(#0E7C86) -->
|
||||
<color name="notification_accent">#0E7C86</color>
|
||||
<!-- 앱 아이콘 바탕 — 남색(웹의 탭 · 홈 화면 아이콘과 같은 #0B3640 — 짝: Dart AppColors.brandSurface) -->
|
||||
<color name="ic_launcher_background">#0B3640</color>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 새 메일 알림 채널 id — 짝: 서버 channel_id · 매니페스트의 기본 채널(default_notification_channel_id) · NewMailNotifications.kt -->
|
||||
<string name="new_mail_channel_id" translatable="false">new_mail</string>
|
||||
<!--
|
||||
휴대폰 설정의 알림 카테고리에 보이는 채널 이름 · 설명 — 앱 보드 App1Push ⑫ 의 글 그대로(화면 문구다 — 디자인 README 「화면 문구」).
|
||||
채널을 다시 만들 때 이름 · 설명은 바뀐다(중요도는 바뀌지 않는다)
|
||||
-->
|
||||
<string name="new_mail_channel_name">새 메일</string>
|
||||
<string name="new_mail_channel_description">새 메일이 오면 알려 드립니다.</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user