Add Firebase push notification support and Android Mattermost integration
- Android: MainActivity, AppLifecycleTracker, Firebase Messaging Service - Android: Notification handlers (dismiss, reply, receipt delivery) - Android: Custom push notification helper and database support - Android: Drawable resources for notification actions - Android: Mipmap resources with foreground/background/round icons - iOS: Push notification service support - Flutter: Push notification background and foreground services - Web/Windows: Generated plugin registrant updates - Config: Added google-services.json for Firebase
|
|
@ -1,12 +1,14 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("kotlin-kapt")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.nomadcode.nomadcode"
|
||||
namespace = "com.tokilabs.mattermost"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
|
|
@ -21,7 +23,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.nomadcode.nomadcode"
|
||||
applicationId = "com.tokilabs.mattermost"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
|
@ -42,3 +44,27 @@ android {
|
|||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Firebase BOM (버전 통합 관리)
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
// JWT 서명 검증 (mattermost와 동일 버전)
|
||||
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-orgjson:0.12.5") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
|
||||
// HTTP 클라이언트
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// Kotlin Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// Room DB (로컬 캐시용)
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
kapt("androidx.room:room-compiler:2.6.1")
|
||||
}
|
||||
|
|
|
|||
29
android/app/google-services.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "1047648748539",
|
||||
"project_id": "mattermost-6ac08",
|
||||
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.tokilabs.mattermost"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<application
|
||||
android:label="nomadcode"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
@ -30,6 +36,27 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Firebase Messaging Service -->
|
||||
<service
|
||||
android:name=".MattermostFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- 알림 삭제 서비스 -->
|
||||
<service
|
||||
android:name=".NotificationDismissService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- 알림 답장 브로드캐스트 리시버 -->
|
||||
<receiver
|
||||
android:name=".NotificationReplyBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
package com.tokilabs.mattermost
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* 앱 포그라운드/백그라운드 상태 추적.
|
||||
* CustomPushNotification의 mAppLifecycleFacade.isAppVisible() 대응.
|
||||
*/
|
||||
object AppLifecycleTracker : Application.ActivityLifecycleCallbacks {
|
||||
var isInForeground = false
|
||||
private set
|
||||
|
||||
private var activeActivities = 0
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
activeActivities++
|
||||
isInForeground = true
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
activeActivities--
|
||||
if (activeActivities <= 0) {
|
||||
activeActivities = 0
|
||||
isInForeground = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.tokilabs.mattermost
|
||||
|
||||
import android.os.Bundle
|
||||
import com.tokilabs.mattermost.helpers.DatabaseHelper
|
||||
import com.tokilabs.mattermost.helpers.Network
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Flutter 메인 액티비티.
|
||||
* - Flutter EventChannel 등록 (네이티브 알림 이벤트 → Dart 전달)
|
||||
* - Flutter MethodChannel 등록 (Dart → 네이티브 토큰 저장 등)
|
||||
* - FlutterEngineCache에 엔진 등록 (백그라운드에서 Flutter 초기화 여부 확인용)
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_EVENT_CHANNEL = "com.tokilabs.mattermost/notifications"
|
||||
const val NOTIFICATION_METHOD_CHANNEL = "com.tokilabs.mattermost/notification_actions"
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// FlutterEngineCache에 등록 → 백그라운드 서비스에서 초기화 여부 확인 가능
|
||||
FlutterEngineCache.getInstance()
|
||||
.put(MattermostFirebaseMessagingService.FLUTTER_ENGINE_ID, flutterEngine)
|
||||
|
||||
// EventChannel: 네이티브 알림 → Dart
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, NOTIFICATION_EVENT_CHANNEL)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
MattermostFirebaseMessagingService.eventSink = events
|
||||
}
|
||||
override fun onCancel(arguments: Any?) {
|
||||
MattermostFirebaseMessagingService.eventSink = null
|
||||
}
|
||||
})
|
||||
|
||||
// MethodChannel: Dart → 네이티브 (토큰 저장, 채널 클리어 등)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, NOTIFICATION_METHOD_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"saveDeviceToken" -> {
|
||||
val token = call.argument<String>("token")
|
||||
if (token != null) {
|
||||
DatabaseHelper.getInstance()?.saveDeviceToken(token)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "token is null", null)
|
||||
}
|
||||
}
|
||||
"getDeviceToken" -> {
|
||||
result.success(DatabaseHelper.getInstance()?.getDeviceToken())
|
||||
}
|
||||
// 로그인 성공 시 Dart에서 호출 → 서버 URL + 인증 토큰 저장
|
||||
"setAuthToken" -> {
|
||||
val serverUrl = call.argument<String>("serverUrl")
|
||||
val token = call.argument<String>("token")
|
||||
val identifier = call.argument<String>("identifier")
|
||||
if (serverUrl != null && token != null) {
|
||||
Network.setToken(serverUrl, token)
|
||||
DatabaseHelper.getInstance()?.saveServerUrl(serverUrl, identifier)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "serverUrl or token is null", null)
|
||||
}
|
||||
}
|
||||
// 로그아웃 시 토큰 제거
|
||||
"clearAuthToken" -> {
|
||||
val serverUrl = call.argument<String>("serverUrl")
|
||||
if (serverUrl != null) {
|
||||
Network.clearToken(serverUrl)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "serverUrl is null", null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// 알림 탭으로 앱 진입 시 Intent 데이터를 Flutter로 전달
|
||||
intent?.extras?.let { extras ->
|
||||
val data = mutableMapOf<String, Any?>()
|
||||
for (key in extras.keySet()) {
|
||||
data[key] = extras.get(key)
|
||||
}
|
||||
if (data.isNotEmpty()) {
|
||||
MattermostFirebaseMessagingService.eventSink?.success(
|
||||
data + mapOf("type" to (data["type"] ?: "opened"), "userInteraction" to true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tokilabs.mattermost
|
||||
|
||||
import android.app.Application
|
||||
import com.tokilabs.mattermost.helpers.CustomPushNotificationHelper
|
||||
import com.tokilabs.mattermost.helpers.DatabaseHelper
|
||||
|
||||
/**
|
||||
* 앱 Application 클래스.
|
||||
* - DatabaseHelper 초기화
|
||||
* - 알림 채널 생성
|
||||
* - AppLifecycleTracker 등록
|
||||
*/
|
||||
class MainApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// 앱 생명주기 추적 (포그라운드/백그라운드 감지)
|
||||
registerActivityLifecycleCallbacks(AppLifecycleTracker)
|
||||
|
||||
// DB 초기화
|
||||
DatabaseHelper.init(this)
|
||||
|
||||
// Android 8+ 알림 채널 생성
|
||||
CustomPushNotificationHelper.createNotificationChannels(this)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
package com.tokilabs.mattermost
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.tokilabs.mattermost.helpers.CustomPushNotificationHelper
|
||||
import com.tokilabs.mattermost.helpers.Network
|
||||
import com.tokilabs.mattermost.helpers.NotificationHelper
|
||||
import com.tokilabs.mattermost.helpers.PushNotificationDataHelper
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Firebase FCM 메시지 수신 및 처리.
|
||||
* mattermost-mobile의 CustomPushNotification.kt 이식 버전.
|
||||
* RN 브릿지(WIX PushNotification) → FirebaseMessagingService + Flutter EventChannel 으로 교체.
|
||||
*/
|
||||
class MattermostFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
private val dataHelper by lazy { PushNotificationDataHelper(this) }
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MattermostFCM"
|
||||
const val FLUTTER_ENGINE_ID = "main_engine"
|
||||
const val NOTIFICATION_CHANNEL = "com.tokilabs.mattermost/notifications"
|
||||
|
||||
// Flutter EventChannel sink (MainActivity에서 설정)
|
||||
var eventSink: EventChannel.EventSink? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 토큰 갱신 시 호출.
|
||||
* Flutter 쪽으로 새 토큰 전달.
|
||||
*/
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.i(TAG, "New FCM token: $token")
|
||||
// Flutter EventChannel이 준비된 경우 토큰 전달
|
||||
eventSink?.success(mapOf("type" to "token_refresh", "token" to token))
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 메시지 수신 시 호출.
|
||||
* CustomPushNotification.onReceived() 대응.
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
super.onMessageReceived(remoteMessage)
|
||||
|
||||
val data = remoteMessage.data
|
||||
if (data.isEmpty()) return
|
||||
|
||||
val bundle = Bundle().apply {
|
||||
data.forEach { (k, v) -> putString(k, v) }
|
||||
}
|
||||
|
||||
val type = bundle.getString("type")
|
||||
val ackId = bundle.getString("ack_id")
|
||||
val postId = bundle.getString("post_id")
|
||||
val channelId = bundle.getString("channel_id")
|
||||
val signature = bundle.getString("signature")
|
||||
val isIdLoaded = bundle.getString("id_loaded") == "true"
|
||||
val notificationId = NotificationHelper.getNotificationId(bundle)
|
||||
val serverUrl = bundle.getString("server_url")
|
||||
|
||||
Log.i(TAG, "onMessageReceived type=$type channelId=$channelId ackId=$ackId")
|
||||
|
||||
GlobalScope.launch {
|
||||
try {
|
||||
handlePushNotificationInCoroutine(
|
||||
serverUrl, type, channelId, ackId,
|
||||
isIdLoaded, notificationId, postId, signature, bundle
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling notification: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비동기 알림 처리 코루틴.
|
||||
* CustomPushNotification.handlePushNotificationInCoroutine() 대응.
|
||||
*/
|
||||
private suspend fun handlePushNotificationInCoroutine(
|
||||
serverUrl: String?,
|
||||
type: String?,
|
||||
channelId: String?,
|
||||
ackId: String?,
|
||||
isIdLoaded: Boolean,
|
||||
notificationId: Int,
|
||||
postId: String?,
|
||||
signature: String?,
|
||||
bundle: Bundle
|
||||
) {
|
||||
var currentBundle = bundle
|
||||
|
||||
// ACK 전송
|
||||
if (ackId != null && serverUrl != null) {
|
||||
val response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded)
|
||||
if (isIdLoaded && response != null) {
|
||||
if (!currentBundle.containsKey("server_url")) {
|
||||
response.putString("server_url", serverUrl)
|
||||
}
|
||||
currentBundle.putAll(response)
|
||||
}
|
||||
}
|
||||
|
||||
// JWT 서명 검증
|
||||
if (!CustomPushNotificationHelper.verifySignature(this, signature, serverUrl, ackId)) {
|
||||
Log.i(TAG, "Notification skipped: signature verification failed")
|
||||
return
|
||||
}
|
||||
|
||||
finishProcessingNotification(serverUrl, type, channelId, notificationId, currentBundle)
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 최종 처리.
|
||||
* CustomPushNotification.finishProcessingNotification() 대응.
|
||||
*/
|
||||
private suspend fun finishProcessingNotification(
|
||||
serverUrl: String?,
|
||||
type: String?,
|
||||
channelId: String?,
|
||||
notificationId: Int,
|
||||
bundle: Bundle
|
||||
) {
|
||||
var currentBundle = bundle
|
||||
val isFlutterRunning = isFlutterEngineRunning()
|
||||
|
||||
when (type) {
|
||||
CustomPushNotificationHelper.PUSH_TYPE_MESSAGE,
|
||||
CustomPushNotificationHelper.PUSH_TYPE_SESSION -> {
|
||||
// 앱이 포그라운드가 아닐 때 시스템 알림 표시
|
||||
if (!isFlutterRunning || !isAppInForeground()) {
|
||||
var createSummary = type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE
|
||||
|
||||
if (type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE && channelId != null) {
|
||||
serverUrl?.let {
|
||||
val notificationResult = dataHelper.fetchAndStoreDataForPushNotification(currentBundle, isFlutterRunning)
|
||||
notificationResult?.let { result ->
|
||||
currentBundle.putBundle("data", result)
|
||||
}
|
||||
}
|
||||
createSummary = NotificationHelper.addNotificationToPreferences(this, notificationId, currentBundle)
|
||||
}
|
||||
|
||||
buildAndPostNotification(notificationId, createSummary, currentBundle)
|
||||
}
|
||||
}
|
||||
CustomPushNotificationHelper.PUSH_TYPE_CLEAR ->
|
||||
NotificationHelper.clearChannelOrThreadNotifications(this, currentBundle)
|
||||
}
|
||||
|
||||
// Flutter가 실행 중이면 EventChannel로 알림 데이터 전달
|
||||
if (isFlutterRunning) {
|
||||
notifyReceivedToFlutter(currentBundle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flutter EventChannel로 알림 이벤트 전송.
|
||||
* CustomPushNotification.notifyReceivedToJS() 대응.
|
||||
*/
|
||||
private fun notifyReceivedToFlutter(bundle: Bundle) {
|
||||
val data = bundleToMap(bundle)
|
||||
Log.i(TAG, "Sending notification event to Flutter: type=${data["type"]}")
|
||||
eventSink?.success(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 알림 빌드 및 표시.
|
||||
*/
|
||||
private fun buildAndPostNotification(notificationId: Int, createSummary: Boolean, bundle: Bundle) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtras(bundle)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, notificationId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = CustomPushNotificationHelper.createNotificationBuilder(this, pendingIntent, bundle, false).build()
|
||||
|
||||
if (createSummary) {
|
||||
val summary = CustomPushNotificationHelper.createNotificationBuilder(this, pendingIntent, bundle, true).build()
|
||||
NotificationHelper.postNotification(this, summary, notificationId + 1)
|
||||
}
|
||||
NotificationHelper.postNotification(this, notification, notificationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flutter 엔진 실행 여부 확인.
|
||||
* CustomPushNotification의 isReactInitialized() 대응.
|
||||
*/
|
||||
private fun isFlutterEngineRunning(): Boolean {
|
||||
return FlutterEngineCache.getInstance().contains(FLUTTER_ENGINE_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 포그라운드 여부 확인.
|
||||
*/
|
||||
private fun isAppInForeground(): Boolean {
|
||||
return AppLifecycleTracker.isInForeground
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle → Map 변환 (EventChannel 전송용).
|
||||
*/
|
||||
private fun bundleToMap(bundle: Bundle): Map<String, Any?> {
|
||||
val map = mutableMapOf<String, Any?>()
|
||||
for (key in bundle.keySet()) {
|
||||
val value = bundle.get(key)
|
||||
map[key] = when (value) {
|
||||
is Bundle -> bundleToMap(value)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tokilabs.mattermost;
|
||||
|
||||
import android.app.IntentService;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import com.tokilabs.mattermost.helpers.NotificationHelper;
|
||||
|
||||
/**
|
||||
* 사용자가 알림을 스와이프(해제)할 때 처리.
|
||||
* mattermost-mobile의 NotificationDismissService.java 이식 버전.
|
||||
* com.wix.reactnativenotifications.core.NotificationIntentAdapter
|
||||
* → 표준 Intent extras 방식으로 교체.
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
public class NotificationDismissService extends IntentService {
|
||||
|
||||
private static final String TAG = "NotifDismissService";
|
||||
private static final String PUSH_NOTIFICATION_EXTRA_NAME = "pushNotification";
|
||||
|
||||
public NotificationDismissService() {
|
||||
super("notificationDismissService");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHandleIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
|
||||
final Context context = getApplicationContext();
|
||||
// WIX NotificationIntentAdapter 대신 표준 extras 방식으로 번들 추출
|
||||
final Bundle bundle = intent.getBundleExtra(PUSH_NOTIFICATION_EXTRA_NAME);
|
||||
|
||||
if (bundle != null) {
|
||||
NotificationHelper.INSTANCE.dismissNotification(context, bundle);
|
||||
Log.i(TAG, "Dismiss notification id=" + NotificationHelper.INSTANCE.getNotificationId(bundle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
package com.tokilabs.mattermost;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.RemoteInput;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.Person;
|
||||
|
||||
import com.tokilabs.mattermost.helpers.CustomPushNotificationHelper;
|
||||
import com.tokilabs.mattermost.helpers.Network;
|
||||
import com.tokilabs.mattermost.helpers.NotificationHelper;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* 알림에서 직접 답장(인라인 리플) 처리.
|
||||
* mattermost-mobile의 NotificationReplyBroadcastReceiver.java 이식 버전.
|
||||
* com.facebook.react.bridge.* + Network.post() → OkHttp 직접 사용으로 교체.
|
||||
* WIX NotificationIntentAdapter / PushNotificationProps → 표준 Intent/Bundle 방식으로 교체.
|
||||
*/
|
||||
public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "NotifReplyReceiver";
|
||||
|
||||
private Context mContext;
|
||||
private Bundle bundle;
|
||||
private NotificationManager notificationManager;
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
try {
|
||||
final CharSequence message = getReplyMessage(intent);
|
||||
if (message == null) return;
|
||||
|
||||
mContext = context;
|
||||
bundle = intent.getBundleExtra(CustomPushNotificationHelper.NOTIFICATION);
|
||||
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
final int notificationId = intent.getIntExtra(CustomPushNotificationHelper.NOTIFICATION_ID, -1);
|
||||
final String serverUrl = bundle.getString("server_url");
|
||||
|
||||
if (serverUrl != null) {
|
||||
replyToMessage(serverUrl, notificationId, message);
|
||||
} else {
|
||||
onReplyFailed(notificationId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "onReceive error: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
protected void replyToMessage(final String serverUrl, final int notificationId,
|
||||
final CharSequence message) {
|
||||
final String channelId = bundle.getString("channel_id");
|
||||
final String postId = bundle.getString("post_id");
|
||||
String rootId = bundle.getString("root_id");
|
||||
if (android.text.TextUtils.isEmpty(rootId)) rootId = postId;
|
||||
|
||||
try {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("channel_id", channelId);
|
||||
body.put("message", message.toString());
|
||||
body.put("root_id", rootId);
|
||||
|
||||
final String finalRootId = rootId;
|
||||
Network.INSTANCE.post(serverUrl, "/api/v4/posts?set_online=false", body,
|
||||
null,
|
||||
json -> {
|
||||
if (json != null) {
|
||||
try {
|
||||
if (json.has("status_code")) {
|
||||
int statusCode = json.getInt("status_code");
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
Log.i(TAG, "Reply FAILED status=" + statusCode);
|
||||
onReplyFailed(notificationId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
onReplySuccess(notificationId, message);
|
||||
Log.i(TAG, "Reply SUCCESS");
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
onReplyFailed(notificationId);
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Reply FAILED: null response");
|
||||
onReplyFailed(notificationId);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
e -> {
|
||||
Log.i(TAG, "Reply FAILED exception: " + e.getMessage());
|
||||
onReplyFailed(notificationId);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "JSON error: " + e.getMessage());
|
||||
onReplyFailed(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
protected void onReplyFailed(int notificationId) {
|
||||
recreateNotification(notificationId, "Message failed to send.");
|
||||
}
|
||||
|
||||
protected void onReplySuccess(int notificationId, final CharSequence message) {
|
||||
recreateNotification(notificationId, message);
|
||||
}
|
||||
|
||||
private void recreateNotification(int notificationId, final CharSequence message) {
|
||||
// 표준 PendingIntent 생성 (WIX NotificationIntentAdapter 제거)
|
||||
Intent openIntent = new Intent(mContext, MainActivity.class);
|
||||
openIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
openIntent.putExtras(bundle);
|
||||
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(
|
||||
mContext, notificationId, openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
NotificationCompat.Builder builder =
|
||||
CustomPushNotificationHelper.createNotificationBuilder(mContext, pendingIntent, bundle, false);
|
||||
|
||||
Notification notification = builder.build();
|
||||
NotificationCompat.MessagingStyle messagingStyle =
|
||||
NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification);
|
||||
|
||||
if (messagingStyle != null) {
|
||||
messagingStyle.addMessage(message, System.currentTimeMillis(), (Person) null);
|
||||
notification = builder.setStyle(messagingStyle).build();
|
||||
}
|
||||
|
||||
notificationManager.notify(notificationId, notification);
|
||||
}
|
||||
|
||||
private CharSequence getReplyMessage(Intent intent) {
|
||||
Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
|
||||
if (remoteInput != null) {
|
||||
return remoteInput.getCharSequence(CustomPushNotificationHelper.KEY_TEXT_REPLY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.tokilabs.mattermost;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import com.tokilabs.mattermost.helpers.Network;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 알림 수신 확인(ACK) 서버 전송.
|
||||
* mattermost-mobile의 ReceiptDelivery.java 이식 버전.
|
||||
* com.facebook.react.bridge.Arguments / WritableMap → org.json.JSONObject / android.os.Bundle 교체.
|
||||
*/
|
||||
public class ReceiptDelivery {
|
||||
private static final String TAG = "ReceiptDelivery";
|
||||
private static final String[] ACK_KEYS = {
|
||||
"post_id", "root_id", "category", "message", "team_id",
|
||||
"channel_id", "channel_name", "type", "sender_id", "sender_name", "version"
|
||||
};
|
||||
|
||||
public static Bundle send(final String ackId, final String serverUrl,
|
||||
final String postId, final String type, final boolean isIdLoaded) {
|
||||
Log.i(TAG, String.format("Send ACK=%s TYPE=%s to URL=%s ID-LOADED=%s", ackId, type, serverUrl, isIdLoaded));
|
||||
|
||||
try {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("id", ackId);
|
||||
body.put("received_at", System.currentTimeMillis());
|
||||
body.put("platform", "android");
|
||||
body.put("type", type);
|
||||
body.put("post_id", postId);
|
||||
body.put("is_id_loaded", isIdLoaded);
|
||||
|
||||
try (Response response = Network.INSTANCE.postSync(serverUrl, "api/v4/notifications/ack", body)) {
|
||||
String responseBody = Objects.requireNonNull(response.body()).string();
|
||||
JSONObject jsonResponse = new JSONObject(responseBody);
|
||||
return parseAckResponse(jsonResponse);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Send receipt delivery failed: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Bundle parseAckResponse(JSONObject jsonResponse) {
|
||||
try {
|
||||
Bundle bundle = new Bundle();
|
||||
for (String key : ACK_KEYS) {
|
||||
if (jsonResponse.has(key)) {
|
||||
bundle.putString(key, jsonResponse.getString(key));
|
||||
}
|
||||
}
|
||||
return bundle;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tokilabs.mattermost.helpers
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.LruCache
|
||||
|
||||
/**
|
||||
* 사용자 아바타 비트맵 캐시.
|
||||
* mattermost-mobile의 BitmapCache 이식.
|
||||
*/
|
||||
class BitmapCache {
|
||||
private data class CacheKey(val userId: String, val serverUrl: String)
|
||||
private data class CacheEntry(val bitmap: Bitmap, val lastUpdateAt: Double)
|
||||
|
||||
private val cache = LruCache<CacheKey, CacheEntry>(20)
|
||||
|
||||
fun bitmap(userId: String, lastUpdateAt: Double, serverUrl: String): Bitmap? {
|
||||
val entry = cache.get(CacheKey(userId, serverUrl)) ?: return null
|
||||
return if (entry.lastUpdateAt == lastUpdateAt) entry.bitmap else null
|
||||
}
|
||||
|
||||
fun insertBitmap(bitmap: Bitmap, userId: String, lastUpdateAt: Double, serverUrl: String) {
|
||||
cache.put(CacheKey(userId, serverUrl), CacheEntry(bitmap, lastUpdateAt))
|
||||
}
|
||||
|
||||
fun removeBitmap(userId: String, serverUrl: String) {
|
||||
cache.remove(CacheKey(userId, serverUrl))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
package com.tokilabs.mattermost.helpers;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffXfermode;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.app.Person;
|
||||
import androidx.core.app.RemoteInput;
|
||||
import androidx.core.graphics.drawable.IconCompat;
|
||||
|
||||
import com.tokilabs.mattermost.NotificationDismissService;
|
||||
import com.tokilabs.mattermost.NotificationReplyBroadcastReceiver;
|
||||
import com.tokilabs.mattermost.R;
|
||||
import com.tokilabs.mattermost.helpers.db.MattermostDatabase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.jsonwebtoken.IncorrectClaimException;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.MissingClaimException;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 알림 UI 생성 및 JWT 서명 검증.
|
||||
* mattermost-mobile의 com.mattermost.helpers.CustomPushNotificationHelper 이식 버전.
|
||||
* com.facebook.react.bridge.* → android.os.Bundle / 표준 Java 타입으로 교체.
|
||||
* WatermelonDB → DatabaseHelper (Room 기반)로 교체.
|
||||
*/
|
||||
public class CustomPushNotificationHelper {
|
||||
public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01";
|
||||
public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02";
|
||||
public static final String KEY_TEXT_REPLY = "CAN_REPLY";
|
||||
public static final String NOTIFICATION_ID = "notificationId";
|
||||
public static final String NOTIFICATION = "notification";
|
||||
public static final String PUSH_TYPE_MESSAGE = "message";
|
||||
public static final String PUSH_TYPE_CLEAR = "clear";
|
||||
public static final String PUSH_TYPE_SESSION = "session";
|
||||
public static final String CATEGORY_CAN_REPLY = "CAN_REPLY";
|
||||
|
||||
private static final String TAG = "PushNotifHelper";
|
||||
|
||||
private static NotificationChannel mHighImportanceChannel;
|
||||
private static NotificationChannel mMinImportanceChannel;
|
||||
|
||||
private static final OkHttpClient client = new OkHttpClient();
|
||||
private static final BitmapCache bitmapCache = new BitmapCache();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public static NotificationCompat.Builder createNotificationBuilder(
|
||||
Context context, PendingIntent intent, Bundle bundle, boolean createSummary) {
|
||||
|
||||
final NotificationCompat.Builder notification =
|
||||
new NotificationCompat.Builder(context, CHANNEL_HIGH_IMPORTANCE_ID);
|
||||
|
||||
String postId = bundle.getString("post_id");
|
||||
String rootId = bundle.getString("root_id");
|
||||
String channelId = bundle.getString("channel_id");
|
||||
int notificationId = postId != null ? postId.hashCode() : NotificationHelper.MESSAGE_NOTIFICATION_ID;
|
||||
|
||||
boolean isCrtEnabled = bundle.containsKey("is_crt_enabled") &&
|
||||
Objects.equals(bundle.getString("is_crt_enabled"), "true");
|
||||
String groupId = isCrtEnabled && !TextUtils.isEmpty(rootId) ? rootId : channelId;
|
||||
|
||||
addNotificationExtras(notification, bundle);
|
||||
setNotificationIcons(context, notification, bundle);
|
||||
setNotificationMessagingStyle(context, notification, bundle);
|
||||
setNotificationGroup(notification, groupId, createSummary);
|
||||
setNotificationBadgeType(notification);
|
||||
setNotificationChannel(context, notification);
|
||||
setNotificationDeleteIntent(context, notification, bundle, notificationId);
|
||||
addNotificationReplyAction(context, notification, bundle, notificationId);
|
||||
|
||||
notification
|
||||
.setContentIntent(intent)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPriority(Notification.PRIORITY_HIGH)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
public static void createNotificationChannels(Context context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
|
||||
|
||||
if (mHighImportanceChannel == null) {
|
||||
mHighImportanceChannel = new NotificationChannel(
|
||||
CHANNEL_HIGH_IMPORTANCE_ID, "High Importance", NotificationManager.IMPORTANCE_HIGH);
|
||||
mHighImportanceChannel.setShowBadge(true);
|
||||
notificationManager.createNotificationChannel(mHighImportanceChannel);
|
||||
}
|
||||
|
||||
if (mMinImportanceChannel == null) {
|
||||
mMinImportanceChannel = new NotificationChannel(
|
||||
CHANNEL_MIN_IMPORTANCE_ID, "Min Importance", NotificationManager.IMPORTANCE_MIN);
|
||||
mMinImportanceChannel.setShowBadge(true);
|
||||
notificationManager.createNotificationChannel(mMinImportanceChannel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 서명 검증.
|
||||
* 원본 로직 그대로 유지 (DatabaseHelper만 Room 기반으로 교체).
|
||||
*/
|
||||
public static boolean verifySignature(final Context context, String signature,
|
||||
String serverUrl, String ackId) {
|
||||
if (signature == null) {
|
||||
Log.i(TAG, "No signature in the notification (backward compat)");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (serverUrl == null) {
|
||||
Log.i(TAG, "No server_url for server_id");
|
||||
return false;
|
||||
}
|
||||
|
||||
DatabaseHelper dbHelper = DatabaseHelper.getInstance();
|
||||
if (dbHelper == null) {
|
||||
Log.i(TAG, "Cannot access the database");
|
||||
return false;
|
||||
}
|
||||
|
||||
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
|
||||
if (db == null) {
|
||||
Log.i(TAG, "Cannot access the server database");
|
||||
return false;
|
||||
}
|
||||
|
||||
if ("NO_SIGNATURE".equals(signature)) {
|
||||
String version = db.queryConfigServerVersion();
|
||||
if (version == null) {
|
||||
Log.i(TAG, "No server version");
|
||||
return false;
|
||||
}
|
||||
if (!version.matches("[0-9]+(\\.[0-9]+)*")) {
|
||||
Log.i(TAG, "Invalid server version");
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] parts = version.split("\\.");
|
||||
int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0;
|
||||
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
|
||||
int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
|
||||
|
||||
int[][] targets = {{9,8,0},{9,7,3},{9,6,3},{9,5,5},{8,1,14}};
|
||||
boolean rejected = false;
|
||||
for (int i = 0; i < targets.length; i++) {
|
||||
boolean first = i == 0;
|
||||
int majorTarget = targets[i][0];
|
||||
int minorTarget = targets[i][1];
|
||||
int patchTarget = targets[i][2];
|
||||
|
||||
if (major > majorTarget) { rejected = first; break; }
|
||||
if (major < majorTarget) { continue; }
|
||||
if (minor > minorTarget) { rejected = first; break; }
|
||||
if (minor < minorTarget) { continue; }
|
||||
if (patch >= patchTarget) { rejected = true; break; }
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rejected) {
|
||||
Log.i(TAG, "Server version should send signature");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String signingKey = db.queryConfigSigningKey();
|
||||
if (signingKey == null) {
|
||||
Log.i(TAG, "No signing key");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] encoded = Base64.decode(signingKey, 0);
|
||||
KeyFactory kf = KeyFactory.getInstance("EC");
|
||||
PublicKey pubKey = kf.generatePublic(new X509EncodedKeySpec(encoded));
|
||||
|
||||
String storedDeviceToken = dbHelper.getDeviceToken();
|
||||
if (storedDeviceToken == null) {
|
||||
Log.i(TAG, "No device token stored");
|
||||
return false;
|
||||
}
|
||||
String[] tokenParts = storedDeviceToken.split(":", 2);
|
||||
if (tokenParts.length != 2) {
|
||||
Log.i(TAG, "Wrong stored device token format");
|
||||
return false;
|
||||
}
|
||||
String deviceToken = tokenParts[1].substring(0, tokenParts[1].length() - 1);
|
||||
if (deviceToken.isEmpty()) {
|
||||
Log.i(TAG, "Empty stored device token");
|
||||
return false;
|
||||
}
|
||||
|
||||
Jwts.parser()
|
||||
.require("ack_id", ackId)
|
||||
.require("device_id", deviceToken)
|
||||
.verifyWith((PublicKey) pubKey)
|
||||
.build()
|
||||
.parseSignedClaims(signature);
|
||||
|
||||
} catch (MissingClaimException e) {
|
||||
Log.i(TAG, "Missing claim: " + e.getMessage());
|
||||
return false;
|
||||
} catch (IncorrectClaimException e) {
|
||||
Log.i(TAG, "Incorrect claim: " + e.getMessage());
|
||||
return false;
|
||||
} catch (JwtException e) {
|
||||
Log.i(TAG, "Cannot verify JWT: " + e.getMessage());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Log.i(TAG, "Exception while parsing JWT: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Private helpers (원본 로직 그대로)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void addNotificationExtras(NotificationCompat.Builder notification, Bundle bundle) {
|
||||
Bundle userInfoBundle = bundle.getBundle("userInfo");
|
||||
if (userInfoBundle == null) userInfoBundle = new Bundle();
|
||||
|
||||
putIfNotNull(userInfoBundle, "channel_id", bundle.getString("channel_id"));
|
||||
putIfNotNull(userInfoBundle, "post_id", bundle.getString("post_id"));
|
||||
putIfNotNull(userInfoBundle, "root_id", bundle.getString("root_id"));
|
||||
putIfNotNull(userInfoBundle, "is_crt_enabled", bundle.getString("is_crt_enabled"));
|
||||
putIfNotNull(userInfoBundle, "server_url", bundle.getString("server_url"));
|
||||
|
||||
notification.addExtras(userInfoBundle);
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedImmutableFlag")
|
||||
private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification,
|
||||
Bundle bundle, int notificationId) {
|
||||
String postId = bundle.getString("post_id");
|
||||
String serverUrl = bundle.getString("server_url");
|
||||
boolean canReply = bundle.containsKey("category") &&
|
||||
Objects.equals(bundle.getString("category"), CATEGORY_CAN_REPLY);
|
||||
|
||||
if (TextUtils.isEmpty(postId) || serverUrl == null || !canReply) return;
|
||||
|
||||
Intent replyIntent = new Intent(context, NotificationReplyBroadcastReceiver.class);
|
||||
replyIntent.setAction(KEY_TEXT_REPLY);
|
||||
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
|
||||
replyIntent.putExtra(NOTIFICATION, bundle);
|
||||
|
||||
PendingIntent replyPendingIntent;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
replyPendingIntent = PendingIntent.getBroadcast(context, notificationId, replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
|
||||
} else {
|
||||
replyPendingIntent = PendingIntent.getBroadcast(context, notificationId, replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
|
||||
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build();
|
||||
|
||||
NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_notif_action_reply, "Reply", replyPendingIntent)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build();
|
||||
|
||||
notification.setShowWhen(true).addAction(replyAction);
|
||||
}
|
||||
|
||||
private static void setNotificationMessagingStyle(Context context,
|
||||
NotificationCompat.Builder notification, Bundle bundle) {
|
||||
notification.setStyle(getMessagingStyle(context, bundle));
|
||||
}
|
||||
|
||||
private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) {
|
||||
final String serverUrl = bundle.getString("server_url");
|
||||
final String type = bundle.getString("type");
|
||||
String urlOverride = bundle.getString("override_icon_url");
|
||||
|
||||
Person.Builder sender = new Person.Builder().setKey("me").setName("Me");
|
||||
|
||||
if (serverUrl != null && type != null && !type.equals(PUSH_TYPE_SESSION)) {
|
||||
try {
|
||||
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
|
||||
if (avatar != null) sender.setIcon(IconCompat.createWithBitmap(avatar));
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(sender.build());
|
||||
String conversationTitle = getConversationTitle(bundle);
|
||||
setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle);
|
||||
addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle);
|
||||
|
||||
return messagingStyle;
|
||||
}
|
||||
|
||||
private static void addMessagingStyleMessages(Context context,
|
||||
NotificationCompat.MessagingStyle messagingStyle,
|
||||
String conversationTitle, Bundle bundle) {
|
||||
String message = bundle.getString("message", bundle.getString("body"));
|
||||
String senderId = bundle.getString("sender_id");
|
||||
final String serverUrl = bundle.getString("server_url");
|
||||
final String type = bundle.getString("type");
|
||||
String urlOverride = bundle.getString("override_icon_url");
|
||||
|
||||
if (senderId == null) senderId = "sender_id";
|
||||
String senderName = getSenderName(bundle);
|
||||
|
||||
if (conversationTitle == null || !TextUtils.isEmpty(senderName.trim())) {
|
||||
message = removeSenderNameFromMessage(message, senderName);
|
||||
}
|
||||
|
||||
long timestamp = new Date().getTime();
|
||||
Person.Builder sender = new Person.Builder().setKey(senderId).setName(senderName);
|
||||
|
||||
if (serverUrl != null && type != null && !type.equals(PUSH_TYPE_SESSION)) {
|
||||
try {
|
||||
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
|
||||
if (avatar != null) sender.setIcon(IconCompat.createWithBitmap(avatar));
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
messagingStyle.addMessage(message, timestamp, sender.build());
|
||||
}
|
||||
|
||||
private static void setNotificationIcons(Context context, NotificationCompat.Builder notification, Bundle bundle) {
|
||||
String channelName = getConversationTitle(bundle);
|
||||
String senderName = bundle.getString("sender_name");
|
||||
String serverUrl = bundle.getString("server_url");
|
||||
String urlOverride = bundle.getString("override_icon_url");
|
||||
|
||||
notification.setSmallIcon(R.mipmap.ic_notification);
|
||||
|
||||
if (serverUrl != null && channelName.equals(senderName)) {
|
||||
try {
|
||||
String senderId = bundle.getString("sender_id");
|
||||
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
|
||||
if (avatar != null) notification.setLargeIcon(avatar);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void setNotificationGroup(NotificationCompat.Builder notification,
|
||||
String channelId, boolean setAsSummary) {
|
||||
notification.setGroup(channelId);
|
||||
if (setAsSummary) notification.setGroupSummary(true);
|
||||
}
|
||||
|
||||
private static void setNotificationBadgeType(NotificationCompat.Builder notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notification.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setNotificationChannel(Context context, NotificationCompat.Builder notification) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
if (mHighImportanceChannel == null) createNotificationChannels(context);
|
||||
notification.setChannelId(mHighImportanceChannel.getId());
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedImmutableFlag")
|
||||
private static void setNotificationDeleteIntent(Context context, NotificationCompat.Builder notification,
|
||||
Bundle bundle, int notificationId) {
|
||||
final String PUSH_NOTIFICATION_EXTRA_NAME = "pushNotification";
|
||||
Intent delIntent = new Intent(context, NotificationDismissService.class);
|
||||
delIntent.putExtra(NOTIFICATION_ID, notificationId);
|
||||
delIntent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, bundle);
|
||||
PendingIntent deleteIntent = PendingIntent.getService(context,
|
||||
(int) System.currentTimeMillis(), delIntent,
|
||||
PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
|
||||
notification.setDeleteIntent(deleteIntent);
|
||||
}
|
||||
|
||||
private static void setMessagingStyleConversationTitle(
|
||||
NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
|
||||
String channelName = getConversationTitle(bundle);
|
||||
String senderName = bundle.getString("sender_name");
|
||||
if (TextUtils.isEmpty(senderName)) senderName = getSenderName(bundle);
|
||||
|
||||
if (conversationTitle != null && !channelName.equals(senderName)) {
|
||||
messagingStyle.setConversationTitle(conversationTitle);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
messagingStyle.setGroupConversation(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String getConversationTitle(Bundle bundle) {
|
||||
String title = bundle.getString("channel_name");
|
||||
if (TextUtils.isEmpty(title)) title = bundle.getString("sender_name");
|
||||
if (TextUtils.isEmpty(title)) title = bundle.getString("title", "");
|
||||
return title;
|
||||
}
|
||||
|
||||
private static String getSenderName(Bundle bundle) {
|
||||
String senderName = bundle.getString("sender_name");
|
||||
if (senderName != null) return senderName;
|
||||
|
||||
String channelName = bundle.getString("channel_name");
|
||||
if (channelName != null && channelName.startsWith("@")) return channelName;
|
||||
|
||||
String message = bundle.getString("message");
|
||||
if (message != null) {
|
||||
String name = message.split(":")[0];
|
||||
if (!name.equals(message)) return name;
|
||||
}
|
||||
|
||||
return getConversationTitle(bundle);
|
||||
}
|
||||
|
||||
private static String removeSenderNameFromMessage(String message, String senderName) {
|
||||
int index = message.indexOf(senderName);
|
||||
if (index == 0) message = message.substring(senderName.length());
|
||||
return message.replaceFirst(": ", "").trim();
|
||||
}
|
||||
|
||||
private static Bitmap getCircleBitmap(Bitmap bitmap) {
|
||||
final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
|
||||
final Canvas canvas = new Canvas(output);
|
||||
final Paint paint = new Paint();
|
||||
final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
|
||||
final RectF rectF = new RectF(rect);
|
||||
|
||||
paint.setAntiAlias(true);
|
||||
canvas.drawARGB(0, 0, 0, 0);
|
||||
paint.setColor(Color.RED);
|
||||
canvas.drawOval(rectF, paint);
|
||||
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
|
||||
canvas.drawBitmap(bitmap, rect, rect, paint);
|
||||
bitmap.recycle();
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static Bitmap userAvatar(final Context context, @NonNull final String serverUrl,
|
||||
final String userId, final String urlOverride) throws IOException {
|
||||
try {
|
||||
Response response;
|
||||
Double lastUpdateAt = 0.0;
|
||||
|
||||
if (!TextUtils.isEmpty(urlOverride)) {
|
||||
Request request = new Request.Builder().url(urlOverride).build();
|
||||
response = client.newCall(request).execute();
|
||||
} else {
|
||||
DatabaseHelper dbHelper = DatabaseHelper.getInstance();
|
||||
if (dbHelper != null) {
|
||||
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
|
||||
if (db != null) {
|
||||
lastUpdateAt = db.getLastPictureUpdate(userId);
|
||||
if (lastUpdateAt == null) lastUpdateAt = 0.0;
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
Bitmap cached = bitmapCache.bitmap(userId, lastUpdateAt, serverUrl);
|
||||
if (cached != null) {
|
||||
return getCircleBitmap(cached.copy(cached.getConfig(), false));
|
||||
}
|
||||
|
||||
bitmapCache.removeBitmap(userId, serverUrl);
|
||||
String url = serverUrl.trimEnd('/') + "/api/v4/users/" + userId + "/image";
|
||||
response = Network.INSTANCE.getSync(serverUrl, "api/v4/users/" + userId + "/image", null);
|
||||
}
|
||||
|
||||
if (response.code() == 200 && response.body() != null) {
|
||||
byte[] bytes = response.body().bytes();
|
||||
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
if (TextUtils.isEmpty(urlOverride) && !TextUtils.isEmpty(userId)) {
|
||||
bitmapCache.insertBitmap(bitmap.copy(bitmap.getConfig(), false), userId, lastUpdateAt, serverUrl);
|
||||
}
|
||||
return getCircleBitmap(bitmap);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void putIfNotNull(Bundle bundle, String key, String value) {
|
||||
if (value != null) bundle.putString(key, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.tokilabs.mattermost.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.tokilabs.mattermost.helpers.db.GlobalDatabase
|
||||
import com.tokilabs.mattermost.helpers.db.GlobalEntity
|
||||
import com.tokilabs.mattermost.helpers.db.MattermostDatabase
|
||||
import com.tokilabs.mattermost.helpers.db.ServerEntity
|
||||
|
||||
/**
|
||||
* 서버별 DB 접근 및 디바이스 토큰 관리.
|
||||
* mattermost-mobile의 DatabaseHelper (WatermelonDB 기반) → Room DB 기반으로 교체.
|
||||
*
|
||||
* Global DB: Servers 목록, deviceToken
|
||||
* Server DB: Config(버전/서명키), User(아바타), System(currentUserId)
|
||||
*/
|
||||
class DatabaseHelper private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DatabaseHelper"
|
||||
private const val DEVICE_TOKEN_KEY = "deviceToken"
|
||||
|
||||
@Volatile
|
||||
private var instance: DatabaseHelper? = null
|
||||
|
||||
fun getInstance(): DatabaseHelper? = instance
|
||||
|
||||
fun init(context: Context): DatabaseHelper {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: DatabaseHelper(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val globalDb by lazy { GlobalDatabase.getInstance(context) }
|
||||
|
||||
// ─── 디바이스 토큰 ────────────────────────────────────────────────────────
|
||||
|
||||
fun getDeviceToken(): String? {
|
||||
return try {
|
||||
globalDb.globalDao().getValue(DEVICE_TOKEN_KEY)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getDeviceToken error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDeviceToken(token: String) {
|
||||
try {
|
||||
globalDb.globalDao().upsert(GlobalEntity(DEVICE_TOKEN_KEY, token))
|
||||
Log.i(TAG, "Device token saved")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "saveDeviceToken error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 서버 목록 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 단일 서버 URL 반환 (서버가 하나만 있는 경우) */
|
||||
val onlyServerUrl: String?
|
||||
get() = try {
|
||||
val servers = globalDb.serversDao().getAll()
|
||||
if (servers.size == 1) servers.first().url else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** server_id(identifier)로 서버 URL 조회 */
|
||||
fun getServerUrlForIdentifier(serverId: String): String? {
|
||||
return try {
|
||||
globalDb.serversDao().getAll()
|
||||
.firstOrNull { it.identifier == serverId }?.url
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveServerUrl(serverUrl: String, identifier: String? = null) {
|
||||
try {
|
||||
globalDb.serversDao().upsert(
|
||||
ServerEntity(url = serverUrl, dbPath = "", identifier = identifier)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "saveServerUrl error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 서버별 DB ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 서버 URL에 해당하는 Room DB 반환.
|
||||
* JWT 검증(CustomPushNotificationHelper), 데이터 페칭(PushNotificationDataHelper)에서 사용.
|
||||
*/
|
||||
fun getDatabaseForServer(context: Context, serverUrl: String): MattermostDatabase? {
|
||||
return try {
|
||||
MattermostDatabase.getInstance(context, serverUrl)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getDatabaseForServer error for $serverUrl: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.tokilabs.mattermost.helpers
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* OkHttp 기반 HTTP 클라이언트.
|
||||
* mattermost-mobile의 com.mattermost.helpers.Network 대체.
|
||||
*/
|
||||
object Network {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
private const val TAG = "NomadNetwork"
|
||||
|
||||
/**
|
||||
* 인증 토큰 저장소 (DatabaseHelper에서 읽어 설정)
|
||||
* key: serverUrl, value: token
|
||||
*/
|
||||
private val tokenCache = mutableMapOf<String, String>()
|
||||
|
||||
fun setToken(serverUrl: String, token: String) {
|
||||
tokenCache[serverUrl] = token
|
||||
}
|
||||
|
||||
fun clearToken(serverUrl: String) {
|
||||
tokenCache.remove(serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기 POST 요청 (코루틴 내부에서 사용)
|
||||
*/
|
||||
fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map<String, String> = emptyMap()): Response {
|
||||
val url = buildUrl(serverUrl, endpoint)
|
||||
val requestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
|
||||
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
|
||||
headers.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
|
||||
|
||||
Log.i(TAG, "POST $url")
|
||||
return client.newCall(requestBuilder.build()).execute()
|
||||
}
|
||||
|
||||
/**
|
||||
* 비동기 POST 요청 (BroadcastReceiver 등에서 사용)
|
||||
*/
|
||||
fun post(serverUrl: String, endpoint: String, body: JSONObject,
|
||||
headers: Map<String, String> = emptyMap(),
|
||||
onSuccess: (JSONObject?) -> Unit,
|
||||
onFailure: (Exception) -> Unit) {
|
||||
val url = buildUrl(serverUrl, endpoint)
|
||||
val requestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
|
||||
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
|
||||
headers.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
|
||||
|
||||
Log.i(TAG, "POST async $url")
|
||||
client.newCall(requestBuilder.build()).enqueue(object : okhttp3.Callback {
|
||||
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
|
||||
Log.e(TAG, "POST failed: ${e.message}")
|
||||
onFailure(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: okhttp3.Call, response: Response) {
|
||||
try {
|
||||
val responseBody = response.body?.string()
|
||||
val json = if (!responseBody.isNullOrEmpty()) JSONObject(responseBody) else null
|
||||
onSuccess(json)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "POST response parse error: ${e.message}")
|
||||
onSuccess(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기 GET 요청
|
||||
*/
|
||||
fun getSync(serverUrl: String, endpoint: String, headers: Map<String, String>? = null): Response {
|
||||
val url = buildUrl(serverUrl, endpoint)
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
|
||||
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
|
||||
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
|
||||
|
||||
Log.i(TAG, "GET $url")
|
||||
return client.newCall(requestBuilder.build()).execute()
|
||||
}
|
||||
|
||||
private fun buildUrl(serverUrl: String, endpoint: String): String {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val path = endpoint.trimStart('/')
|
||||
return "$base/$path"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package com.tokilabs.mattermost.helpers
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
/**
|
||||
* 알림 유틸리티.
|
||||
* mattermost-mobile의 com.mattermost.rnutils.helpers.NotificationHelper 대응.
|
||||
*/
|
||||
object NotificationHelper {
|
||||
private const val TAG = "NotificationHelper"
|
||||
const val MESSAGE_NOTIFICATION_ID = -1
|
||||
|
||||
// 알림 ID → Bundle 저장소 (요약 알림 처리용)
|
||||
private val notificationPreferences = mutableMapOf<Int, Bundle>()
|
||||
|
||||
fun getNotificationId(bundle: Bundle): Int {
|
||||
val postId = bundle.getString("post_id")
|
||||
return postId?.hashCode() ?: MESSAGE_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림을 preferences에 추가.
|
||||
* @return true if this is the first notification for the group (→ createSummary)
|
||||
*/
|
||||
fun addNotificationToPreferences(context: Context, notificationId: Int, bundle: Bundle): Boolean {
|
||||
val channelId = bundle.getString("channel_id") ?: return true
|
||||
val rootId = bundle.getString("root_id")
|
||||
val isCRTEnabled = bundle.getString("is_crt_enabled") == "true"
|
||||
val groupId = if (isCRTEnabled && !rootId.isNullOrEmpty()) rootId else channelId
|
||||
|
||||
val isFirst = notificationPreferences.none { (_, b) ->
|
||||
val bGroupId = if (isCRTEnabled && !b.getString("root_id").isNullOrEmpty())
|
||||
b.getString("root_id") else b.getString("channel_id")
|
||||
bGroupId == groupId
|
||||
}
|
||||
|
||||
notificationPreferences[notificationId] = bundle
|
||||
return isFirst
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널/스레드 알림 전체 초기화.
|
||||
*/
|
||||
fun clearChannelOrThreadNotifications(context: Context, bundle: Bundle) {
|
||||
val channelId = bundle.getString("channel_id") ?: return
|
||||
val rootId = bundle.getString("root_id")
|
||||
val isCRTEnabled = bundle.getString("is_crt_enabled") == "true"
|
||||
val targetGroupId = if (isCRTEnabled && !rootId.isNullOrEmpty()) rootId else channelId
|
||||
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val toRemove = mutableListOf<Int>()
|
||||
|
||||
notificationPreferences.forEach { (id, b) ->
|
||||
val bRootId = b.getString("root_id")
|
||||
val bChannelId = b.getString("channel_id")
|
||||
val bGroupId = if (isCRTEnabled && !bRootId.isNullOrEmpty()) bRootId else bChannelId
|
||||
if (bGroupId == targetGroupId) {
|
||||
notificationManager.cancel(id)
|
||||
toRemove.add(id)
|
||||
Log.i(TAG, "Cancelled notification id=$id for group=$targetGroupId")
|
||||
}
|
||||
}
|
||||
|
||||
toRemove.forEach { notificationPreferences.remove(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 알림 해제.
|
||||
*/
|
||||
fun dismissNotification(context: Context, bundle: Bundle) {
|
||||
val notificationId = getNotificationId(bundle)
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
notificationManager.cancel(notificationId)
|
||||
notificationPreferences.remove(notificationId)
|
||||
Log.i(TAG, "Dismissed notification id=$notificationId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 표시.
|
||||
*/
|
||||
fun postNotification(context: Context, notification: android.app.Notification, notificationId: Int) {
|
||||
try {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
notificationManager.notify(notificationId, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "No notification permission: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package com.tokilabs.mattermost.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import com.tokilabs.mattermost.helpers.db.MattermostDatabase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 알림 수신 시 필요한 데이터(채널/포스트/유저/스레드)를 DB에서 페칭.
|
||||
* mattermost-mobile의 PushNotificationDataHelper.kt 이식 버전.
|
||||
*
|
||||
* 주요 변경:
|
||||
* - com.facebook.react.bridge.* (Arguments, ReadableMap 등) → android.os.Bundle 사용
|
||||
* - WatermelonDB (WMDatabase) → Room DB (MattermostDatabase)
|
||||
* - 데이터 페칭 로직은 구조 유지, 실제 DB 쿼리는 MattermostDatabase.kt에서 구현
|
||||
*/
|
||||
class PushNotificationDataHelper(private val context: Context) {
|
||||
|
||||
suspend fun fetchAndStoreDataForPushNotification(initialData: Bundle, isFlutterInit: Boolean): Bundle? {
|
||||
return withContext(Dispatchers.Default) {
|
||||
PushNotificationDataRunnable.start(context, initialData, isFlutterInit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object PushNotificationDataRunnable {
|
||||
private const val TAG = "PushNotifDataHelper"
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun start(context: Context, initialData: Bundle, isFlutterInit: Boolean): Bundle? {
|
||||
mutex.withLock {
|
||||
val serverUrl = initialData.getString("server_url") ?: return null
|
||||
val dbHelper = DatabaseHelper.getInstance() ?: run {
|
||||
Log.e(TAG, "DatabaseHelper not initialized")
|
||||
return null
|
||||
}
|
||||
val db = dbHelper.getDatabaseForServer(context, serverUrl)
|
||||
|
||||
var result: Bundle? = null
|
||||
|
||||
try {
|
||||
if (db != null) {
|
||||
val teamId = initialData.getString("team_id")
|
||||
val channelId = initialData.getString("channel_id")
|
||||
val postId = initialData.getString("post_id")
|
||||
val rootId = initialData.getString("root_id")
|
||||
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
|
||||
val ackId = initialData.getString("ack_id")
|
||||
|
||||
Log.i(TAG, "Start fetching notification data server=$serverUrl channel=$channelId ack=$ackId")
|
||||
|
||||
val notificationData = Bundle()
|
||||
|
||||
// 팀 정보 (TODO: Room DAO 구현 후 실제 쿼리로 교체)
|
||||
if (!teamId.isNullOrEmpty()) {
|
||||
fetchTeamIfNeeded(db, serverUrl, teamId)?.let {
|
||||
notificationData.putBundle("team", it)
|
||||
}
|
||||
}
|
||||
|
||||
// 채널 / 포스트 정보
|
||||
if (channelId != null && postId != null) {
|
||||
fetchMyChannel(db, channelId)?.let { notificationData.putBundle("channel", it) }
|
||||
|
||||
fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId)?.let {
|
||||
notificationData.putBundle("posts", it)
|
||||
}
|
||||
|
||||
// 스레드 (CRT 활성화 시)
|
||||
if (isCRTEnabled && !rootId.isNullOrEmpty()) {
|
||||
fetchThread(db, serverUrl, rootId)?.let {
|
||||
notificationData.putBundle("thread", it)
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 목록
|
||||
fetchNeededUsers(serverUrl, channelId)?.let {
|
||||
notificationData.putParcelableArray("users", it.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
result = notificationData
|
||||
|
||||
// Flutter가 초기화되지 않은 경우 DB에 저장 (다음 Flutter 부팅 시 로드)
|
||||
if (!isFlutterInit) {
|
||||
saveToDatabase(db, notificationData, teamId, channelId, isCRTEnabled)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Done processing push notification server=$serverUrl channel=$channelId ack=$ackId")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing push notification: ${e.message}")
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
db?.close()
|
||||
Log.i(TAG, "DONE fetching notification data")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 개별 데이터 페칭 (TODO: Room DAO 실제 쿼리로 교체) ──────────────
|
||||
|
||||
private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle? {
|
||||
// TODO: db.teamDao().getTeam(teamId)
|
||||
Log.i(TAG, "fetchTeamIfNeeded: teamId=$teamId (Room DAO 구현 필요)")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle? {
|
||||
// TODO: db.channelDao().getChannel(channelId)
|
||||
Log.i(TAG, "fetchMyChannel: channelId=$channelId (Room DAO 구현 필요)")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String,
|
||||
isCRTEnabled: Boolean, rootId: String?): Bundle? {
|
||||
// TODO: db.postDao().getPostsForChannel(channelId)
|
||||
Log.i(TAG, "fetchPosts: channelId=$channelId (Room DAO 구현 필요)")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle? {
|
||||
// TODO: db.threadDao().getThread(rootId)
|
||||
Log.i(TAG, "fetchThread: rootId=$rootId (Room DAO 구현 필요)")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetchNeededUsers(serverUrl: String, channelId: String): List<Bundle>? {
|
||||
// TODO: db.userDao().getUsersForChannel(channelId)
|
||||
Log.i(TAG, "fetchNeededUsers: channelId=$channelId (Room DAO 구현 필요)")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?,
|
||||
channelId: String?, isCRTEnabled: Boolean) {
|
||||
// TODO: Room DB에 알림 관련 데이터 저장 (Flutter 부팅 전 수신된 경우)
|
||||
Log.i(TAG, "saveToDatabase: channelId=$channelId (Room DAO 구현 필요)")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tokilabs.mattermost.helpers.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* 전역 Room DB. 서버 목록과 디바이스 토큰 등을 저장.
|
||||
* mattermost-mobile의 defaultDatabase (Global WatermelonDB) 대응.
|
||||
*/
|
||||
@Database(
|
||||
entities = [GlobalEntity::class, ServerEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class GlobalDatabase : RoomDatabase() {
|
||||
abstract fun globalDao(): GlobalDao
|
||||
abstract fun serversDao(): ServersDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var instance: GlobalDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): GlobalDatabase {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
GlobalDatabase::class.java,
|
||||
"mattermost_global.db"
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.tokilabs.mattermost.helpers.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
|
||||
// ─── Global table (deviceToken 등 전역 설정) ────────────────────────────────
|
||||
|
||||
@Entity(tableName = "Global")
|
||||
data class GlobalEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface GlobalDao {
|
||||
@Query("SELECT value FROM Global WHERE id = :id LIMIT 1")
|
||||
fun getValue(id: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: GlobalEntity)
|
||||
}
|
||||
|
||||
// ─── Servers table (등록된 서버 목록) ─────────────────────────────────────────
|
||||
|
||||
@Entity(tableName = "Servers")
|
||||
data class ServerEntity(
|
||||
@PrimaryKey val url: String,
|
||||
@ColumnInfo(name = "db_path") val dbPath: String,
|
||||
val identifier: String? = null
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface ServersDao {
|
||||
@Query("SELECT * FROM Servers WHERE url = :url LIMIT 1")
|
||||
fun getServer(url: String): ServerEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM Servers")
|
||||
fun count(): Int
|
||||
|
||||
@Query("SELECT url FROM Servers LIMIT 1")
|
||||
fun getFirstUrl(): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: ServerEntity)
|
||||
|
||||
@Query("SELECT * FROM Servers")
|
||||
fun getAll(): List<ServerEntity>
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tokilabs.mattermost.helpers.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* 서버별 Room DB. Config / User / System 테이블 포함.
|
||||
* mattermost-mobile의 서버별 WatermelonDB 대응.
|
||||
*/
|
||||
@Database(
|
||||
entities = [ConfigEntity::class, UserEntity::class, SystemEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class MattermostDatabase : RoomDatabase() {
|
||||
abstract fun configDao(): ConfigDao
|
||||
abstract fun userDao(): UserDao
|
||||
abstract fun systemDao(): SystemDao
|
||||
|
||||
// ─── JWT 검증용 편의 메서드 ───────────────────────────────────────────────
|
||||
|
||||
fun queryConfigServerVersion(): String? = configDao().getValue("Version")
|
||||
|
||||
fun queryConfigSigningKey(): String? = configDao().getValue("AsymmetricSigningPublicKey")
|
||||
|
||||
fun getLastPictureUpdate(userId: String): Double? {
|
||||
val id = if (userId == "me") systemDao().getValue("currentUserId") ?: userId else userId
|
||||
return userDao().getLastPictureUpdate(id)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private val instances = mutableMapOf<String, MattermostDatabase>()
|
||||
|
||||
fun getInstance(context: Context, serverUrl: String): MattermostDatabase {
|
||||
return instances.getOrPut(serverUrl) {
|
||||
val dbName = "mattermost_server_${serverUrl.hashCode()}.db"
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MattermostDatabase::class.java,
|
||||
dbName
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.tokilabs.mattermost.helpers.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
|
||||
// ─── Config table (서버 버전, 서명키 등) ──────────────────────────────────────
|
||||
|
||||
@Entity(tableName = "Config")
|
||||
data class ConfigEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val value: String?
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface ConfigDao {
|
||||
@Query("SELECT value FROM Config WHERE id = :id LIMIT 1")
|
||||
fun getValue(id: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: ConfigEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsertAll(entities: List<ConfigEntity>)
|
||||
}
|
||||
|
||||
// ─── User table (아바타 캐시용) ───────────────────────────────────────────────
|
||||
|
||||
@Entity(tableName = "User")
|
||||
data class UserEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@ColumnInfo(name = "last_picture_update") val lastPictureUpdate: Double = 0.0,
|
||||
val username: String? = null,
|
||||
val email: String? = null,
|
||||
@ColumnInfo(name = "first_name") val firstName: String? = null,
|
||||
@ColumnInfo(name = "last_name") val lastName: String? = null,
|
||||
val roles: String? = null
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface UserDao {
|
||||
@Query("SELECT last_picture_update FROM User WHERE id = :userId LIMIT 1")
|
||||
fun getLastPictureUpdate(userId: String): Double?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: UserEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsertAll(entities: List<UserEntity>)
|
||||
}
|
||||
|
||||
// ─── System table (currentUserId, currentTeamId 등) ─────────────────────────
|
||||
|
||||
@Entity(tableName = "System")
|
||||
data class SystemEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val value: String?
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface SystemDao {
|
||||
@Query("SELECT value FROM System WHERE id = :id LIMIT 1")
|
||||
fun getValue(id: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: SystemEntity)
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.nomadcode.nomadcode
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
BIN
android/app/src/main/res/drawable-hdpi/ic_notif_action_reply.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_notif_action_reply.png
Normal file
|
After Width: | Height: | Size: 348 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 348 B |
|
After Width: | Height: | Size: 610 B |
|
After Width: | Height: | Size: 833 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 610 B |
|
After Width: | Height: | Size: 833 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 481 B |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 847 B |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -1,3 +1,9 @@
|
|||
buildscript {
|
||||
dependencies {
|
||||
classpath("com.google.gms:google-services:4.4.2")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
|
|
|
|||
133
lib/main.dart
|
|
@ -1,52 +1,64 @@
|
|||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'services/push_notification_background.dart';
|
||||
import 'services/push_notification_service.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await Firebase.initializeApp();
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
|
||||
|
||||
final pushService = PushNotificationService();
|
||||
pushService.init();
|
||||
|
||||
runApp(MyApp(pushService: pushService));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
final PushNotificationService pushService;
|
||||
|
||||
const MyApp({super.key, required this.pushService});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
title: 'Mattermost',
|
||||
navigatorKey: _navigatorKey,
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
home: const MyHomePage(title: 'Mattermost'),
|
||||
builder: (context, child) {
|
||||
// 알림 탭 시 네비게이션 콜백 등록
|
||||
pushService.onNavigateToChannel = (serverUrl, channelId) {
|
||||
// TODO: 채널 화면으로 이동
|
||||
// _navigatorKey.currentState?.pushNamed(
|
||||
// '/channel',
|
||||
// arguments: {'serverUrl': serverUrl, 'channelId': channelId},
|
||||
// );
|
||||
print('[Nav] Navigate to channel: $channelId on $serverUrl');
|
||||
};
|
||||
pushService.onNavigateToThread = (serverUrl, rootId) {
|
||||
// TODO: 스레드 화면으로 이동
|
||||
// _navigatorKey.currentState?.pushNamed(
|
||||
// '/thread',
|
||||
// arguments: {'serverUrl': serverUrl, 'rootId': rootId},
|
||||
// );
|
||||
print('[Nav] Navigate to thread: $rootId on $serverUrl');
|
||||
};
|
||||
return child!;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
|
|
@ -54,68 +66,15 @@ class MyHomePage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
body: const Center(
|
||||
child: Text('Mattermost Flutter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
12
lib/services/push_notification_background.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
||||
/// 백그라운드 FCM 메시지 핸들러.
|
||||
/// main() 외부의 top-level 함수여야 함.
|
||||
/// push_notifications.ts의 백그라운드 처리 대응.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
// NOTE: 백그라운드에서는 네이티브 Android(MattermostFirebaseMessagingService)가
|
||||
// FCM을 직접 처리하므로 이 핸들러는 Flutter가 완전히 종료된 상태에서만 호출됨.
|
||||
// 필요 시 최소한의 작업만 수행.
|
||||
print('[PushNotification] Background message: ${message.messageId}');
|
||||
}
|
||||
233
lib/services/push_notification_service.dart
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import 'dart:async';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 알림 타입 상수.
|
||||
class PushNotificationType {
|
||||
static const String message = 'message';
|
||||
static const String clear = 'clear';
|
||||
static const String session = 'session';
|
||||
static const String tokenRefresh = 'token_refresh';
|
||||
static const String opened = 'opened';
|
||||
}
|
||||
|
||||
/// 알림 탭 이벤트 데이터.
|
||||
class NotificationOpenedEvent {
|
||||
final String? serverUrl;
|
||||
final String? channelId;
|
||||
final String? rootId;
|
||||
final bool isCRTEnabled;
|
||||
|
||||
const NotificationOpenedEvent({
|
||||
this.serverUrl,
|
||||
this.channelId,
|
||||
this.rootId,
|
||||
this.isCRTEnabled = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 푸시 알림 싱글톤 서비스.
|
||||
/// mattermost-mobile의 push_notifications.ts 싱글톤 대응.
|
||||
class PushNotificationService {
|
||||
static final PushNotificationService _instance =
|
||||
PushNotificationService._internal();
|
||||
factory PushNotificationService() => _instance;
|
||||
PushNotificationService._internal();
|
||||
|
||||
static const EventChannel _notificationChannel =
|
||||
EventChannel('com.tokilabs.mattermost/notifications');
|
||||
static const MethodChannel _actionChannel =
|
||||
MethodChannel('com.tokilabs.mattermost/notification_actions');
|
||||
|
||||
StreamSubscription? _channelSubscription;
|
||||
|
||||
/// 알림 수신 스트림 (포그라운드 메시지 등 UI에서 구독)
|
||||
final StreamController<Map<String, dynamic>> _notificationController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get onNotification =>
|
||||
_notificationController.stream;
|
||||
|
||||
/// 알림 탭 스트림 (채널/스레드 네비게이션용)
|
||||
final StreamController<NotificationOpenedEvent> _openedController =
|
||||
StreamController<NotificationOpenedEvent>.broadcast();
|
||||
Stream<NotificationOpenedEvent> get onNotificationOpened =>
|
||||
_openedController.stream;
|
||||
|
||||
// 네비게이션 콜백 (앱 라우터에서 등록)
|
||||
void Function(String serverUrl, String channelId)? onNavigateToChannel;
|
||||
void Function(String serverUrl, String rootId)? onNavigateToThread;
|
||||
|
||||
// ─── 초기화 / 해제 ────────────────────────────────────────────────────
|
||||
|
||||
void init() {
|
||||
_listenNativeChannel();
|
||||
_listenFcmTokenRefresh();
|
||||
_requestPermission();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_channelSubscription?.cancel();
|
||||
_notificationController.close();
|
||||
_openedController.close();
|
||||
}
|
||||
|
||||
// ─── 내부 초기화 ─────────────────────────────────────────────────────
|
||||
|
||||
void _listenNativeChannel() {
|
||||
_channelSubscription = _notificationChannel
|
||||
.receiveBroadcastStream()
|
||||
.listen(
|
||||
(dynamic event) {
|
||||
if (event is Map) {
|
||||
_processNotification(Map<String, dynamic>.from(event));
|
||||
}
|
||||
},
|
||||
onError: (error) => print('[PushNotification] EventChannel error: $error'),
|
||||
);
|
||||
}
|
||||
|
||||
void _listenFcmTokenRefresh() {
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
|
||||
await _saveDeviceToken(token);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _requestPermission() async {
|
||||
final settings = await FirebaseMessaging.instance.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
print('[PushNotification] Permission: ${settings.authorizationStatus}');
|
||||
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token != null) await _saveDeviceToken(token);
|
||||
}
|
||||
|
||||
// ─── 알림 처리 ───────────────────────────────────────────────────────
|
||||
|
||||
void _processNotification(Map<String, dynamic> data) {
|
||||
final type = data['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
case PushNotificationType.message:
|
||||
_handleMessageNotification(data);
|
||||
break;
|
||||
case PushNotificationType.clear:
|
||||
_handleClearNotification(data);
|
||||
break;
|
||||
case PushNotificationType.session:
|
||||
_handleSessionNotification(data);
|
||||
break;
|
||||
case PushNotificationType.tokenRefresh:
|
||||
final token = data['token'] as String?;
|
||||
if (token != null) _saveDeviceToken(token);
|
||||
break;
|
||||
case PushNotificationType.opened:
|
||||
_handleNotificationOpened(data);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!_notificationController.isClosed) {
|
||||
_notificationController.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMessageNotification(Map<String, dynamic> data) {
|
||||
final isUserInteraction = data['userInteraction'] == true;
|
||||
if (isUserInteraction) {
|
||||
_handleNotificationOpened(data);
|
||||
}
|
||||
// 포그라운드 메시지는 onNotification 스트림으로 UI에서 처리
|
||||
}
|
||||
|
||||
void _handleClearNotification(Map<String, dynamic> data) {
|
||||
print('[PushNotification] Clear: channelId=${data['channel_id']}');
|
||||
// TODO: 뱃지 카운트 초기화, 로컬 알림 상태 초기화
|
||||
}
|
||||
|
||||
void _handleSessionNotification(Map<String, dynamic> data) {
|
||||
print('[PushNotification] Session expired: serverUrl=${data['server_url']}');
|
||||
// TODO: 해당 서버 로그아웃 처리
|
||||
// clearAuthToken(data['server_url'])
|
||||
}
|
||||
|
||||
/// 알림 탭 처리 - 채널 또는 스레드로 이동.
|
||||
/// mattermost-mobile의 openNotification() + pushNotificationEntry() 대응.
|
||||
void _handleNotificationOpened(Map<String, dynamic> data) {
|
||||
final serverUrl = data['server_url'] as String?;
|
||||
final channelId = data['channel_id'] as String?;
|
||||
final rootId = data['root_id'] as String?;
|
||||
final isCRTEnabled = data['is_crt_enabled'] == 'true';
|
||||
|
||||
print('[PushNotification] Opened: channelId=$channelId rootId=$rootId');
|
||||
|
||||
final event = NotificationOpenedEvent(
|
||||
serverUrl: serverUrl,
|
||||
channelId: channelId,
|
||||
rootId: rootId,
|
||||
isCRTEnabled: isCRTEnabled,
|
||||
);
|
||||
|
||||
if (!_openedController.isClosed) {
|
||||
_openedController.add(event);
|
||||
}
|
||||
|
||||
// 등록된 네비게이션 콜백 호출
|
||||
if (serverUrl == null) return;
|
||||
|
||||
if (isCRTEnabled && rootId != null && rootId.isNotEmpty) {
|
||||
onNavigateToThread?.call(serverUrl, rootId);
|
||||
} else if (channelId != null) {
|
||||
onNavigateToChannel?.call(serverUrl, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 인증 토큰 관리 ──────────────────────────────────────────────────
|
||||
|
||||
/// 로그인 성공 시 호출. 서버 URL + Bearer 토큰을 네이티브에 저장.
|
||||
/// 이후 ReceiptDelivery(ACK), NotificationReplyBroadcastReceiver(답장) API 호출에 사용.
|
||||
Future<void> setAuthToken(String serverUrl, String token,
|
||||
{String? identifier}) async {
|
||||
try {
|
||||
await _actionChannel.invokeMethod('setAuthToken', {
|
||||
'serverUrl': serverUrl,
|
||||
'token': token,
|
||||
if (identifier != null) 'identifier': identifier,
|
||||
});
|
||||
print('[PushNotification] Auth token saved for $serverUrl');
|
||||
} catch (e) {
|
||||
print('[PushNotification] Failed to save auth token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그아웃 시 호출.
|
||||
Future<void> clearAuthToken(String serverUrl) async {
|
||||
try {
|
||||
await _actionChannel.invokeMethod('clearAuthToken', {'serverUrl': serverUrl});
|
||||
} catch (e) {
|
||||
print('[PushNotification] Failed to clear auth token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// FCM 디바이스 토큰 저장 (네이티브 DB에 android-react-native-v2:{token}: 형식으로 저장).
|
||||
Future<void> _saveDeviceToken(String token) async {
|
||||
try {
|
||||
const prefix = 'android-react-native-v2';
|
||||
final formattedToken = '$prefix:$token:';
|
||||
await _actionChannel
|
||||
.invokeMethod('saveDeviceToken', {'token': formattedToken});
|
||||
print('[PushNotification] Device token saved');
|
||||
} catch (e) {
|
||||
print('[PushNotification] Failed to save device token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getDeviceToken() async {
|
||||
try {
|
||||
return await _actionChannel.invokeMethod<String>('getDeviceToken');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,12 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_local_notifications
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
}
|
||||
|
|
|
|||
191
pubspec.lock
|
|
@ -1,6 +1,22 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.59"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -49,6 +65,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -57,6 +81,62 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.15.2"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.24.1"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.10"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.10"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.10.10"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
@ -70,11 +150,56 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -111,10 +236,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -139,6 +264,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -188,10 +329,26 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -208,6 +365,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.22.0"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ dependencies:
|
|||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# Firebase
|
||||
firebase_core: ^3.13.0
|
||||
firebase_messaging: ^15.2.5
|
||||
|
||||
# Local notifications (foreground display)
|
||||
flutter_local_notifications: ^18.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
firebase_core
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||