diff --git a/.gitignore b/.gitignore index 931d8c5..0647b05 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +# Mattermost credentials +assets/mattermost_credentials.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3ced9ab --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Flutter (Android)", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + "deviceId": "R3CX80DTM6W" + } + ] +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b9a85ca..4d8ff2f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4350882..c7be139 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -37,7 +38,22 @@ android:name="flutterEmbedding" android:value="2" /> - + + + + + + + diff --git a/android/app/src/main/java/com/tokilabs/mattermost/MainActivity.kt b/android/app/src/main/java/com/tokilabs/mattermost/MainActivity.kt index c3809a8..2b59531 100644 --- a/android/app/src/main/java/com/tokilabs/mattermost/MainActivity.kt +++ b/android/app/src/main/java/com/tokilabs/mattermost/MainActivity.kt @@ -69,6 +69,20 @@ class MainActivity : FlutterActivity() { result.error("INVALID_ARG", "serverUrl or token is null", null) } } + // 서버의 JWT 서명 검증용 공개키 저장 + "setSigningKey" -> { + val serverUrl = call.argument("serverUrl") + val signingKey = call.argument("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("serverUrl") diff --git a/android/app/src/main/java/com/tokilabs/mattermost/MattermostFirebaseMessagingService.kt b/android/app/src/main/java/com/tokilabs/mattermost/MattermostFirebaseMessagingService.kt index ae27a94..fade1aa 100644 --- a/android/app/src/main/java/com/tokilabs/mattermost/MattermostFirebaseMessagingService.kt +++ b/android/app/src/main/java/com/tokilabs/mattermost/MattermostFirebaseMessagingService.kt @@ -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) + } } /** diff --git a/android/app/src/main/java/com/tokilabs/mattermost/ReceiptDelivery.java b/android/app/src/main/java/com/tokilabs/mattermost/ReceiptDelivery.java index eeb25fd..a5918bc 100644 --- a/android/app/src/main/java/com/tokilabs/mattermost/ReceiptDelivery.java +++ b/android/app/src/main/java/com/tokilabs/mattermost/ReceiptDelivery.java @@ -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); diff --git a/android/app/src/main/java/com/tokilabs/mattermost/helpers/CustomPushNotificationHelper.java b/android/app/src/main/java/com/tokilabs/mattermost/helpers/CustomPushNotificationHelper.java index fe5749e..e445a87 100644 --- a/android/app/src/main/java/com/tokilabs/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/tokilabs/mattermost/helpers/CustomPushNotificationHelper.java @@ -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); } diff --git a/android/app/src/main/java/com/tokilabs/mattermost/helpers/Network.kt b/android/app/src/main/java/com/tokilabs/mattermost/helpers/Network.kt index d846946..286db8c 100644 --- a/android/app/src/main/java/com/tokilabs/mattermost/helpers/Network.kt +++ b/android/app/src/main/java/com/tokilabs/mattermost/helpers/Network.kt @@ -40,7 +40,7 @@ object Network { /** * 동기 POST 요청 (코루틴 내부에서 사용) */ - fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map = emptyMap()): Response { + fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map? = 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 = emptyMap(), + headers: Map? = 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 { diff --git a/android/app/src/main/java/com/tokilabs/mattermost/helpers/db/MattermostDatabase.kt b/android/app/src/main/java/com/tokilabs/mattermost/helpers/db/MattermostDatabase.kt index ac59784..463d9d0 100644 --- a/android/app/src/main/java/com/tokilabs/mattermost/helpers/db/MattermostDatabase.kt +++ b/android/app/src/main/java/com/tokilabs/mattermost/helpers/db/MattermostDatabase.kt @@ -31,7 +31,7 @@ abstract class MattermostDatabase : RoomDatabase() { } companion object { - @Volatile private val instances = mutableMapOf() + @Volatile private var instances = mutableMapOf() fun getInstance(context: Context, serverUrl: String): MattermostDatabase { return instances.getOrPut(serverUrl) { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 3fc4be3..5813b6d 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,8 @@ buildscript { + repositories { + google() + mavenCentral() + } dependencies { classpath("com.google.gms:google-services:4.4.2") } diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -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 diff --git a/lib/main.dart b/lib/main.dart index ad3fa12..c5f1992 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); +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 { + 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( diff --git a/lib/services/mattermost_auth_service.dart b/lib/services/mattermost_auth_service.dart new file mode 100644 index 0000000..19ba2f0 --- /dev/null +++ b/lib/services/mattermost_auth_service.dart @@ -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 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> _loadCredentials() async { + final jsonStr = + await rootBundle.loadString('assets/mattermost_credentials.json'); + final map = json.decode(jsonStr) as Map; + return { + 'serverUrl': map['serverUrl'] as String, + 'loginId': map['loginId'] as String, + 'password': map['password'] as String, + }; + } + + /// POST /api/v4/users/login → Token 헤더에서 인증 토큰 추출. + Future _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; + _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 _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; + 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 _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}', + ); + } + } + +} diff --git a/lib/services/push_notification_service.dart b/lib/services/push_notification_service.dart index 3c97dc5..31a575f 100644 --- a/lib/services/push_notification_service.dart +++ b/lib/services/push_notification_service.dart @@ -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 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 _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'); } diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -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 diff --git a/pubspec.lock b/pubspec.lock index 4694982..5046551 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,7 +185,7 @@ packages: source: sdk version: "0.0.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index bdf0af2..ee5a5af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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