Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

# google-services.json 파일 생성
- name: Create google-services.json
run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > app/google-services.json

# 2. JDK 17 버전 설정 (핵심 수정 사항)
- name: Set up JDK 17
uses: actions/setup-java@v4
Expand Down
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
id("com.google.gms.google-services")
}

android {
Expand Down Expand Up @@ -83,6 +84,11 @@ dependencies {

// Coroutines
implementation(libs.kotlinx.coroutines.android)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")

//FCM
implementation(platform("com.google.firebase:firebase-bom:33.2.0"))
implementation("com.google.firebase:firebase-messaging-ktx")

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
Expand Down
15 changes: 13 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".TiggleApplication"
Expand All @@ -16,6 +16,17 @@
android:supportsRtl="true"
android:theme="@style/Theme.Tiggle"
android:usesCleartextTraffic="true">
<!-- ✅ 기본 알림 채널 ID 등록 -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/default_notification_channel_id" />
<service
android:name=".core.fcm.TiggleMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
71 changes: 71 additions & 0 deletions app/src/main/java/com/ssafy/tiggle/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
package com.ssafy.tiggle

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging

import com.ssafy.tiggle.presentation.navigation.NavigationGraph
import com.ssafy.tiggle.presentation.ui.theme.TiggleTheme
Expand All @@ -18,10 +32,67 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

// ✅ 디버그용: 현재 기기의 FCM 토큰 로그로 확인
FirebaseMessaging.getInstance().token.addOnSuccessListener {
Log.d("TiggleFCM", "FCM token = $it")
}

setContent {
TiggleTheme {
// ✅ Android 13+ 알림 권한 1회 요청
RequestPostNotificationsPermissionOnce()
// ⬇️ 기존 네비게이션
NavigationGraph()
}
}
}
}

/** Android 13+에서 POST_NOTIFICATIONS 권한 1회 요청 */
@Composable
private fun RequestPostNotificationsPermissionOnce() {
val ctx = LocalContext.current

// 현재 상태 로그로 확인
LaunchedEffect(Unit) {
android.util.Log.d(
"NotifPerm",
"SDK=${android.os.Build.VERSION.SDK_INT}, areEnabled=${
NotificationManagerCompat.from(
ctx
).areNotificationsEnabled()
}"
)
}

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// Android 12 이하: 런타임 권한 없음 (설정에서만 on/off)
return
}

val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
android.util.Log.d("NotifPerm", "request result granted=$granted")
if (!granted) {
// 거부된 상태면 설정으로 유도(선택)
// open app notification settings
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, ctx.packageName)
}
ctx.startActivity(intent)
}
}

LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(
ctx, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED

Log.d("NotifPerm", "already granted=$granted")

// 미허용이면 팝업 띄움
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
29 changes: 28 additions & 1 deletion app/src/main/java/com/ssafy/tiggle/TiggleApplication.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
package com.ssafy.tiggle

import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import dagger.hilt.android.HiltAndroidApp

/**
* Tiggle 애플리케이션 클래스
* Hilt를 사용하기 위한 Application 클래스
*/
@HiltAndroidApp
class TiggleApplication : Application()
class TiggleApplication : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannel() // 🔔 앱 시작 시 채널 1회 생성
}

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val id = getString(R.string.default_notification_channel_id)
val name = getString(R.string.notification_channel_name)
val desc = getString(R.string.notification_channel_desc)

val channel = NotificationChannel(
id,
name,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = desc
enableVibration(true)
}

getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
}
53 changes: 53 additions & 0 deletions app/src/main/java/com/ssafy/tiggle/core/fcm/FcmTokenUploader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.ssafy.tiggle.core.fcm

import android.content.Context
import android.provider.Settings
import com.google.firebase.installations.FirebaseInstallations
import com.google.firebase.messaging.FirebaseMessaging
import com.ssafy.tiggle.domain.repository.FcmRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton

private const val TAG = "FcmTokenUploader"


/**
* 앱 ↔ 서버 사이에서 FCM 토큰을 업로드하는 "작은 유틸 서비스" 클래스.
*
* 언제 쓰나?
* - 로그인 직후(서버가 유저 인증된 상태에서 토큰을 묶어 저장해야 하므로)
* - 혹은 onNewToken() 으로 토큰이 갱신되었을 때(서비스에서 직접 서버로 업로드)
*/
@Singleton
class FcmTokenUploader @Inject constructor(
private val repo: FcmRepository,
@ApplicationContext private val context: Context
) {
/**
* 단말 식별자(디바이스 ID)를 구함.
* - 1순위: Firebase Installation ID (FIID) : Firebase가 제공하는 안정적 설치 식별자
* - 실패/예외 시: ANDROID_ID (OS가 제공하는 단말 고유 식별자)
*/
private suspend fun getDeviceId(): String {
return try {
FirebaseInstallations.getInstance().id.await()
} catch (_: Exception) {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
?: "unknown"
}
}

/**
* 현재 단말의 FCM 토큰을 가져와 서버에 업로드.
*
* - suspend fun: 호출 측(viewModel 등)에서 코루틴으로 쉽게 호출 가능.
* - 실패 시 Result.failure 로 전달해서 UI에서 토스트/스낵바 처리 용이.
*/
suspend fun upload(): Result<Unit> {
val token = FirebaseMessaging.getInstance().token.await()
val deviceId = getDeviceId()
return repo.registerToken(token)
}
}
119 changes: 119 additions & 0 deletions app/src/main/java/com/ssafy/tiggle/core/fcm/TiggleMessageService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.ssafy.tiggle.core.fcm

import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.ssafy.tiggle.MainActivity
import com.ssafy.tiggle.R
import com.ssafy.tiggle.domain.repository.FcmRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject

private const val TAG = "TiggleMessageService"

/**
* FirebaseMessagingService 구현체.
*
* 역할 요약
* 1) onNewToken(token): 단말의 FCM 토큰이 갱신될 때 호출 → 서버에 새 토큰 업로드
* 2) onMessageReceived(msg): 포그라운드(또는 data-only) 수신 시 직접 알림 표시
*
* 주의
* - 앱이 "백그라운드"이고 메시지 payload에 "notification" 키가 있으면,
* 시스템이 알림을 자동으로 표시하고 onMessageReceived()는 호출되지 않는게 정상.
* - 앱이 포그라운드이거나 "data-only" payload면 onMessageReceived()가 호출됨.
*/

@AndroidEntryPoint // Hilt로 Repository 등 주입 받을 수 있게 함
class TiggleMessagingService : FirebaseMessagingService() {

@Inject
lateinit var fcmRepository: FcmRepository

/**
* 새로운 FCM 토큰이 발급/갱신되었을 때(앱 설치 직후, 데이터 초기화, 토큰 만료 등)
* -> 서버에 업로드해서 최신 토큰으로 푸시를 받을 수 있도록 함.
*/
override fun onNewToken(token: String) {
super.onNewToken(token)
CoroutineScope(Dispatchers.IO).launch {
try {
val deviceId = Settings.Secure.getString(
applicationContext.contentResolver,
Settings.Secure.ANDROID_ID
) ?: "unknown"

//서버로 토큰 전송
fcmRepository.registerToken(token)
} catch (e: Exception) {
Log.e("TiggleFCM", "FCM 토큰 업로드 실패", e)
}
}
}

/**
* 앱이 "포그라운드"일 때, 또는 "data-only" 메시지를 받았을 때 호출됨.
*
* ※ 백그라운드 + notification payload 조합이면 시스템이 자동으로 알림을 띄우며
* 이 메서드는 호출되지 않는 게 정상
*
* 권한
* - Android 13+ 에서는 POST_NOTIFICATIONS 권한이 필요.
* 권한이 없으면 알림을 띄우지 않고 리턴.
*/

override fun onMessageReceived(message: RemoteMessage) {
// notification payload + data payload 모두 수동 처리
val title = message.data["title"]
?: message.notification?.title
?: "티끌"
val body = message.data["body"]
?: message.notification?.body
?: "새 알림이 도착했어요."

Log.d(TAG, "onMessageReceived: data=${message.data}, notif=${message.notification}")

// Android 13+ 권한 체크
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) return

val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pending = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

// ✅ 항상 직접 알림 생성
val channelId = getString(R.string.default_notification_channel_id)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.logo)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pending)
.build()

NotificationManagerCompat.from(this)
.notify((System.currentTimeMillis() % 100000).toInt(), notification)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.ssafy.tiggle.data.datasource.remote

import com.ssafy.tiggle.data.model.BaseResponse
import com.ssafy.tiggle.data.model.EmptyResponse
import com.ssafy.tiggle.data.model.fcm.FcmTokenRequestDto
import retrofit2.http.Body
import retrofit2.http.POST

interface FcmApiService {
@POST("fcm/token")
suspend fun registerToken(
@Body body: FcmTokenRequestDto
): BaseResponse<EmptyResponse>
}
Loading