Update project files
This commit is contained in:
parent
af6b2962a2
commit
7953563443
22 changed files with 446 additions and 82 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
12
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Flutter (Android)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart",
|
||||
"deviceId": "R3CX80DTM6W"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.google.gms:google-services:4.4.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
|
|
|||
43
ios/Podfile
Normal file
43
ios/Podfile
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
137
lib/services/mattermost_auth_service.dart
Normal file
137
lib/services/mattermost_auth_service.dart
Normal 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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
|
|
|||
42
macos/Podfile
Normal file
42
macos/Podfile
Normal 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
|
||||
|
|
@ -185,7 +185,7 @@ packages:
|
|||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue