Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
59f8a47
✨ Feat: :core:navigation-contract 생성
dogmania Feb 22, 2026
6e595dd
✨ Feat: :core:notification 생성
dogmania Feb 22, 2026
ddb2024
✨ Feat: :core:device-contract 생성
dogmania Feb 22, 2026
4050ec1
✨ Feat: fcm, kermit, :core:notification 의존성 추가
dogmania Feb 23, 2026
8cabcb6
✨ Feat: FirebaseMessagingService 서비스 등록
dogmania Feb 23, 2026
461ce01
✨ Feat: Fcm 토큰 등록 로직 구현
dogmania Feb 23, 2026
061b270
✨ Feat: notificationModule, coroutineModule 초기화
dogmania Feb 23, 2026
fd3b30b
✨ Feat: NotificationIntent 처리 메서드 호출
dogmania Feb 23, 2026
1ef8d24
✨ Feat: :core:device-contract 의존성 추가
dogmania Feb 23, 2026
14aa4c4
✨ Feat: :domain, :core:navigation-contract 의존성 추가
dogmania Feb 23, 2026
1ae5440
✨ Feat: DeviceIdProvider 구현
dogmania Feb 23, 2026
24c0a43
✨ Feat: DeviceIdProvider Koin 컨테이너 등록
dogmania Feb 23, 2026
b489385
♻️ Refactor: java.library -> android.library로 변경
dogmania Feb 23, 2026
0c6761b
✨ Feat: 딥링크 처리 결과에 따른 네비게이션 로직 구현
dogmania Feb 23, 2026
9e9cb0f
✨ Faet: 필요 의존성 추가
dogmania Feb 23, 2026
1680969
✨ Feat: 전역 코루틴 모듈 생성
dogmania Feb 23, 2026
768d463
✨ Feat: :core:navigation, :core:notification 의존 관계를 분리하기 위한 인터페이스 정의
dogmania Feb 23, 2026
ddb4c90
✨ Feat: :core:datastore, :core:notification 의존 관계를 분리하기 위한 인터페이스 정의
dogmania Feb 23, 2026
76f2368
✨ Feat: FirebaseMessagingService 구현
dogmania Feb 23, 2026
376a4f1
✨ Feat: 시스템 알림 채널 생성 클래스 구현
dogmania Feb 23, 2026
9d4faea
✨ Feat: 딥링크 네비게이션 sealed interface 정의
dogmania Feb 23, 2026
66c322d
✨ Feat: 딥링크 파싱 클래스 구현
dogmania Feb 23, 2026
d968130
✨ Feat: PushPayload Model 구현
dogmania Feb 23, 2026
168c994
✨ Feat: Intent에 따른 deepLink 이벤트 관리 클래스 구현
dogmania Feb 23, 2026
90ad2b4
✨ Feat: fcm 토큰 관리 클래스 구현
dogmania Feb 23, 2026
0d4f27f
✨ Feat: 딥링크 파싱 후 네비게이션, 알림 읽음 처리 수행 클래스 구현
dogmania Feb 23, 2026
d6853e9
✨ Feat: NotificationModule 구현
dogmania Feb 23, 2026
6dc6a3a
🐛 Fix: conflict 해결
dogmania Feb 23, 2026
3c3ae24
♻️ Refactor: ktlint 적용
dogmania Feb 23, 2026
3ec06a6
♻️ Refactor: 이름 변경
dogmania Feb 23, 2026
7a615ba
✨ Feat: 알림 권한 추가
dogmania Feb 23, 2026
f87f0ff
✨ Feat: 알림 권한 체크 로직 추가
dogmania Feb 23, 2026
b459840
🔥 Remove: 불필요한 의존성 제거
dogmania Feb 24, 2026
506bd4e
🔥 Remove: 불필요한 플러그인 제거
dogmania Feb 24, 2026
d2771c6
✨ Feat: 파싱 실패 시 로그 추가
dogmania Feb 24, 2026
9486a71
♻️ Refactor: 에러 출력 방식 수정
dogmania Feb 24, 2026
9c2ca7e
🐛 Fix: conflict 해결
dogmania Feb 24, 2026
635683a
Merge branch 'develop' of github.com:YAPP-Github/Twix-Android into fe…
dogmania Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".TwixApplication"
Expand All @@ -11,6 +12,14 @@
android:supportsRtl="true"
android:theme="@style/Theme.Twix">

<service
android:name=".service.TwixFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<activity
android:name=".main.MainActivity"
android:exported="true">
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/yapp/twix/TwixApplication.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
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()

initKoin(
context = this,
)
KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY)

try {
getKoin().get<NotificationTokenRegistrar>().registerCurrentToken()
} catch (e: Exception) {
logger.e(e) { "FCM token 등록 실패" }
}
}
}
18 changes: 18 additions & 0 deletions app/src/main/java/com/yapp/twix/di/CoroutineModule.kt
Original file line number Diff line number Diff line change
@@ -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<CoroutineScope>(named("AppScope")) {
CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
}
Comment on lines +13 to +18
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일전에 DataStore 리뷰할 때 현수가 AuthTokenProvider에 코루틴을 전역으로 주입해서 사용하면
좋겠다고 의견을 줘서 app/di/AppModule 에 named만 지정하지 않은 코루틴 주입 객체가 있는데
혹시 이거랑 다른걸까 ?

3 changes: 3 additions & 0 deletions app/src/main/java/com/yapp/twix/di/InitKoin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,8 @@ fun initKoin(
add(appModule)
add(utilModule)
add(imageModule)
add(notificationModule)
add(coroutineModule)
},
)
}
Expand Down
45 changes: 43 additions & 2 deletions app/src/main/java/com/yapp/twix/main/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,36 +1,53 @@
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
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<ToastManager>()
val toastManager: ToastManager = koinInject()
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = true

LaunchedEffect(Unit) {
requestNotificationPermissionIfNeeded()
}

TwixTheme {
Box(
modifier =
Expand All @@ -40,7 +57,7 @@ class MainActivity : ComponentActivity() {
WindowInsets.systemBars.only(WindowInsetsSides.Vertical),
),
) {
AppNavHost()
AppNavHost(notificationLaunchEventSource = notificationLaunchEventSource)

ToastHost(
toastManager = toastManager,
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions core/datastore/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ android {

dependencies {
implementation(projects.core.token)
implementation(projects.core.deviceContract)

implementation(libs.androidx.datastore)
implementation(libs.kotlinx.serialization.json)
Expand Down
7 changes: 7 additions & 0 deletions core/datastore/src/main/java/com/twix/datastore/DataStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthConfigure> by dataStore(
fileName = "auth-configure.json",
serializer = AuthConfigureSerializer,
)

internal val Context.deviceIdDataStore: DataStore<DeviceId> by dataStore(
fileName = "device-id.json",
serializer = DeviceIdSerializer,
)
Original file line number Diff line number Diff line change
@@ -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<DeviceId> {
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(),
)
}
}
}
Loading