Skip to content

Commit

Permalink
Merge pull request #229 from team-winey/feature/feat-fcm
Browse files Browse the repository at this point in the history
[feat] FCM 푸쉬알림 / 푸쉬알림 구현
  • Loading branch information
Sangwook123 authored Jan 10, 2024
2 parents 55dab6e + ff8ba91 commit 31abbd4
Show file tree
Hide file tree
Showing 35 changed files with 642 additions and 14 deletions.
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) :
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) }
}

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
)
}

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

private fun generateUniqueIdentifier(): Int {
return (System.currentTimeMillis() / 7).toInt()
}

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)
}

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"
}
}
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

0 comments on commit 31abbd4

Please sign in to comment.