diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d4b4687d..f9d3e89f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.twix.koin) alias(libs.plugins.google.firebase.crashlytics) alias(libs.plugins.google.services) + alias(libs.plugins.twix.kermit) } val localPropertiesFile = project.rootProject.file("local.properties") @@ -64,10 +65,13 @@ dependencies { implementation(projects.feature.goalEditor) implementation(projects.feature.goalManage) implementation(projects.feature.settings) + implementation(projects.core.notification) + implementation(projects.core.navigationContract) // Firebase implementation(platform(libs.google.firebase.bom)) implementation(libs.google.firebase.crashlytics) + implementation(libs.google.firebase.messaging) implementation(libs.kakao.user) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e1e1e5e..daff8b0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + + + + + + diff --git a/app/src/main/java/com/yapp/twix/TwixApplication.kt b/app/src/main/java/com/yapp/twix/TwixApplication.kt index e148eaa0..396a0d4c 100644 --- a/app/src/main/java/com/yapp/twix/TwixApplication.kt +++ b/app/src/main/java/com/yapp/twix/TwixApplication.kt @@ -1,10 +1,15 @@ package com.yapp.twix import android.app.Application +import co.touchlab.kermit.Logger import com.kakao.sdk.common.KakaoSdk +import com.twix.notification.token.NotificationTokenRegistrar import com.yapp.twix.di.initKoin +import org.koin.java.KoinJavaComponent.getKoin class TwixApplication : Application() { + private val logger = Logger.withTag("TwixApplication") + override fun onCreate() { super.onCreate() @@ -12,5 +17,11 @@ class TwixApplication : Application() { context = this, ) KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) + + try { + getKoin().get().registerCurrentToken() + } catch (e: Exception) { + logger.e(e) { "FCM token 등록 실패" } + } } } diff --git a/app/src/main/java/com/yapp/twix/di/CoroutineModule.kt b/app/src/main/java/com/yapp/twix/di/CoroutineModule.kt new file mode 100644 index 00000000..dde7b6f4 --- /dev/null +++ b/app/src/main/java/com/yapp/twix/di/CoroutineModule.kt @@ -0,0 +1,18 @@ +package com.yapp.twix.di + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.core.qualifier.named +import org.koin.dsl.module + +/** + * 전역 코루틴이 필요한 상황 + * · 전역/장수/인프라 객체(fcm 토큰 등록 객체, 알림 딥링크 네비게이션 처리 객체 등)가 독립적인 코루틴을 생성하면 수명 제어가 안됨 + * */ +val coroutineModule = + module { + single(named("AppScope")) { + CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + } diff --git a/app/src/main/java/com/yapp/twix/di/InitKoin.kt b/app/src/main/java/com/yapp/twix/di/InitKoin.kt index 296ee7e7..2253f157 100644 --- a/app/src/main/java/com/yapp/twix/di/InitKoin.kt +++ b/app/src/main/java/com/yapp/twix/di/InitKoin.kt @@ -4,6 +4,7 @@ import android.content.Context import com.twix.data.di.dataModule import com.twix.datastore.di.dataStoreModule import com.twix.network.di.networkModule +import com.twix.notification.di.notificationModule import com.twix.ui.di.imageModule import com.twix.util.di.utilModule import org.koin.android.ext.koin.androidContext @@ -28,6 +29,8 @@ fun initKoin( add(appModule) add(utilModule) add(imageModule) + add(notificationModule) + add(coroutineModule) }, ) } diff --git a/app/src/main/java/com/yapp/twix/main/MainActivity.kt b/app/src/main/java/com/yapp/twix/main/MainActivity.kt index 1137ae12..81ed495e 100644 --- a/app/src/main/java/com/yapp/twix/main/MainActivity.kt +++ b/app/src/main/java/com/yapp/twix/main/MainActivity.kt @@ -1,9 +1,14 @@ package com.yapp.twix.main +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -11,26 +16,38 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat import com.twix.designsystem.components.toast.ToastHost import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.theme.TwixTheme import com.twix.navigation.AppNavHost +import com.twix.navigation_contract.NotificationLaunchEventSource import org.koin.android.ext.android.inject +import org.koin.compose.koinInject import kotlin.getValue class MainActivity : ComponentActivity() { + private val notificationLaunchEventSource: NotificationLaunchEventSource by inject() + private val requestNotificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + handleNotificationIntent(intent) enableEdgeToEdge() setContent { - val toastManager by inject() + val toastManager: ToastManager = koinInject() WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = true + LaunchedEffect(Unit) { + requestNotificationPermissionIfNeeded() + } + TwixTheme { Box( modifier = @@ -40,7 +57,7 @@ class MainActivity : ComponentActivity() { WindowInsets.systemBars.only(WindowInsetsSides.Vertical), ), ) { - AppNavHost() + AppNavHost(notificationLaunchEventSource = notificationLaunchEventSource) ToastHost( toastManager = toastManager, @@ -52,4 +69,28 @@ class MainActivity : ComponentActivity() { } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleNotificationIntent(intent) + } + + private fun handleNotificationIntent(intent: Intent?) { + notificationLaunchEventSource.dispatchFromIntent(intent) + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + + val isGranted = + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + + if (isGranted) return + + requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } } diff --git a/app/src/main/java/com/yapp/twix/service/TwixFirebaseMessagingService.kt b/app/src/main/java/com/yapp/twix/service/TwixFirebaseMessagingService.kt new file mode 100644 index 00000000..61e3f5e9 --- /dev/null +++ b/app/src/main/java/com/yapp/twix/service/TwixFirebaseMessagingService.kt @@ -0,0 +1,109 @@ +package com.yapp.twix.service + +import android.Manifest +import android.app.PendingIntent +import android.content.Intent +import androidx.annotation.RequiresPermission +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri +import co.touchlab.kermit.Logger +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.twix.designsystem.R +import com.twix.notification.channel.TwixNotificationChannelManager +import com.twix.notification.model.PushPayload +import com.twix.notification.model.toTwixPushPayload +import com.twix.notification.routing.NotificationLaunchDispatcher +import com.twix.notification.token.NotificationTokenRegistrar +import com.yapp.twix.main.MainActivity +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class TwixFirebaseMessagingService : + FirebaseMessagingService(), + KoinComponent { + private val logger = Logger.withTag("TwixFirebaseMessagingService") + private val tokenRegistrar: NotificationTokenRegistrar by inject() + private val notificationChannelManager: TwixNotificationChannelManager by inject() + + override fun onNewToken(token: String) { + super.onNewToken(token) + // 토큰 갱신 시 재등록 + tokenRegistrar.registerFcmToken(token) + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + val payload = message.data.toTwixPushPayload() + + // 앱 실행 중에 토스트나 인앱 배너를 렌더링할 때 여기에서 분기처리하면 됨 + showSystemNotification(payload) + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private fun showSystemNotification(payload: PushPayload) { + val manager = NotificationManagerCompat.from(this) + if (!manager.areNotificationsEnabled()) return + + notificationChannelManager.ensureDefaultChannel() + + val intent = + Intent(this, MainActivity::class.java).apply { + action = NotificationLaunchDispatcher.ACTION_NOTIFICATION_CLICK + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(NotificationLaunchDispatcher.EXTRA_DEEP_LINK, payload.deepLink) + putExtra(NotificationLaunchDispatcher.EXTRA_FROM_PUSH_CLICK, true) + } + + val stableId = payload.resolveStableNotificationId() + val pendingIntent = + PendingIntent.getActivity( + this, + stableId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = + NotificationCompat + .Builder(this, TwixNotificationChannelManager.CHANNEL_DEFAULT) + .setSmallIcon(R.drawable.ic_app_logo) + .setContentTitle(payload.title ?: getString(com.yapp.twix.R.string.app_name)) + .setContentText(payload.body.orEmpty()) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + NotificationManagerCompat + .from(this) + .notify(stableId, notification) + } + + // PendingIntent의 requestCode에 활용될 안전한 notificationId 변환 메서드 + private fun PushPayload.resolveStableNotificationId(): Int { + val fromDeepLink = + deepLink + ?.let { + try { + it.toUri() + } catch (e: Exception) { + logger.e(e) { "deepLink 파싱 실패: $it" } + null + } + }?.getQueryParameter("notificationId") + ?.toLongOrNull() + + if (fromDeepLink != null) { + val normalized = (fromDeepLink % Int.MAX_VALUE).toInt() + return if (normalized <= 0) 1 else normalized + } + + val fallbackSeed = deepLink ?: title ?: body ?: System.currentTimeMillis().toString() + val hash = fallbackSeed.hashCode() + return if (hash == Int.MIN_VALUE) 0 else kotlin.math.abs(hash) + } +} diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index c76d6461..23d7504f 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -10,6 +10,7 @@ android { dependencies { implementation(projects.core.token) + implementation(projects.core.deviceContract) implementation(libs.androidx.datastore) implementation(libs.kotlinx.serialization.json) diff --git a/core/datastore/src/main/java/com/twix/datastore/DataStore.kt b/core/datastore/src/main/java/com/twix/datastore/DataStore.kt index a98f6396..dcc37527 100644 --- a/core/datastore/src/main/java/com/twix/datastore/DataStore.kt +++ b/core/datastore/src/main/java/com/twix/datastore/DataStore.kt @@ -3,8 +3,15 @@ package com.twix.datastore import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.dataStore +import com.twix.datastore.deviceid.DeviceId +import com.twix.datastore.deviceid.DeviceIdSerializer internal val Context.authDataStore: DataStore by dataStore( fileName = "auth-configure.json", serializer = AuthConfigureSerializer, ) + +internal val Context.deviceIdDataStore: DataStore by dataStore( + fileName = "device-id.json", + serializer = DeviceIdSerializer, +) diff --git a/core/datastore/src/main/java/com/twix/datastore/deviceid/DeviceId.kt b/core/datastore/src/main/java/com/twix/datastore/deviceid/DeviceId.kt new file mode 100644 index 00000000..8c07bfad --- /dev/null +++ b/core/datastore/src/main/java/com/twix/datastore/deviceid/DeviceId.kt @@ -0,0 +1,53 @@ +package com.twix.datastore.deviceid + +import androidx.datastore.core.Serializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import java.io.InputStream +import java.io.OutputStream + +@Serializable +data class DeviceId( + val deviceId: String = "", +) + +internal object DeviceIdSerializer : Serializer { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + override val defaultValue: DeviceId + get() = DeviceId() + + override suspend fun readFrom(input: InputStream): DeviceId = + try { + withContext(Dispatchers.IO) { + json.decodeFromString( + deserializer = DeviceId.serializer(), + string = input.readBytes().decodeToString(), + ) + } + } catch (e: SerializationException) { + defaultValue + } + + override suspend fun writeTo( + t: DeviceId, + output: OutputStream, + ) { + withContext(Dispatchers.IO) { + output.write( + json + .encodeToString( + serializer = DeviceId.serializer(), + value = t, + ).encodeToByteArray(), + ) + } + } +} diff --git a/core/datastore/src/main/java/com/twix/datastore/deviceid/DeviceIdProvider.kt b/core/datastore/src/main/java/com/twix/datastore/deviceid/DeviceIdProvider.kt new file mode 100644 index 00000000..9ae53bec --- /dev/null +++ b/core/datastore/src/main/java/com/twix/datastore/deviceid/DeviceIdProvider.kt @@ -0,0 +1,49 @@ +package com.twix.datastore.deviceid + +import android.content.Context +import androidx.datastore.core.DataStore +import com.twix.datastore.deviceIdDataStore +import com.twix.device_contract.IdProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID + +class DeviceIdProvider( + context: Context, +) : IdProvider { + private val dataStore: DataStore = context.deviceIdDataStore + private val mutex = Mutex() + + override suspend fun getOrCreateDeviceId(): String { + val current = + dataStore.data + .first() + .deviceId + .trim() + if (current.isNotEmpty()) return current + + return mutex.withLock { + val rechecked = + dataStore.data + .first() + .deviceId + .trim() + if (rechecked.isNotEmpty()) return@withLock rechecked + + val newId = "twix-${UUID.randomUUID()}" + + dataStore.updateData { currentValue -> + currentValue.copy(deviceId = newId) + } + + newId + } + } + + override suspend fun clear() { + dataStore.updateData { currentValue -> + currentValue.copy(deviceId = "") + } + } +} diff --git a/core/datastore/src/main/java/com/twix/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/twix/datastore/di/DataStoreModule.kt index 55049db8..b8334a49 100644 --- a/core/datastore/src/main/java/com/twix/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/twix/datastore/di/DataStoreModule.kt @@ -2,6 +2,8 @@ package com.twix.datastore.di import android.content.Context import com.twix.datastore.AuthTokenProvider +import com.twix.datastore.deviceid.DeviceIdProvider +import com.twix.device_contract.IdProvider import com.twix.token.TokenProvider import kotlinx.coroutines.CoroutineScope import org.koin.dsl.module @@ -9,4 +11,5 @@ import org.koin.dsl.module val dataStoreModule = module { single { AuthTokenProvider(get(), get()) } + single { DeviceIdProvider(get()) } } diff --git a/core/device-contract/.gitignore b/core/device-contract/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/device-contract/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/device-contract/build.gradle.kts b/core/device-contract/build.gradle.kts new file mode 100644 index 00000000..ba7b83cc --- /dev/null +++ b/core/device-contract/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.twix.java.library) +} diff --git a/core/device-contract/consumer-rules.pro b/core/device-contract/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/device-contract/proguard-rules.pro b/core/device-contract/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/device-contract/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/device-contract/src/main/AndroidManifest.xml b/core/device-contract/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/device-contract/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/device-contract/src/main/java/com/twix/device_contract/IdProvider.kt b/core/device-contract/src/main/java/com/twix/device_contract/IdProvider.kt new file mode 100644 index 00000000..14f026d9 --- /dev/null +++ b/core/device-contract/src/main/java/com/twix/device_contract/IdProvider.kt @@ -0,0 +1,7 @@ +package com.twix.device_contract + +interface IdProvider { + suspend fun getOrCreateDeviceId(): String + + suspend fun clear() +} diff --git a/core/navigation-contract/.gitignore b/core/navigation-contract/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/navigation-contract/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/navigation-contract/build.gradle.kts b/core/navigation-contract/build.gradle.kts new file mode 100644 index 00000000..874f930f --- /dev/null +++ b/core/navigation-contract/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.twix.android.library) +} + +android { + namespace = "com.twix.navigation_contract" +} diff --git a/core/navigation-contract/consumer-rules.pro b/core/navigation-contract/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/navigation-contract/proguard-rules.pro b/core/navigation-contract/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/navigation-contract/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/navigation-contract/src/main/AndroidManifest.xml b/core/navigation-contract/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/navigation-contract/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/navigation-contract/src/main/java/com/twix/navigation_contract/AppNavigator.kt b/core/navigation-contract/src/main/java/com/twix/navigation_contract/AppNavigator.kt new file mode 100644 index 00000000..8cc75753 --- /dev/null +++ b/core/navigation-contract/src/main/java/com/twix/navigation_contract/AppNavigator.kt @@ -0,0 +1,21 @@ +package com.twix.navigation_contract + +import java.time.LocalDate + +interface AppNavigator { + fun toHome() + + fun toLogin() // 앱 사용 중 리프레시 토큰까지 만료된 경우 로그인으로 이동시킬 때 사용 + + fun toMyPhotolog( + goalId: Long, + date: LocalDate, + ) + + fun toPartnerPhotolog( + goalId: Long, + date: LocalDate, + ) + + fun toStatisticsEndedGoals() +} diff --git a/core/navigation-contract/src/main/java/com/twix/navigation_contract/NotificationDeepLinkHandler.kt b/core/navigation-contract/src/main/java/com/twix/navigation_contract/NotificationDeepLinkHandler.kt new file mode 100644 index 00000000..aa382e22 --- /dev/null +++ b/core/navigation-contract/src/main/java/com/twix/navigation_contract/NotificationDeepLinkHandler.kt @@ -0,0 +1,11 @@ +package com.twix.navigation_contract + +/** + * 구현체는 :core:navigation -> NotificationRouter + * */ +interface NotificationDeepLinkHandler { + fun handle( + rawDeepLink: String, + navigator: AppNavigator, + ) +} diff --git a/core/navigation-contract/src/main/java/com/twix/navigation_contract/NotificationLaunchEventSource.kt b/core/navigation-contract/src/main/java/com/twix/navigation_contract/NotificationLaunchEventSource.kt new file mode 100644 index 00000000..2652ad4c --- /dev/null +++ b/core/navigation-contract/src/main/java/com/twix/navigation_contract/NotificationLaunchEventSource.kt @@ -0,0 +1,15 @@ +package com.twix.navigation_contract + +import android.content.Intent +import kotlinx.coroutines.flow.StateFlow + +/** + * 구현체는 :core:notification -> NotificationLaunchDispatcher + * */ +interface NotificationLaunchEventSource { + val pendingDeepLink: StateFlow + + fun dispatchFromIntent(intent: Intent?) + + fun consumePendingDeepLink(expected: String? = null) +} diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index a9a48887..21ce0f2d 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -11,5 +11,9 @@ android { } dependencies { + implementation(projects.core.navigationContract) + implementation(projects.core.ui) + implementation(projects.domain) + implementation(libs.kotlinx.serialization.json) } diff --git a/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt b/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt index 6a20fb4a..17aedade 100644 --- a/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt +++ b/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt @@ -6,15 +6,29 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController +import com.twix.domain.model.enums.BetweenUs import com.twix.navigation.base.NavGraphContributor +import com.twix.navigation_contract.AppNavigator +import com.twix.navigation_contract.NotificationDeepLinkHandler +import com.twix.navigation_contract.NotificationLaunchEventSource import org.koin.compose.getKoin +import org.koin.compose.koinInject +import java.time.LocalDate @Composable -fun AppNavHost() { +fun AppNavHost( + notificationLaunchEventSource: NotificationLaunchEventSource, + notificationRouter: NotificationDeepLinkHandler = koinInject(), +) { val navController = rememberNavController() val koin = getKoin() val contributors = @@ -26,8 +40,76 @@ fun AppNavHost() { .firstOrNull { it.graphRoute == NavRoutes.LoginGraph } ?.graphRoute ?: error("해당 Graph를 찾을 수 없습니다.") + val pendingDeepLink by notificationLaunchEventSource.pendingDeepLink.collectAsStateWithLifecycle() + val appNavigator = + remember(navController) { + object : AppNavigator { + override fun toHome() { + navController.navigate(NavRoutes.MainGraph) { launchSingleTop = true } + } + + override fun toLogin() { + navController.navigate(NavRoutes.LoginGraph) { + launchSingleTop = true + popUpTo(NavRoutes.LoginGraph) { + inclusive = true + } + } + } + + override fun toMyPhotolog( + goalId: Long, + date: LocalDate, + ) { + ensureMainStack(navController) + navController.navigate( + NavRoutes.TaskCertificationDetailRoute.createRoute( + goalId = goalId, + date = date, + betweenUs = BetweenUs.ME.name, + ), + ) { + launchSingleTop = true + } + } + + override fun toPartnerPhotolog( + goalId: Long, + date: LocalDate, + ) { + ensureMainStack(navController) + navController.navigate( + NavRoutes.TaskCertificationDetailRoute.createRoute( + goalId = goalId, + date = date, + betweenUs = BetweenUs.PARTNER.name, + ), + ) { + launchSingleTop = true + } + } + + override fun toStatisticsEndedGoals() { + ensureMainStack(navController) + TODO("Not yet implemented") + } + } + } val duration = 300 + LaunchedEffect(pendingDeepLink) { + val deepLink = pendingDeepLink ?: return@LaunchedEffect + + try { + notificationRouter.handle( + rawDeepLink = deepLink, + navigator = appNavigator, + ) + } finally { + notificationLaunchEventSource.consumePendingDeepLink(deepLink) + } + } + NavHost( navController = navController, startDestination = start.route, @@ -60,3 +142,21 @@ fun AppNavHost() { contributors.forEach { with(it) { registerGraph(navController) } } } } + +/** + * 푸쉬알림 클릭으로 앱 진입 시 네비게이션 백스택이 없어서 뒤로가기를 누르면 바로 앱이 종료될 수 있음 + * 이를 방지하기 위해 백스택에 MainGraph를 미리 넣어두는 메서드 + * hierarchy는 현재 화면이 속하는 그래프를 검사할 수 있게 해줌 + * */ +private fun ensureMainStack(navController: NavHostController) { + val inMainGraph = + navController.currentDestination + ?.hierarchy + ?.any { it.route == NavRoutes.MainGraph.route } == true + + if (!inMainGraph) { + navController.navigate(NavRoutes.MainGraph) { + launchSingleTop = true + } + } +} diff --git a/core/notification/.gitignore b/core/notification/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/notification/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/notification/build.gradle.kts b/core/notification/build.gradle.kts new file mode 100644 index 00000000..079652c7 --- /dev/null +++ b/core/notification/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.twix.android.library) + alias(libs.plugins.twix.koin) + alias(libs.plugins.twix.kermit) +} + +android { + namespace = "com.twix.notification" +} + +dependencies { + implementation(projects.domain) + implementation(projects.core.result) + implementation(projects.core.deviceContract) + implementation(projects.core.navigationContract) + + implementation(platform(libs.google.firebase.bom)) + implementation(libs.google.firebase.messaging) +} diff --git a/core/notification/consumer-rules.pro b/core/notification/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/notification/proguard-rules.pro b/core/notification/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/notification/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/notification/src/main/AndroidManifest.xml b/core/notification/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/notification/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/notification/src/main/java/com/twix/notification/channel/TwixNotificationChannelManager.kt b/core/notification/src/main/java/com/twix/notification/channel/TwixNotificationChannelManager.kt new file mode 100644 index 00000000..7f6cd735 --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/channel/TwixNotificationChannelManager.kt @@ -0,0 +1,26 @@ +package com.twix.notification.channel + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context + +class TwixNotificationChannelManager( + private val context: Context, +) { + fun ensureDefaultChannel() { + val manager = context.getSystemService(NotificationManager::class.java) + val channel = + NotificationChannel( + CHANNEL_DEFAULT, + "일반 알림", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Twix 알림 채널" + } + manager.createNotificationChannel(channel) + } + + companion object { + const val CHANNEL_DEFAULT = "twix_default_notifications" + } +} diff --git a/core/notification/src/main/java/com/twix/notification/deeplink/NotificationDeepLink.kt b/core/notification/src/main/java/com/twix/notification/deeplink/NotificationDeepLink.kt new file mode 100644 index 00000000..3967280d --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/deeplink/NotificationDeepLink.kt @@ -0,0 +1,41 @@ +package com.twix.notification.deeplink + +import java.time.LocalDate + +sealed interface NotificationDeepLink { + val notificationId: Long + + data class PartnerConnected( + override val notificationId: Long, + ) : NotificationDeepLink + + data class Poke( + override val notificationId: Long, + val goalId: Long, + val date: LocalDate, + ) : NotificationDeepLink + + data class GoalCompleted( + override val notificationId: Long, + val goalId: Long, + val date: LocalDate, + ) : NotificationDeepLink + + data class Reaction( + override val notificationId: Long, + val goalId: Long, + val date: LocalDate, + ) : NotificationDeepLink + + data class DailyGoalAchieved( + override val notificationId: Long, + ) : NotificationDeepLink + + data class GoalEnded( + override val notificationId: Long, + ) : NotificationDeepLink + + data class Marketing( + override val notificationId: Long, + ) : NotificationDeepLink +} diff --git a/core/notification/src/main/java/com/twix/notification/deeplink/NotificationDeepLinkParser.kt b/core/notification/src/main/java/com/twix/notification/deeplink/NotificationDeepLinkParser.kt new file mode 100644 index 00000000..ea6f5c85 --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/deeplink/NotificationDeepLinkParser.kt @@ -0,0 +1,68 @@ +package com.twix.notification.deeplink + +import android.net.Uri +import androidx.core.net.toUri +import co.touchlab.kermit.Logger +import java.time.LocalDate + +class NotificationDeepLinkParser { + private val logger = Logger.withTag("NotificationDeepLinkParser") + + fun parse(raw: String?): NotificationDeepLink? { + if (raw.isNullOrBlank()) return null + + return try { + val uri = raw.toUri() + val scheme = uri.scheme + val host = uri.host + val action = uri.lastPathSegment + + if (scheme != "twix" || host != "notification" || action.isNullOrBlank()) { + logger.w("유효하지 않는 deepLink 포맷입니다.: $raw") + return null + } + + val notificationId = uri.requireLongQuery("notificationId") + + when (action) { + "partner-connected" -> NotificationDeepLink.PartnerConnected(notificationId) + "poke" -> + NotificationDeepLink.Poke( + notificationId = notificationId, + goalId = uri.requireLongQuery("goalId"), + date = uri.requireLocalDateQuery("date"), + ) + "goal-completed" -> + NotificationDeepLink.GoalCompleted( + notificationId = notificationId, + goalId = uri.requireLongQuery("goalId"), + date = uri.requireLocalDateQuery("date"), + ) + "reaction" -> + NotificationDeepLink.Reaction( + notificationId = notificationId, + goalId = uri.requireLongQuery("goalId"), + date = uri.requireLocalDateQuery("date"), + ) + "daily-goal-achieved" -> NotificationDeepLink.DailyGoalAchieved(notificationId) + "goal-ended" -> NotificationDeepLink.GoalEnded(notificationId) + "marketing" -> NotificationDeepLink.Marketing(notificationId) + else -> { + logger.w("알 수 없는 notification action: $action") + null + } + } + } catch (e: Exception) { + logger.e(e) { "notification deepLink 파싱에 실패했습니다.: $raw" } + null + } + } + + private fun Uri.requireLongQuery(name: String): Long = + getQueryParameter(name)?.toLongOrNull() + ?: throw IllegalArgumentException("유효하지 않은 Long param입니다.: $name") + + private fun Uri.requireLocalDateQuery(name: String): LocalDate = + getQueryParameter(name)?.let(LocalDate::parse) + ?: throw IllegalArgumentException("유효하지 않은 Date param입니다.: $name") +} diff --git a/core/notification/src/main/java/com/twix/notification/di/NotificationModule.kt b/core/notification/src/main/java/com/twix/notification/di/NotificationModule.kt new file mode 100644 index 00000000..868eeb09 --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/di/NotificationModule.kt @@ -0,0 +1,21 @@ +package com.twix.notification.di + +import android.content.Context +import com.twix.navigation_contract.NotificationDeepLinkHandler +import com.twix.navigation_contract.NotificationLaunchEventSource +import com.twix.notification.channel.TwixNotificationChannelManager +import com.twix.notification.deeplink.NotificationDeepLinkParser +import com.twix.notification.routing.NotificationLaunchDispatcher +import com.twix.notification.routing.NotificationRouter +import com.twix.notification.token.NotificationTokenRegistrar +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val notificationModule = + module { + single { NotificationDeepLinkParser() } + single { TwixNotificationChannelManager(get()) } + single { NotificationTokenRegistrar(get(), get(), get(named("AppScope"))) } + single { NotificationRouter(get(), get(named("AppScope"))) } + single { NotificationLaunchDispatcher() } + } diff --git a/core/notification/src/main/java/com/twix/notification/model/PushPayload.kt b/core/notification/src/main/java/com/twix/notification/model/PushPayload.kt new file mode 100644 index 00000000..93504d39 --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/model/PushPayload.kt @@ -0,0 +1,14 @@ +package com.twix.notification.model + +data class PushPayload( + val title: String?, + val body: String?, + val deepLink: String?, +) + +fun Map.toTwixPushPayload() = + PushPayload( + title = this["title"], + body = this["body"], + deepLink = this["deepLink"], + ) diff --git a/core/notification/src/main/java/com/twix/notification/routing/NotificationLaunchDispatcher.kt b/core/notification/src/main/java/com/twix/notification/routing/NotificationLaunchDispatcher.kt new file mode 100644 index 00000000..6a7c1be9 --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/routing/NotificationLaunchDispatcher.kt @@ -0,0 +1,59 @@ +package com.twix.notification.routing + +import android.content.Intent +import com.twix.navigation_contract.NotificationLaunchEventSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * SharedFlow, Channel을 사용하지 않은 이유 + * · 푸시 클릭 발생 상황에서 UI가 아직 준비되지 않은 상태일 수 있음 + * · 이를 방지하기 위해 최신 값 1개 보관 + 구독자가 늦게 붙어도 최신 값 전달 + 명시적 소비가 필요함(StateFlow) + * */ +class NotificationLaunchDispatcher : NotificationLaunchEventSource { + private val _pendingDeepLink = MutableStateFlow(null) + override val pendingDeepLink: StateFlow = _pendingDeepLink.asStateFlow() + + // MainActivity의 onCreate/onNewIntent에서 중복 호출 방지 + private var lastDispatchedDeepLink: String? = null + private var lastDispatchedAtMillis: Long = 0L + + override fun dispatchFromIntent(intent: Intent?) { + if (intent == null) return + + val isPushClick = intent.getBooleanExtra(EXTRA_FROM_PUSH_CLICK, false) + val deepLink = intent.getStringExtra(EXTRA_DEEP_LINK) + + if (!isPushClick || deepLink.isNullOrBlank()) return + + if (shouldIgnoreDuplicate(deepLink)) return + + _pendingDeepLink.value = deepLink + } + + override fun consumePendingDeepLink(expected: String?) { + val current = _pendingDeepLink.value ?: return + if (expected == null || current == expected) { + _pendingDeepLink.value = null + } + } + + private fun shouldIgnoreDuplicate(deepLink: String): Boolean { + val now = System.currentTimeMillis() + val isDuplicate = + lastDispatchedDeepLink == deepLink && (now - lastDispatchedAtMillis) < 1500L + + if (!isDuplicate) { + lastDispatchedDeepLink = deepLink + lastDispatchedAtMillis = now + } + return isDuplicate + } + + companion object { + const val ACTION_NOTIFICATION_CLICK = "com.twix.notification.ACTION_CLICK" + const val EXTRA_DEEP_LINK = "extra_deep_link" + const val EXTRA_FROM_PUSH_CLICK = "extra_from_push_click" + } +} diff --git a/core/notification/src/main/java/com/twix/notification/routing/NotificationRouter.kt b/core/notification/src/main/java/com/twix/notification/routing/NotificationRouter.kt new file mode 100644 index 00000000..82de2e78 --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/routing/NotificationRouter.kt @@ -0,0 +1,77 @@ +package com.twix.notification.routing + +import co.touchlab.kermit.Logger +import com.twix.navigation_contract.AppNavigator +import com.twix.navigation_contract.NotificationDeepLinkHandler +import com.twix.notification.deeplink.NotificationDeepLink +import com.twix.notification.deeplink.NotificationDeepLinkParser +import kotlinx.coroutines.CoroutineScope + +class NotificationRouter( + private val parser: NotificationDeepLinkParser, +// private val notificationRepository: NotificationRepository, + private val appScope: CoroutineScope, +) : NotificationDeepLinkHandler { + private val logger = Logger.withTag("NotificationRouter") + + override fun handle( + rawDeepLink: String, + navigator: AppNavigator, + ) { + val deepLink = parser.parse(rawDeepLink) + if (deepLink == null) { + logger.w("유효하지 않은 deepLink Home으로 이동: $rawDeepLink") + navigator.toHome() // 기본값 + return + } + + // 읽음 처리 + markAsReadBestEffort(deepLink.notificationId) + + when (deepLink) { + is NotificationDeepLink.PartnerConnected -> { + navigator.toHome() + } + + is NotificationDeepLink.Poke -> { + // 내 인증샷 + navigator.toMyPhotolog(goalId = deepLink.goalId, date = deepLink.date) + } + + is NotificationDeepLink.GoalCompleted -> { + // 파트너 인증샷 + navigator.toPartnerPhotolog(goalId = deepLink.goalId, date = deepLink.date) + } + + is NotificationDeepLink.Reaction -> { + // 내 인증샷 + navigator.toMyPhotolog(goalId = deepLink.goalId, date = deepLink.date) + } + + is NotificationDeepLink.DailyGoalAchieved -> { + navigator.toHome() + } + + is NotificationDeepLink.GoalEnded -> { + // 통계 화면 종료 탭 + navigator.toStatisticsEndedGoals() + } + + is NotificationDeepLink.Marketing -> { + navigator.toHome() // 문서상 확인 필요, 현재 홈으로 가정 + } + } + } + + private fun markAsReadBestEffort(notificationId: Long) { +// appScope.launch { +// try { +// notificationRepository.markNotification(notificationId) +// } catch (ce: CancellationException) { +// throw ce +// } catch (e: Exception) { +// logger.w(it, "알림 읽음 처리 실패: $notificationId") +// } +// } + } +} diff --git a/core/notification/src/main/java/com/twix/notification/token/NotificationTokenRegistrar.kt b/core/notification/src/main/java/com/twix/notification/token/NotificationTokenRegistrar.kt new file mode 100644 index 00000000..3e9e66cf --- /dev/null +++ b/core/notification/src/main/java/com/twix/notification/token/NotificationTokenRegistrar.kt @@ -0,0 +1,73 @@ +package com.twix.notification.token + +import co.touchlab.kermit.Logger +import com.google.firebase.messaging.FirebaseMessaging +import com.twix.device_contract.IdProvider +import com.twix.domain.model.user.User +import com.twix.domain.repository.UserRepository +import com.twix.result.AppResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlin.coroutines.cancellation.CancellationException + +class NotificationTokenRegistrar( +// private val notificationRepository: NotificationRepository, + private val userRepository: UserRepository, + private val deviceIdProvider: IdProvider, + private val appScope: CoroutineScope, +) { + private val logger = Logger.withTag("NotificationTokenRegistrar") + + /** + * 앱 시작 시 호출 + */ + fun registerCurrentToken() { + appScope.launch { + try { + val fcmToken = FirebaseMessaging.getInstance().token.await() + registerInternal(fcmToken) + } catch (ce: CancellationException) { + throw ce + } catch (e: Exception) { + logger.e(e) { "기존 FCM token 등록 실패" } + } + } + } + + /** + * FirebaseMessagingService.onNewToken 에서 호출 + */ + fun registerFcmToken(fcmToken: String) { + appScope.launch { + try { + registerInternal(fcmToken) + } catch (ce: CancellationException) { + throw ce + } catch (e: Exception) { + logger.e(e) { "새로운 FCM token 등록 실패" } + } + } + } + + private suspend fun registerInternal(fcmToken: String) { + val userResult = userRepository.fetchUserInfo() + val deviceId = deviceIdProvider.getOrCreateDeviceId() + + when (userResult) { + is AppResult.Error -> { + logger.w { "FCM token 등록 스킵 - 사용자 정보 조회 실패: ${userResult.error}" } + return + } + is AppResult.Success -> { +// notificationRepository.registerFcmToken( +// userId = user.data.id, +// deviceId = deviceId, +// fcmToken = fcmToken, +// ) + + logger.i("FCM token registered. userId=${userResult.data.id}, deviceId=$deviceId") + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5446543f..ba6cba8e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,3 +45,6 @@ include(":feature:goal-editor") include(":feature:goal-manage") include(":feature:settings") include(":feature:onboarding") +include(":core:navigation-contract") +include(":core:notification") +include(":core:device-contract")