Update project files

This commit is contained in:
toki 2026-03-22 20:49:05 +09:00
parent af6b2962a2
commit 7953563443
22 changed files with 446 additions and 82 deletions

4
.gitignore vendored
View file

@ -63,4 +63,6 @@ lib/generated_plugin_registrant.dart
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
# Mattermost credentials
assets/mattermost_credentials.json

12
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Flutter (Android)",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"deviceId": "R3CX80DTM6W"
}
]
}

View file

@ -15,6 +15,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@ -46,6 +47,7 @@ flutter {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
// Firebase BOM (버전 통합 관리)
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
@ -63,8 +65,8 @@ dependencies {
// 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")
// Room DB (로컬 캐시용, Kotlin 2.2.0 지원을 위해 최신 RC 버전 사용)
implementation("androidx.room:room-runtime:2.7.0-rc01")
implementation("androidx.room:room-ktx:2.7.0-rc01")
kapt("androidx.room:room-compiler:2.7.0-rc01")
}

View file

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
@ -37,7 +38,22 @@
android:name="flutterEmbedding"
android:value="2" />
<!-- Firebase Messaging Service -->
<!-- Flutter firebase_messaging 플러그인 컴포넌트 비활성화 (네이티브 서비스 사용) -->
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingBackgroundService"
tools:node="remove" />
<provider
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingInitProvider"
android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
tools:node="remove" />
<!-- Firebase Messaging Service (네이티브) -->
<service
android:name=".MattermostFirebaseMessagingService"
android:exported="false">

View file

@ -69,6 +69,20 @@ class MainActivity : FlutterActivity() {
result.error("INVALID_ARG", "serverUrl or token is null", null)
}
}
// 서버의 JWT 서명 검증용 공개키 저장
"setSigningKey" -> {
val serverUrl = call.argument<String>("serverUrl")
val signingKey = call.argument<String>("signingKey")
if (serverUrl != null && signingKey != null) {
getSharedPreferences("mattermost_signing", MODE_PRIVATE)
.edit()
.putString("signing_key_$serverUrl", signingKey)
.apply()
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl or signingKey is null", null)
}
}
// 로그아웃 시 토큰 제거
"clearAuthToken" -> {
val serverUrl = call.argument<String>("serverUrl")

View file

@ -4,11 +4,14 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
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.DatabaseHelper
import com.tokilabs.mattermost.helpers.Network
import com.tokilabs.mattermost.helpers.NotificationHelper
import com.tokilabs.mattermost.helpers.PushNotificationDataHelper
@ -43,8 +46,10 @@ class MattermostFirebaseMessagingService : FirebaseMessagingService() {
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))
// Flutter EventChannel이 준비된 경우 토큰 전달 (Main 스레드에서 실행)
Handler(Looper.getMainLooper()).post {
eventSink?.success(mapOf("type" to "token_refresh", "token" to token))
}
}
/**
@ -54,35 +59,65 @@ class MattermostFirebaseMessagingService : FirebaseMessagingService() {
@OptIn(DelicateCoroutinesApi::class)
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.i(TAG, ">>> onMessageReceived ENTER, dataSize=${remoteMessage.data.size}")
val data = remoteMessage.data
if (data.isEmpty()) return
try {
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()
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 = try {
NotificationHelper.getNotificationId(bundle)
} catch (e: Exception) {
Log.e(TAG, "getNotificationId failed: ${e.message}")
System.currentTimeMillis().toInt()
}
// server_url 해석: 직접 포함 → server_id로 DB 조회 → 단일 서버 fallback
var serverUrl = bundle.getString("server_url")
if (serverUrl == null) {
try {
val dbHelper = DatabaseHelper.getInstance()
val serverId = bundle.getString("server_id")
if (serverId != null && dbHelper != null) {
serverUrl = dbHelper.getServerUrlForIdentifier(serverId)
}
if (serverUrl == null) {
serverUrl = dbHelper?.onlyServerUrl
}
} catch (e: Exception) {
Log.e(TAG, "DB lookup failed: ${e.message}")
}
if (serverUrl != null) {
bundle.putString("server_url", serverUrl)
}
}
Log.i(TAG, "onMessageReceived type=$type channelId=$channelId ackId=$ackId serverUrl=$serverUrl")
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()
}
}
} catch (e: Exception) {
Log.e(TAG, ">>> onMessageReceived CRASH: ${e.message}")
e.printStackTrace()
}
}
@ -174,7 +209,9 @@ class MattermostFirebaseMessagingService : FirebaseMessagingService() {
private fun notifyReceivedToFlutter(bundle: Bundle) {
val data = bundleToMap(bundle)
Log.i(TAG, "Sending notification event to Flutter: type=${data["type"]}")
eventSink?.success(data)
Handler(Looper.getMainLooper()).post {
eventSink?.success(data)
}
}
/**

View file

@ -36,7 +36,7 @@ public class ReceiptDelivery {
body.put("post_id", postId);
body.put("is_id_loaded", isIdLoaded);
try (Response response = Network.INSTANCE.postSync(serverUrl, "api/v4/notifications/ack", body)) {
try (Response response = Network.INSTANCE.postSync(serverUrl, "api/v4/notifications/ack", body, null)) {
String responseBody = Objects.requireNonNull(response.body()).string();
JSONObject jsonResponse = new JSONObject(responseBody);
return parseAckResponse(jsonResponse);

View file

@ -144,20 +144,20 @@ public class CustomPushNotificationHelper {
}
if (serverUrl == null) {
Log.i(TAG, "No server_url for server_id");
return false;
Log.i(TAG, "No server_url available, skipping signature check");
return true;
}
DatabaseHelper dbHelper = DatabaseHelper.getInstance();
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper == null) {
Log.i(TAG, "Cannot access the database");
return false;
Log.i(TAG, "Cannot access the database, skipping signature check");
return true;
}
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
if (db == null) {
Log.i(TAG, "Cannot access the server database");
return false;
Log.i(TAG, "Cannot access the server database, skipping signature check");
return true;
}
if ("NO_SIGNATURE".equals(signature)) {
@ -201,8 +201,13 @@ public class CustomPushNotificationHelper {
String signingKey = db.queryConfigSigningKey();
if (signingKey == null) {
Log.i(TAG, "No signing key");
return false;
// Room DB에 없으면 SharedPreferences에서 조회
signingKey = context.getSharedPreferences("mattermost_signing", Context.MODE_PRIVATE)
.getString("signing_key_" + serverUrl, null);
}
if (signingKey == null) {
Log.i(TAG, "No signing key, skipping signature check");
return true;
}
try {
@ -220,7 +225,7 @@ public class CustomPushNotificationHelper {
Log.i(TAG, "Wrong stored device token format");
return false;
}
String deviceToken = tokenParts[1].substring(0, tokenParts[1].length() - 1);
String deviceToken = tokenParts[1];
if (deviceToken.isEmpty()) {
Log.i(TAG, "Empty stored device token");
return false;
@ -483,7 +488,7 @@ public class CustomPushNotificationHelper {
Request request = new Request.Builder().url(urlOverride).build();
response = client.newCall(request).execute();
} else {
DatabaseHelper dbHelper = DatabaseHelper.getInstance();
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper != null) {
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
if (db != null) {
@ -499,7 +504,7 @@ public class CustomPushNotificationHelper {
}
bitmapCache.removeBitmap(userId, serverUrl);
String url = serverUrl.trimEnd('/') + "/api/v4/users/" + userId + "/image";
String url = serverUrl.replaceAll("/+$", "") + "/api/v4/users/" + userId + "/image";
response = Network.INSTANCE.getSync(serverUrl, "api/v4/users/" + userId + "/image", null);
}

View file

@ -40,7 +40,7 @@ object Network {
/**
* 동기 POST 요청 (코루틴 내부에서 사용)
*/
fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map<String, String> = emptyMap()): Response {
fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map<String, String>? = null): Response {
val url = buildUrl(serverUrl, endpoint)
val requestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE)
val requestBuilder = Request.Builder()
@ -49,7 +49,7 @@ object Network {
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST $url")
return client.newCall(requestBuilder.build()).execute()
@ -59,7 +59,7 @@ object Network {
* 비동기 POST 요청 (BroadcastReceiver 등에서 사용)
*/
fun post(serverUrl: String, endpoint: String, body: JSONObject,
headers: Map<String, String> = emptyMap(),
headers: Map<String, String>? = null,
onSuccess: (JSONObject?) -> Unit,
onFailure: (Exception) -> Unit) {
val url = buildUrl(serverUrl, endpoint)
@ -70,7 +70,7 @@ object Network {
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST async $url")
client.newCall(requestBuilder.build()).enqueue(object : okhttp3.Callback {

View file

@ -31,7 +31,7 @@ abstract class MattermostDatabase : RoomDatabase() {
}
companion object {
@Volatile private val instances = mutableMapOf<String, MattermostDatabase>()
@Volatile private var instances = mutableMapOf<String, MattermostDatabase>()
fun getInstance(context: Context, serverUrl: String): MattermostDatabase {
return instances.getOrPut(serverUrl) {

View file

@ -1,4 +1,8 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.google.gms:google-services:4.4.2")
}

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View file

@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View file

@ -1,10 +1,13 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'services/mattermost_auth_service.dart';
import 'services/push_notification_background.dart';
import 'services/push_notification_service.dart';
final _navigatorKey = GlobalKey<NavigatorState>();
final _pushService = PushNotificationService();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -13,16 +16,21 @@ void main() async {
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
final pushService = PushNotificationService();
pushService.init();
_pushService.init();
runApp(MyApp(pushService: pushService));
// Mattermost + FCM
final authService = MattermostAuthService(_pushService);
try {
await authService.autoLoginAndRegister();
} catch (e) {
print('[Main] Mattermost auto-login failed: $e');
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
final PushNotificationService pushService;
const MyApp({super.key, required this.pushService});
const MyApp({super.key});
@override
Widget build(BuildContext context) {
@ -34,21 +42,10 @@ class MyApp extends StatelessWidget {
),
home: const MyHomePage(title: 'Mattermost'),
builder: (context, child) {
//
pushService.onNavigateToChannel = (serverUrl, channelId) {
// TODO:
// _navigatorKey.currentState?.pushNamed(
// '/channel',
// arguments: {'serverUrl': serverUrl, 'channelId': channelId},
// );
_pushService.onNavigateToChannel = (serverUrl, channelId) {
print('[Nav] Navigate to channel: $channelId on $serverUrl');
};
pushService.onNavigateToThread = (serverUrl, rootId) {
// TODO:
// _navigatorKey.currentState?.pushNamed(
// '/thread',
// arguments: {'serverUrl': serverUrl, 'rootId': rootId},
// );
_pushService.onNavigateToThread = (serverUrl, rootId) {
print('[Nav] Navigate to thread: $rootId on $serverUrl');
};
return child!;
@ -66,6 +63,37 @@ class MyHomePage extends StatefulWidget {
}
class _MyHomePageState extends State<MyHomePage> {
StreamSubscription? _notifSub;
@override
void initState() {
super.initState();
_notifSub = _pushService.onNotification.listen((data) {
if (data['type'] != 'message') return;
final message = data['message'] as String? ?? '';
final channel = data['channel_name'] as String? ?? '';
final sender = data['sender_name'] as String? ?? '';
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
sender.isNotEmpty ? '[$channel] $sender: $message' : message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
});
}
@override
void dispose() {
_notifSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(

View file

@ -0,0 +1,137 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'push_notification_service.dart';
/// Mattermost + FCM .
class MattermostAuthService {
final PushNotificationService _pushService;
String? _serverUrl;
String? _authToken;
String? _userId;
String? _sessionId;
String? get serverUrl => _serverUrl;
bool get isLoggedIn => _authToken != null;
MattermostAuthService(this._pushService);
/// assets/mattermost_credentials.json FCM .
Future<void> autoLoginAndRegister() async {
final creds = await _loadCredentials();
_serverUrl = creds['serverUrl']!;
print('[MattermostAuth] Logging in to $_serverUrl ...');
await _login(creds['loginId']!, creds['password']!);
// FCM ,
final existingToken = await _pushService.getDeviceToken();
if (existingToken != null && existingToken.isNotEmpty) {
print('[MattermostAuth] FCM token already available, registering ...');
await _registerDeviceToken(existingToken);
} else {
print('[MattermostAuth] FCM token not ready yet, waiting for callback ...');
}
// FCM /
_pushService.onDeviceTokenReady = (deviceToken) async {
print('[MattermostAuth] FCM token ready, registering with server ...');
await _registerDeviceToken(deviceToken);
};
print('[MattermostAuth] Auto login & FCM registration complete.');
}
Future<Map<String, String>> _loadCredentials() async {
final jsonStr =
await rootBundle.loadString('assets/mattermost_credentials.json');
final map = json.decode(jsonStr) as Map<String, dynamic>;
return {
'serverUrl': map['serverUrl'] as String,
'loginId': map['loginId'] as String,
'password': map['password'] as String,
};
}
/// POST /api/v4/users/login Token .
Future<void> _login(String loginId, String password) async {
final url = Uri.parse('$_serverUrl/api/v4/users/login');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: json.encode({'login_id': loginId, 'password': password}),
);
if (response.statusCode != 200) {
throw Exception(
'[MattermostAuth] Login failed (${response.statusCode}): ${response.body}',
);
}
_authToken = response.headers['token'];
if (_authToken == null) {
throw Exception('[MattermostAuth] Login response missing Token header');
}
final body = json.decode(response.body) as Map<String, dynamic>;
_userId = body['id'] as String?;
_sessionId = body['session_id'] as String? ?? body['id'] as String?;
print('[MattermostAuth] Login OK - userId=$_userId sessionId=$_sessionId');
// (ACK, )
await _pushService.setAuthToken(_serverUrl!, _authToken!);
// config에서 signing key
await _fetchAndStoreSigningKey();
}
/// GET /api/v4/config/client?format=old AsymmetricSigningPublicKey를 .
Future<void> _fetchAndStoreSigningKey() async {
try {
final url = Uri.parse('$_serverUrl/api/v4/config/client?format=old');
final response = await http.get(
url,
headers: {'Authorization': 'Bearer $_authToken'},
);
if (response.statusCode != 200) {
print('[MattermostAuth] Failed to fetch config (${response.statusCode})');
return;
}
final config = json.decode(response.body) as Map<String, dynamic>;
final signingKey = config['AsymmetricSigningPublicKey'] as String?;
if (signingKey != null && signingKey.isNotEmpty) {
await _pushService.setSigningKey(_serverUrl!, signingKey);
print('[MattermostAuth] Signing key stored.');
} else {
print('[MattermostAuth] No signing key in server config.');
}
} catch (e) {
print('[MattermostAuth] Failed to fetch signing key: $e');
}
}
/// PUT /api/v4/users/sessions/device device_id .
Future<void> _registerDeviceToken(String deviceToken) async {
final url =
Uri.parse('$_serverUrl/api/v4/users/sessions/device');
final response = await http.put(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_authToken',
},
body: json.encode({'device_id': deviceToken}),
);
if (response.statusCode == 200) {
print('[MattermostAuth] FCM device token registered successfully.');
} else {
print(
'[MattermostAuth] FCM registration failed (${response.statusCode}): ${response.body}',
);
}
}
}

View file

@ -57,6 +57,9 @@ class PushNotificationService {
void Function(String serverUrl, String channelId)? onNavigateToChannel;
void Function(String serverUrl, String rootId)? onNavigateToThread;
// FCM /
void Function(String deviceToken)? onDeviceTokenReady;
// /
void init() {
@ -193,7 +196,7 @@ class PushNotificationService {
await _actionChannel.invokeMethod('setAuthToken', {
'serverUrl': serverUrl,
'token': token,
if (identifier != null) 'identifier': identifier,
'identifier': identifier,
});
print('[PushNotification] Auth token saved for $serverUrl');
} catch (e) {
@ -210,14 +213,28 @@ class PushNotificationService {
}
}
/// FCM ( DB에 android-react-native-v2:{token}: ).
/// JWT .
Future<void> setSigningKey(String serverUrl, String signingKey) async {
try {
await _actionChannel.invokeMethod('setSigningKey', {
'serverUrl': serverUrl,
'signingKey': signingKey,
});
} catch (e) {
print('[PushNotification] Failed to save signing key: $e');
}
}
/// FCM ( DB에 android_rn-v2:{token} ).
Future<void> _saveDeviceToken(String token) async {
try {
const prefix = 'android-react-native-v2';
final formattedToken = '$prefix:$token:';
const prefix = 'android_rn';
final formattedToken = '$prefix-v2:$token';
await _actionChannel
.invokeMethod('saveDeviceToken', {'token': formattedToken});
print('[PushNotification] Device token saved');
//
onDeviceTokenReady?.call(formattedToken);
} catch (e) {
print('[PushNotification] Failed to save device token: $e');
}

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

42
macos/Podfile Normal file
View file

@ -0,0 +1,42 @@
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

View file

@ -185,7 +185,7 @@ packages:
source: sdk
version: "0.0.0"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View file

@ -39,6 +39,9 @@ dependencies:
firebase_core: ^3.13.0
firebase_messaging: ^15.2.5
# HTTP client
http: ^1.3.0
# Local notifications (foreground display)
flutter_local_notifications: ^18.0.1
@ -64,10 +67,8 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/mattermost_credentials.json
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images