Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] FCM 푸쉬알림 / 푸쉬알림 구현 #229

Merged
merged 41 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4662a19
[add] gitignore google-services.json
Sangwook123 Nov 13, 2023
2b1738e
[add] Dependencies, Plugins, Versions
Sangwook123 Nov 13, 2023
0dcb0a2
[feat] firebase gradle setting
Sangwook123 Nov 13, 2023
32fd741
[feat] datastore에 디바이스 토큰 저장 로직
Sangwook123 Nov 13, 2023
a1ce97b
[feat] WineyMessagingService
Sangwook123 Nov 13, 2023
f6c64f1
[add] #226 service lifecycleScope dependencies
Sangwook123 Nov 19, 2023
6f52257
[mod] #226 파이어베이스 서비스 코드 수정
Sangwook123 Nov 20, 2023
73fa15c
[add] #226 아이콘 리소스 추가
Sangwook123 Nov 20, 2023
d90a1b5
[feat] #226 알림설정 스위치 모션 레이아웃 구현
Sangwook123 Nov 20, 2023
c3cdaa0
[feat] #226 스위치 상태 처리
Sangwook123 Nov 20, 2023
01918bb
[chore] #226 ResponseGetUserDto 변경
Sangwook123 Nov 20, 2023
9eebf99
[feat] #226 알림설정 Dto 구현
Sangwook123 Nov 20, 2023
1869607
[chore] #226 domain User 엔터티 알림동의여부 추가
Sangwook123 Nov 20, 2023
46949f1
[feat] #226 알림동의 여부 변경 service
Sangwook123 Nov 20, 2023
a40c575
[feat] #226 알림동의 여부 변경 datasource
Sangwook123 Nov 20, 2023
6228ae9
[feat] #226 알림동의 여부 변경 repository
Sangwook123 Nov 20, 2023
768942c
[chore] #226 알림동의여부 변경 response dto
Sangwook123 Nov 20, 2023
4f3623a
[feat] #226 알림동의 여부변경 함수 구현
Sangwook123 Nov 20, 2023
30f3eab
[feat] #226 알림동의여부 변경 ui 구현
Sangwook123 Nov 20, 2023
2c35766
[feat] fcm토큰 패치 dto
Sangwook123 Dec 5, 2023
9ea1a46
[feat] fcm token patch 서비스
Sangwook123 Dec 5, 2023
38b24d3
[feat] fcm 토큰 패치 datasource
Sangwook123 Dec 5, 2023
8f30fc2
[feat] fcm 토큰 패치 repository
Sangwook123 Dec 5, 2023
52c2089
[feat] patchFcmToken 뷰모델
Sangwook123 Dec 5, 2023
173d49f
[mod] firebase messaging service 수정
Sangwook123 Dec 5, 2023
cb34398
[feat] 알림 분기처리
Sangwook123 Dec 5, 2023
01d7885
[feat] 앱이 foreground인지 판별 로직
Sangwook123 Dec 5, 2023
39ba5d9
[chore] 토큰 로직 개선
Sangwook123 Dec 5, 2023
bc1835a
[chore] ktlintformat
Sangwook123 Dec 5, 2023
fa5332a
Merge branch 'develop' into feature/feat-fcm
Sangwook123 Dec 5, 2023
4235bb2
[chore] string 리소스
Sangwook123 Dec 5, 2023
643ef55
[chore] prchecker google service.json 추가
Sangwook123 Dec 5, 2023
a9c5f7a
[chore] pr checker 수정
Sangwook123 Dec 5, 2023
c8ee570
[chore] prchecker
Sangwook123 Dec 5, 2023
a4312b0
[refactor] activitylifecyclecallbacks 클래스화
Sangwook123 Dec 21, 2023
c098203
[refactor] wineymessagingservice 함수 세분화
Sangwook123 Dec 21, 2023
a66700f
[refactor] wineyApplication 콜백 클래스화
Sangwook123 Dec 21, 2023
dd0c643
[feat] notificationtype enum으로 관리
Sangwook123 Dec 21, 2023
bc88e5f
[mod] 코드리뷰 반영
Sangwook123 Dec 21, 2023
d119f8a
[mod] 코드리뷰 반영
Sangwook123 Dec 27, 2023
ff8ba91
[chore] ktlint
Sangwook123 Dec 27, 2023
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
5 changes: 5 additions & 0 deletions .github/workflows/pr_checker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ jobs:
echo keyPassword=$KEY_PASSWORD >> ./local.properties
echo storePassword=$STORE_PASSWORD >> ./local.properties

- name: Create Google Services JSON File
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $GOOGLE_SERVICES_JSON > ./app/google-services.json

- name: Build debug APK
run: ./gradlew assembleDebug --stacktrace

Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ captures/
*.pem

# Google Services (e.g. APIs or Firebase)
# google-services.json
google-services.json

# Android Patch
gen-external-apklibs
Expand Down
12 changes: 12 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ plugins {
id(ModulePlugins.kotlinSerialization)
id(ModulePlugins.hilt)
id(ModulePlugins.oss)
id(ModulePlugins.googleService)
id(ModulePlugins.firebaseAppdistribution)
id(ModulePlugins.firebaseCrashlytics)
}

android {
Expand Down Expand Up @@ -102,6 +105,7 @@ dependencies {
implementation(lifecycleLiveDataKtx)
implementation(lifecycleViewModelKtx)
implementation(lifecycleJava8)
implementation(lifecycleService)
implementation(splashScreen)
implementation(pagingRuntime)
implementation(workManager)
Expand Down Expand Up @@ -145,4 +149,12 @@ dependencies {
debugImplementation(flipperLeakCanary)
debugImplementation(soloader)
}

FirebaseDependencies.run {
implementation(platform(bom))
implementation(messaging)
implementation(analytics)
implementation(crashlytics)
implementation(remoteConfig)
}
}
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
android:theme="@style/Theme.Winey"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<service
android:name=".configuration.WineyMessagingService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<activity
android:name=".presentation.splash.SplashActivity"
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/java/org/go/sopt/winey/ActivityLifecycleHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.go.sopt.winey

import android.app.Activity
import android.app.Application
import android.os.Bundle

class ActivityLifecycleHandler(private val application: Application) :
Copy link
Member

Choose a reason for hiding this comment

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

생성자의 인자로 application을 넘겨줘야 하는 이유가 궁금합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

불필요한 인자네요 수정하겠습니다 !

Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(p0: Activity, p1: Bundle?) {
}

override fun onActivityStarted(p0: Activity) {
}

override fun onActivityResumed(p0: Activity) {
isAppInForeground = true
}

override fun onActivityPaused(p0: Activity) {
isAppInForeground = false
}

override fun onActivityStopped(p0: Activity) {
}

override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) {
}

override fun onActivityDestroyed(p0: Activity) {
}

companion object {
var isAppInForeground = false
}
}
1 change: 1 addition & 0 deletions app/src/main/java/org/go/sopt/winey/WineyApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class WineyApplication : Application() {
setupTimber()
setupKakaoSdk()
preventDarkMode()
registerActivityLifecycleCallbacks(ActivityLifecycleHandler(this))
}

private fun setupTimber() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.go.sopt.winey.configuration

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.go.sopt.winey.ActivityLifecycleHandler
import org.go.sopt.winey.R
import org.go.sopt.winey.domain.repository.DataStoreRepository
import org.go.sopt.winey.presentation.splash.SplashActivity
import javax.inject.Inject

@AndroidEntryPoint
class WineyMessagingService : FirebaseMessagingService() {

@Inject
lateinit var dataStoreRepository: DataStoreRepository

override fun onNewToken(token: String) {
super.onNewToken(token)

CoroutineScope(Dispatchers.IO).launch { dataStoreRepository.saveDeviceToken(token) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Dispatchers.IO 으로 지정되어야하는 이유가 있을까요 ? FCM 자체가 백그라운드 스레드에서 호출된다고 알고 있어서 별도 스코프를 지정하지 않아도 될까하는데 궁금합니다 ! 자세히 공부해본 적은 없어서 아니라면 알려주세요 😮

All methods are invoked on a background thread, and may be called when the app is in the background or not open

Copy link
Contributor Author

Choose a reason for hiding this comment

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

맞습니다! 별도의 백그라운드 스레드에서 실행되기때문에 스코프 지정이 필요없습니다. 그런데 Dispatcher.default는 최대 12개의 스레드, 즉 12개의 작업만 한번에 할 수 있는 반면, Dispatcher.io는 최대 64개의 스레드를 사용한다고 합니다. 따라서 한 가지의 무거운 작업을 할때는 Dispatcher.default, 대기시간이 있는 가벼운 입출력 작업을 할때는 Dispatcher.io가 성능향상에 도움이 된다고 합니다.

}

override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
if (remoteMessage.data.isNotEmpty() && !ActivityLifecycleHandler.isAppInForeground) {
sendNotification(remoteMessage)
}
}

private fun createNotificationIntent(remoteMessage: RemoteMessage): Intent {
return Intent(this, SplashActivity::class.java).apply {
putExtra(KEY_NOTI_TYPE, remoteMessage.data[KEY_NOTI_TYPE])
putExtra(KEY_FEED_ID, remoteMessage.data[KEY_FEED_ID])
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
}

private fun createPendingIntent(intent: Intent, uniqueIdentifier: Int): PendingIntent {
return PendingIntent.getActivity(
this,
uniqueIdentifier,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
Copy link
Member

Choose a reason for hiding this comment

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

이 글을 통해 Intent vs. PendingIntent 차이점을 알 수 있었네요! 참고하면 좋을 거 같아요~!

}

private fun getSoundUri(): Uri {
return RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
}

private fun generateUniqueIdentifier(): Int {
return (System.currentTimeMillis() / 7).toInt()
Copy link
Member

Choose a reason for hiding this comment

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

7로 나누는 이유가 있을까요?!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

특별한 이유는 없습니다 ! 유니크한 고유 식별자를 만들기위해서 사용한 방식입니다.

}

private fun createNotificationBuilder(remoteMessage: RemoteMessage, pendingIntent: PendingIntent): NotificationCompat.Builder {
val soundUri = getSoundUri()

return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(remoteMessage.data[KEY_TITLE])
.setContentText(remoteMessage.data[KEY_MESSAGE])
.setAutoCancel(true)
.setSound(soundUri)
.setContentIntent(pendingIntent)
}

private fun showNotification(notificationBuilder: NotificationCompat.Builder, uniqueIdentifier: Int) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}

notificationManager.notify(uniqueIdentifier, notificationBuilder.build())
}

private fun sendNotification(remoteMessage: RemoteMessage) {
val uniqueIdentifier = generateUniqueIdentifier()
val intent = createNotificationIntent(remoteMessage)
val pendingIntent = createPendingIntent(intent, uniqueIdentifier)
val notification = createNotificationBuilder(remoteMessage, pendingIntent)

showNotification(notification, uniqueIdentifier)
}
Copy link
Member

Choose a reason for hiding this comment

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

sendNotification 함수 안에서 스플래쉬 화면 띄우기, Notification 띄우기 등 여러 동작을 수행하고 있어서
각 동작에 대해 별도의 함수를 만들면 가독성이 더 올라갈 거 같습니다~!


companion object {
private const val KEY_FEED_ID = "feedId"
private const val KEY_NOTI_TYPE = "notiType"
private const val KEY_TITLE = "title"
private const val KEY_MESSAGE = "message"
private const val CHANNEL_NAME = "Notice"
private const val CHANNEL_ID = "channel"
}
Copy link
Member

Choose a reason for hiding this comment

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

상수화 👍👍 근데 NOTICE는 어떤 문자열인지 구분이 좀 어려워서 이름을 좀 더 구체적으로 적어도 좋을 거 같아요~!

}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ class AuthInterceptor @Inject constructor(
dataStoreRepository.saveAccessToken(accessToken, refreshToken)
}

private fun handleTokenExpired(chain: Interceptor.Chain, originalRequest: Request, headerRequest: Request): Response {
private fun handleTokenExpired(
chain: Interceptor.Chain,
originalRequest: Request,
headerRequest: Request
): Response {
val refreshTokenRequest = originalRequest.newBuilder().post("".toRequestBody())
.url("$AUTH_BASE_URL/auth/token")
.addHeader(REFRESH_TOKEN, runBlocking(Dispatchers.IO) { getRefreshToken() })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.go.sopt.winey.data.model.remote.request

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class RequestPatchAllowedNotificationDto(
@SerialName("allowedPush")
val allowedPush: Boolean
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.go.sopt.winey.data.model.remote.request

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class RequestPatchFcmTokenDto(
@SerialName("token")
val fcmToken: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ data class ResponseGetUserDto(
@SerialName("userId")
val userId: Int,
@SerialName("userLevel")
val userLevel: String
val userLevel: String,
@SerialName("fcmIsAllowed")
val fcmIsAllowed: Boolean
)

fun toUser(): User {
Expand All @@ -46,6 +48,7 @@ data class ResponseGetUserDto(
return User(
nickname = userResponseUserDto?.nickname.orEmpty(),
userLevel = userResponseUserDto?.userLevel.orEmpty(),
fcmIsAllowed = userResponseUserDto?.fcmIsAllowed ?: false,
duringGoalAmount = data.userResponseGoalDto?.duringGoalAmount ?: 0,
duringGoalCount = data.userResponseGoalDto?.duringGoalCount ?: 0,
targetMoney = data.userResponseGoalDto?.targetMoney ?: 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.go.sopt.winey.data.model.remote.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ResponsePatchAllowedNotificationDto(
@SerialName("isAllowed")
val isAllowed: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package org.go.sopt.winey.data.repository

import org.go.sopt.winey.data.model.remote.request.RequestCreateGoalDto
import org.go.sopt.winey.data.model.remote.request.RequestLoginDto
import org.go.sopt.winey.data.model.remote.request.RequestPatchAllowedNotificationDto
import org.go.sopt.winey.data.model.remote.request.RequestPatchFcmTokenDto
import org.go.sopt.winey.data.model.remote.request.RequestPatchNicknameDto
import org.go.sopt.winey.data.model.remote.response.ResponseGetNicknameDuplicateCheckDto
import org.go.sopt.winey.data.model.remote.response.ResponseLoginDto
Expand Down Expand Up @@ -60,4 +62,14 @@ class AuthRepositoryImpl @Inject constructor(
runCatching {
authDataSource.patchNickname(requestPatchNicknameDto).data
}

override suspend fun patchAllowedNotification(request: Boolean): Result<Boolean?> =
runCatching {
authDataSource.patchAllowedNotification(RequestPatchAllowedNotificationDto(allowedPush = request)).data?.isAllowed
}

override suspend fun patchFcmToken(token: String): Result<Unit> =
runCatching {
authDataSource.patchFcmToken(RequestPatchFcmTokenDto(fcmToken = token))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ class DataStoreRepositoryImpl @Inject constructor(
}
}

override suspend fun saveDeviceToken(deviceToken: String) {
dataStore.edit {
it[DEVICE_TOKEN] = deviceToken
}
}

override suspend fun saveUserId(userId: Int) {
dataStore.edit {
it[USER_ID] = userId
Expand All @@ -51,6 +57,10 @@ class DataStoreRepositoryImpl @Inject constructor(
return getStringValue(REFRESH_TOKEN)
}

override suspend fun getDeviceToken(): Flow<String?> {
return getStringValue(DEVICE_TOKEN)
}

override suspend fun getStringValue(key: Preferences.Key<String>): Flow<String?> {
return dataStore.data
.catch { exception ->
Expand Down Expand Up @@ -119,6 +129,7 @@ class DataStoreRepositoryImpl @Inject constructor(
stringPreferencesKey("social_refresh_token")
private val ACCESS_TOKEN: Preferences.Key<String> = stringPreferencesKey("access_token")
private val REFRESH_TOKEN: Preferences.Key<String> = stringPreferencesKey("refresh_token")
private val DEVICE_TOKEN: Preferences.Key<String> = stringPreferencesKey("device_token")
private val USER_ID: Preferences.Key<Int> = intPreferencesKey("user_id")
private val USER_INFO: Preferences.Key<String> = stringPreferencesKey("user_info")
}
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/org/go/sopt/winey/data/service/AuthService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package org.go.sopt.winey.data.service

import org.go.sopt.winey.data.model.remote.request.RequestCreateGoalDto
import org.go.sopt.winey.data.model.remote.request.RequestLoginDto
import org.go.sopt.winey.data.model.remote.request.RequestPatchAllowedNotificationDto
import org.go.sopt.winey.data.model.remote.request.RequestPatchFcmTokenDto
import org.go.sopt.winey.data.model.remote.request.RequestPatchNicknameDto
import org.go.sopt.winey.data.model.remote.response.ResponseCreateGoalDto
import org.go.sopt.winey.data.model.remote.response.ResponseGetNicknameDuplicateCheckDto
import org.go.sopt.winey.data.model.remote.response.ResponseGetUserDto
import org.go.sopt.winey.data.model.remote.response.ResponseLoginDto
import org.go.sopt.winey.data.model.remote.response.ResponseLogoutDto
import org.go.sopt.winey.data.model.remote.response.ResponsePatchAllowedNotificationDto
import org.go.sopt.winey.data.model.remote.response.ResponseReIssueTokenDto
import org.go.sopt.winey.data.model.remote.response.base.BaseResponse
import retrofit2.http.Body
Expand Down Expand Up @@ -53,4 +56,14 @@ interface AuthService {
suspend fun patchNickname(
@Body requestPatchNicknameDto: RequestPatchNicknameDto
): BaseResponse<Unit>

@PATCH("user/notification")
suspend fun patchAllowedNotification(
@Body requestPatchAllowedNotificationDto: RequestPatchAllowedNotificationDto
): BaseResponse<ResponsePatchAllowedNotificationDto>

@PATCH("user/fcmtoken")
suspend fun patchFcmToken(
@Body requestPatchFcmTokenDto: RequestPatchFcmTokenDto
): BaseResponse<Unit>
}
Loading