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
This commit is contained in:
toki 2026-03-21 15:06:19 +09:00
parent 73375477ba
commit 9a39917a2c
67 changed files with 2469 additions and 101 deletions

View file

@ -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")
}

View 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"
}

View file

@ -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

View file

@ -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) {}
}

View file

@ -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)
)
}
}
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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));
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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))
}
}

View file

@ -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);
}
}

View file

@ -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
}
}
}

View file

@ -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"
}
}

View file

@ -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}")
}
}
}

View file

@ -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 구현 필요)")
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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>
}

View file

@ -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()
}
}
}
}

View file

@ -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)
}

View file

@ -1,5 +0,0 @@
package com.nomadcode.nomadcode
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -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>

View file

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,3 +1,9 @@
buildscript {
dependencies {
classpath("com.google.gms:google-services:4.4.2")
}
}
allprojects {
repositories {
google()

View file

@ -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'),
),
);
}

View 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}');
}

View 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;
}
}
}

View file

@ -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"))
}

View file

@ -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"

View file

@ -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

View file

@ -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"));
}

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
firebase_core
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST