Skip to content

Commit 92cf18d

Browse files
authored
Merge pull request #132
* ♻️ refactor(data): 알람 데이터 레이어 구조 개선 * ♻️ refactor(repository): AlarmRepository의 반환 타입을 Flow로 변경 * ♻️ refactor(data): AlarmPagingSource 생성자 및 가시성 수정 * ♻️ refactor(ui): 알람 목록 UI 최적화 및 코드 가독성 개선 * ✨ feat(alarm): 알람 목록 조회 API 연동 및 알람 관련 기능 구조 개선 * ♻️ refactor(data): 알림 설정 로직 리팩터링 및 서버 동기화 구현 * ♻️ refactor(alarm): 알림 기능 관련 코드 정리 및 UI 상태 구조 개선 * ✨ feat(fcm): FCM 토큰 등록 설정 * ✨ feat(notification): FCM 토큰 서버 등록 로직 구현 및 권한 처리 수정 * ✨ feat(notification): 알림 설정 로드 및 토글 연동 로직 구현 * chore(notification): 알람 설정 API 응답 변경 대비 TODO 주석 추가 * ♻️ refactor(notification): 알림 설정 로직 리팩터링 및 모델 통합 * ♻️ refactor(notification): FCM 토큰 관리 프로세스 개선 및 로컬 저장 로직 구현 * ✨ feat(ui): 알림 설정 화면 시스템 알람 상태 연동 및 UI 변경사항 반영 * chore(resource): ic_info_blue 아이콘 추가 * ✨ feat(mypage): 알림 설정 동기화 로직 구현 및 스켈레톤 UI 적용 * ✨ feat(data): 상대 시간 표시 기능 구현 및 알람 매퍼에 적용 * ✨ feat(mypage): 알람 설정 화면 에러 처리 및 토스트 메시지 구현 * ✨ feat(notification): FCM 알림 수신 처리 구현 및 알림 설정 로직 고도화 * ✨ feat(ui): 알람 목록 UI 컴포넌트 분리 및 페이징 처리 구현 * ♻️ refactor(ui): 알람 리스트 관련 컴포넌트 패키지 구조 개선 * 📝 docs(fcm): FCM 메시지 서비스 내 로직 설명 주석 추가 * 🐛 fix(notification): 알림 설정 동기화 및 동시성 이슈 해결 * ♻️ refactor(mypage): 알림 설정 화면 MVI 아키텍처 적용 * ♻️ refactor(mypage): 알림 설정 Side Effect 처리 방식을 Channel로 변경 * chore: 주석 추가 * ♻️ refactor(ui): 알람 화면 UI 구조 통합 및 컴포넌트 패키지 정리 * ♻️ refactor: 알림 설정 로직 및 페이징 처리 구조 개선 * ♻️ refactor(mypage): 알림 설정 실패 시 상태 복구 로직 수정 * ✨ feat(ui): 알림 화면 스와이프 새로고침 기능 추가 및 UI 구조 개선 * ♻️ refactor(alarm): 알람 데이터 로드 및 설정 확인 로직 개선 * ♻️ refactor(fcm): ApplicationScope 도입 및 FCM 토큰 등록 로직 개선 * fix: 알람 단계 DEFAULT로 수정 * ♻️ refactor(di): AlarmRepositoryModule 코드 정리 * ♻️ refactor(data): 상대 시간 변환 로직 개선 및 단위 테스트 추가 * ♻️ refactor(data): 상대 시간 파싱 로직 개선 및 UI 코드 정리 * ♻️ refactor(ui): 알람 컴포넌트 네이밍 통일 및 레이아웃 구조 개선 * merge branch develop into feature/#131-alarmApi * ♻️ refactor(ui): 알림 설정 Intent 구조 개선 및 리팩터링 * ♻️ refactor(mypage): NotificationIntent 구조 개선 및 ViewModel 업데이트 로직 최적화 * ♻️ refactor(viewmodel): NotificationViewModel 내 알림 상태 갱신 로직 분리 * ♻️ refactor(ui): AlarmSettingScreen 내 Intent 호출 코드 간소화 * refactor(home): AlarmScreen 내 탭별 스크롤 상태 저장 로직 제거 -> 이것또한 페이징3이 다 해주기 때문 * ♻️ refactor(ui): NotificationViewModel 주입 위치 변경 및 로깅 로직 수정 * ♻️ refactor(mypage): 알림 Intent에 상태 변경 로직 위임 및 ViewModel 리팩터링
1 parent 7ccb07d commit 92cf18d

53 files changed

Lines changed: 1583 additions & 605 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/androidTest/java/com/linku/ExampleInstrumentedTest.kt

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,17 @@
1-
<<<<<<<< HEAD:app/src/androidTest/java/com/linku/link/ExampleInstrumentedTest.kt
21
package com.linku.link
3-
========
4-
package com.linku
5-
>>>>>>>> fd1304faab6b86e04c17e31a0786ce151290d292:app/src/androidTest/java/com/linku/ExampleInstrumentedTest.kt
62

73
import androidx.test.platform.app.InstrumentationRegistry
84
import androidx.test.ext.junit.runners.AndroidJUnit4
9-
105
import org.junit.Test
116
import org.junit.runner.RunWith
12-
137
import org.junit.Assert.*
148

15-
/**
16-
* Instrumented test, which will execute on an Android device.
17-
*
18-
* See [testing documentation](http://d.android.com/tools/testing).
19-
*/
209
@RunWith(AndroidJUnit4::class)
2110
class ExampleInstrumentedTest {
11+
2212
@Test
2313
fun useAppContext() {
24-
// Context of the app under test.
2514
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
26-
<<<<<<<< HEAD:app/src/androidTest/java/com/linku/link/ExampleInstrumentedTest.kt
27-
assertEquals("com.linku.link", appContext.packageName)
28-
========
2915
assertEquals("com.linku", appContext.packageName)
30-
>>>>>>>> fd1304faab6b86e04c17e31a0786ce151290d292:app/src/androidTest/java/com/linku/ExampleInstrumentedTest.kt
3116
}
3217
}

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979

8080
</activity>
8181

82+
<meta-data
83+
android:name="com.google.firebase.messaging.default_notification_channel_id"
84+
android:value="default_channel" />
85+
8286
<!-- FCM Service -->
8387
<service
8488
android:name="com.linku.LinkUFireBaseMessageService"
Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,94 @@
11
package com.linku
22

3+
import android.app.NotificationManager
4+
import android.app.PendingIntent
5+
import android.util.Log
6+
import androidx.core.app.NotificationCompat
37
import com.google.firebase.messaging.FirebaseMessagingService
48
import com.google.firebase.messaging.RemoteMessage
9+
import com.linku.core.di.ApplicationScope
10+
import com.linku.core.repository.AlarmRepository
11+
import com.linku.data.preference.NotificationPreference
12+
import dagger.hilt.android.AndroidEntryPoint
13+
import kotlinx.coroutines.CoroutineScope
14+
import kotlinx.coroutines.launch
15+
import javax.inject.Inject
516

17+
18+
@AndroidEntryPoint
619
class LinkUFireBaseMessageService : FirebaseMessagingService() {
720

21+
// FirebaseMessagingService는 생성자 주입이 불가. 따라서 필드 주입 사용
22+
@Inject
23+
lateinit var alarmRepository: AlarmRepository
24+
@Inject
25+
lateinit var notificationPreference: NotificationPreference
26+
27+
@Inject
28+
@ApplicationScope
29+
lateinit var externalScope: CoroutineScope
30+
31+
//FireBase에서 새 토큰이 발급되었을 때 호출되는 콜백
832
override fun onNewToken(token: String) {
933
super.onNewToken(token)
10-
// TODO: 서버에 FCM 토큰 전송
34+
if (BuildConfig.DEBUG) {
35+
Log.d("FCM Token", token)
36+
}
37+
38+
notificationPreference.setFcmToken(token)
39+
40+
externalScope.launch {
41+
alarmRepository.registerFCMToken(token)
42+
}
1143
}
1244

45+
// FireBase로부터 메세지를 받았을 때 호출되는 콜백
1346
override fun onMessageReceived(message: RemoteMessage) {
1447
super.onMessageReceived(message)
15-
// TODO: 푸시 알림 처리
48+
49+
// 푸시알림 활성화 안되어있으면 종료
50+
if (!notificationPreference.isMasterNotificationEnabled()) return
51+
52+
// FCM 메시지 타입에 따라 title/body 추출
53+
// Notification Message: message.notification에서 추출
54+
// Data Message: message.data에서 추출
55+
// 둘 다 없으면 처리 불필요로 판단하여 종료
56+
val title = message.notification?.title ?: message.data["title"] ?: return
57+
val body = message.notification?.body ?: message.data["message"] ?: return
58+
val targetId = message.data["targetId"] ?: "null"
59+
60+
Log.d("FCM", """
61+
FCM 수신
62+
title: $title
63+
body: $body
64+
targetId: $targetId
65+
""".trimIndent()
66+
)
67+
68+
// 일단은 액티비티로의 이동처리만 구현. 추후 수정 예정
69+
val pendingIntent = PendingIntent.getActivity(
70+
this,
71+
0,
72+
packageManager.getLaunchIntentForPackage(packageName),
73+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
74+
)
75+
76+
// 알림 제작
77+
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
78+
.setSmallIcon(R.drawable.ic_logo)
79+
.setContentTitle(title)
80+
.setContentText(body)
81+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
82+
.setAutoCancel(true)
83+
.setContentIntent(pendingIntent)
84+
.build()
85+
86+
// 알림 출력
87+
getSystemService(NotificationManager::class.java)
88+
.notify(System.currentTimeMillis().toInt(), notification)
1689
}
1790

18-
}
91+
companion object {
92+
const val CHANNEL_ID = "default_channel"
93+
}
94+
}

app/src/main/java/com/linku/MainApp.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,9 @@ fun MainApp(
159159
// 권한 요청 런쳐
160160
val notificationPermissionLauncher = rememberLauncherForActivityResult(
161161
ActivityResultContracts.RequestPermission()
162-
) { isGranted -> // 시스템 권한 요청 결과를 로컬에 저장
162+
) { isGranted ->
163163
viewModel.setNotificationEnabled(isGranted)
164164
Log.d("MainApp", "알림 권한 요청 결과: $isGranted")
165-
Log.d("MainApp", "시스템 권한 상태: ${ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED}")
166165
}
167166

168167

@@ -173,6 +172,8 @@ fun MainApp(
173172
// Android 13 이상에서는 POST_NOTIFICATIONS 런타임 권한이 필요하므로 조건부 요청
174173
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
175174
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
175+
} else {
176+
viewModel.setNotificationEnabled(true)
176177
}
177178
requestNotificationPermission = false // 한 번만 요청하도록 처리
178179
}

app/src/main/java/com/linku/MainApplication.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.linku
22

33
import android.app.Application
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.os.Build
47
import android.util.Log
58
import com.kakao.sdk.common.KakaoSdk
69
import dagger.hilt.android.HiltAndroidApp
@@ -11,6 +14,20 @@ class MainApplication: Application() {
1114
super.onCreate()
1215
Log.d("DEBUG", "✅ MainApplication 실행됨")
1316
KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY)
17+
createNotificationChannels()
18+
19+
}
20+
21+
// 알림 채널 생성. minSdk가 26이므로 분기처리 생략
22+
private fun createNotificationChannels() {
23+
val channel = NotificationChannel(
24+
"default_channel",
25+
"기본 알림",
26+
NotificationManager.IMPORTANCE_HIGH
27+
)
28+
29+
getSystemService(NotificationManager::class.java)
30+
.createNotificationChannel(channel)
1431

1532
}
1633
}

app/src/main/java/com/linku/MainViewModel.kt

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import android.net.NetworkRequest
88
import android.util.Log
99
import androidx.lifecycle.AndroidViewModel
1010
import androidx.lifecycle.viewModelScope
11+
import com.linku.core.model.alarm.AlarmType
12+
import com.linku.core.repository.AlarmRepository
1113
import com.linku.core.repository.RecentSearchRepository
1214
import com.linku.core.repository.UserRepository
13-
import com.linku.core.system.NotificationController
1415
import com.linku.data.preference.AuthPreference
16+
import com.linku.data.preference.NotificationPreference
1517
import dagger.hilt.android.lifecycle.HiltViewModel
1618
import kotlinx.coroutines.channels.awaitClose
1719
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,7 +30,8 @@ import javax.inject.Inject
2830
class MainViewModel @Inject constructor(
2931
application: Application,
3032
private val recentRepository: RecentSearchRepository,
31-
private val notificationController: NotificationController,
33+
private val notificationPreference: NotificationPreference,
34+
private val alarmRepository: AlarmRepository,
3235
private val authPreference: AuthPreference,
3336
private val userRepository: UserRepository // 닉네임 호출용
3437
) : AndroidViewModel(application) {
@@ -140,11 +143,29 @@ class MainViewModel @Inject constructor(
140143
val token = authPreference.getRefreshToken()
141144
return !token.isNullOrBlank()
142145
}
146+
// 시스템 알람 허용 여부에 따른 초기 푸시알람설정 초기화
147+
fun setNotificationEnabled(isGranted: Boolean) {
148+
if (!isGranted) return
143149

144-
// 알림 허용 여부 저장
145-
// 로그인 성공 후 시스템 권한 요청 결과를 로컬에 반영
146-
fun setNotificationEnabled(enabled: Boolean) {
147-
notificationController.setNotificationEnabled(enabled)
148-
}
150+
viewModelScope.launch {
151+
val token = notificationPreference.getFcmToken()
152+
153+
if (token == null) {
154+
Log.d("FCM", "token 없음 → skip")
155+
return@launch
156+
}
149157

158+
// 토큰 등록이 성공했으면 전체 푸시알림 활성화
159+
val registerResult = alarmRepository.registerFCMToken(token)
160+
161+
if (registerResult.isSuccess) {
162+
alarmRepository.updateAlarmSetting(AlarmType.ALL)
163+
.onFailure { e ->
164+
Log.e("FCM", "알람 설정 실패: ${e.message}", e)
165+
}
166+
} else {
167+
Log.e("FCM", "register 실패: ${registerResult.exceptionOrNull()?.message}")
168+
}
169+
}
170+
}
150171
}

app/src/test/java/com/linku/link/ExampleUnitTest.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
<<<<<<<< HEAD:app/src/test/java/com/linku/link/ExampleUnitTest.kt
21
package com.linku.link
3-
========
4-
package com.linku.data
5-
>>>>>>>> 6cfa3247fa9a751d3cefb7daf59fb3f6f6c8368c:data/src/test/java/com/linku/data/ExampleUnitTest.kt
62

73
import org.junit.Test
84

core/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,9 @@ dependencies {
9090

9191
implementation(libs.androidx.datastore.preferences)
9292

93+
//Paging3
94+
implementation(libs.paging.runtime)
95+
96+
// Firebase
97+
implementation(libs.bundles.firebase)
9398
}

core/src/main/java/com/linku/core/di/SystemModule.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@ import dagger.Provides
77
import dagger.hilt.InstallIn
88
import dagger.hilt.android.qualifiers.ApplicationContext
99
import dagger.hilt.components.SingletonComponent
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.SupervisorJob
13+
import javax.inject.Qualifier
1014
import javax.inject.Singleton
1115

16+
@Qualifier
17+
@Retention(AnnotationRetention.BINARY)
18+
annotation class ApplicationScope
19+
1220
@Module
1321
@InstallIn(SingletonComponent::class)
1422
object SystemModule {
@@ -20,4 +28,11 @@ object SystemModule {
2028
): PermissionChecker {
2129
return PermissionChecker(context)
2230
}
31+
32+
@Provides
33+
@Singleton
34+
@ApplicationScope
35+
fun provideApplicationScope(): CoroutineScope {
36+
return CoroutineScope(SupervisorJob() + Dispatchers.Main)
37+
}
2338
}

0 commit comments

Comments
 (0)