Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 5 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.gms.google-services") version "4.4.4"
}

android {
Expand Down Expand Up @@ -54,6 +55,9 @@ android {
}

dependencies {
implementation(platform("com.google.firebase:firebase-bom:34.7.0"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-messaging")
implementation("io.coil-kt:coil-compose:2.6.0")

implementation(libs.navigation.compose) // navigation
Expand Down Expand Up @@ -83,4 +87,4 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
}
16 changes: 15 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">

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

<application
android:allowBackup="true"
Expand All @@ -13,6 +14,19 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.JapKor">

<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/fcm_default_channel_id" />

<service
android:name=".push.JapKorFirebaseMessagingService"
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 Expand Up @@ -43,4 +57,4 @@

</application>

</manifest>
</manifest>
48 changes: 48 additions & 0 deletions app/src/main/java/com/apptive/japkor/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.apptive.japkor

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
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.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
Expand All @@ -15,6 +20,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.apptive.japkor.data.local.DataStoreManager
import com.apptive.japkor.navigation.AppNavHost
Expand All @@ -25,13 +32,39 @@ import com.apptive.japkor.ui.components.ToastManager
import com.apptive.japkor.ui.components.ToastProvider
import com.apptive.japkor.ui.localization.AppLanguage
import com.apptive.japkor.ui.localization.LocalAppLanguage
import com.apptive.japkor.push.ensureDefaultNotificationChannel
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
Log.d(TAG, "Notification permission granted=$granted")
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

enableEdgeToEdge()

requestNotificationPermission()
ensureDefaultNotificationChannel(this)

val dataStoreManager = DataStoreManager(this)
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w(TAG, "Fetching FCM registration token failed", task.exception)
return@addOnCompleteListener
}

val token = task.result
Log.d(TAG, "FCM token: $token")
lifecycleScope.launch {
dataStoreManager.saveFcmToken(token)
}
}
Comment on lines +54 to +66
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Race condition between FCM token fetch and login. The FCM token is fetched asynchronously in MainActivity.onCreate() (lines 54-66) and saved via lifecycleScope.launch. However, if a user navigates to the login screen and attempts to sign in before this async operation completes, the fcmToken retrieved at line 45 could be empty. This creates a timing-dependent bug where login behavior differs based on how quickly the user navigates.

Copilot uses AI. Check for mistakes.

val startDestinationFromIntent =
intent.getStringExtra(EXTRA_START_DESTINATION)
val startDestination =
Expand All @@ -43,6 +76,21 @@ class MainActivity : ComponentActivity() {
}
}
}

private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return

val permission = Manifest.permission.POST_NOTIFICATIONS
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
return
}

notificationPermissionLauncher.launch(permission)
}

companion object {
private const val TAG = "MainActivity"
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class DataStoreManager(private val context: Context) {
val KEY_STATUS = stringPreferencesKey("status")
val KEY_REMEMBERED_EMAIL = stringPreferencesKey("remembered_email")
val KEY_LANGUAGE = stringPreferencesKey("app_language")
val KEY_FCM_TOKEN = stringPreferencesKey("fcm_token")
}

suspend fun saveUserInfo(memberId: Int, name: String, token: String, status: String) {
Expand All @@ -42,6 +43,14 @@ class DataStoreManager(private val context: Context) {
)
}

suspend fun saveFcmToken(token: String) {
context.dataStore.edit { prefs ->
prefs[KEY_FCM_TOKEN] = token
}
}

fun getFcmToken() = context.dataStore.data.map { it[KEY_FCM_TOKEN] ?: "" }

suspend fun saveRememberedEmail(email: String) {
context.dataStore.edit { prefs ->
prefs[KEY_REMEMBERED_EMAIL] = email
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/apptive/japkor/data/model/Auth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ data class SignUpDTO(

data class SignInDTO(
val email: String,
val password: String
val password: String,
val fcmToken: String,
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Missing trailing comma in data class parameter list. For consistency with Kotlin coding conventions and to reduce diff noise in future changes, add a trailing comma after the fcmToken parameter.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

API design concern - mandatory FCM token may break existing clients. Adding fcmToken as a required parameter to SignInDTO makes it mandatory for all sign-in requests. If there are other clients or older app versions still using the API, this would break their login functionality. Consider making the fcmToken parameter optional (nullable) in the DTO to maintain backward compatibility.

Suggested change
val fcmToken: String,
val fcmToken: String? = null,

Copilot uses AI. Check for mistakes.
)
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ class AuthRepository(
return response.isSuccessful
}

suspend fun signIn(email:String,password:String) : SignInResponse?{
val response = api.signIn(SignInDTO(email,password)).awaitResponse()
suspend fun signIn(email: String, password: String, fcmToken: String) : SignInResponse?{
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Inconsistent parameter spacing in function signature. The function parameters have inconsistent spacing around colons. The email parameter has "email: String" with proper spacing, while the original code likely had different spacing. Ensure consistent formatting with spaces after colons for all parameters.

Copilot uses AI. Check for mistakes.
val response = api.signIn(SignInDTO(email, password, fcmToken)).awaitResponse()
Log.d("AuthRepository", "signIn success=${response.isSuccessful} code=${response.code()}")

return if (response.isSuccessful) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.apptive.japkor.push

import android.app.PendingIntent
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.apptive.japkor.MainActivity
import com.apptive.japkor.R
import com.apptive.japkor.data.local.DataStoreManager
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class JapKorFirebaseMessagingService : FirebaseMessagingService() {

override fun onNewToken(token: String) {
Log.d(TAG, "New FCM token: $token")
CoroutineScope(Dispatchers.IO).launch {
DataStoreManager(applicationContext).saveFcmToken(token)
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Missing error handling for FCM token generation. If the FCM token retrieval fails in onNewToken(), the coroutine will crash silently without any fallback mechanism. Consider adding try-catch error handling and logging any failures, so that token save failures don't go unnoticed.

Suggested change
DataStoreManager(applicationContext).saveFcmToken(token)
try {
DataStoreManager(applicationContext).saveFcmToken(token)
} catch (e: Exception) {
Log.e(TAG, "Failed to save FCM token", e)
}

Copilot uses AI. Check for mistakes.
}
}

override fun onMessageReceived(message: RemoteMessage) {
val title = message.notification?.title ?: message.data["title"]
val body = message.notification?.body ?: message.data["body"]

if (title.isNullOrBlank() && body.isNullOrBlank()) {
Log.d(TAG, "Skipping notification: empty payload")
return
}

ensureDefaultNotificationChannel(this)

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

val channelId = getString(R.string.fcm_default_channel_id)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title ?: getString(R.string.app_name))
.setContentText(body.orEmpty())
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()

NotificationManagerCompat.from(this)
.notify(System.currentTimeMillis().toInt(), notification)
Comment on lines +58 to +59
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Missing runtime permission check before displaying notifications. On Android 13 (API 33) and above, the POST_NOTIFICATIONS permission can be denied by users even after being requested. You should check if the permission is granted before calling notify(), otherwise a SecurityException could be thrown when the permission is denied.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Potential notification ID collision and memory issue. Using System.currentTimeMillis().toInt() as the notification ID can lead to problems: (1) notifications arriving within the same millisecond will have the same ID and overwrite each other, (2) the cast to Int can cause collisions due to truncation. Consider using a more reliable approach such as a sequential counter or hash of the message ID.

Copilot uses AI. Check for mistakes.
}

companion object {
private const val TAG = "FcmService"
}
}
24 changes: 24 additions & 0 deletions app/src/main/java/com/apptive/japkor/push/NotificationChannels.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.apptive.japkor.push

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import com.apptive.japkor.R

Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Missing documentation for public function. The ensureDefaultNotificationChannel function is a public API used across multiple files but lacks documentation explaining its purpose, when it should be called, and why it's safe to call multiple times. Consider adding KDoc to explain that this function is idempotent and handles channel creation for Android O and above.

Suggested change
/**
* Ensures that the app's default notification channel exists.
*
* This function should be called before posting notifications that rely on the
* default FCM notification channel (for example during app startup or when
* initializing push notification handling).
*
* On Android versions prior to O (API 26), this method is a no-op because
* notification channels are not supported. On Android O and above, it will
* create the default channel if it does not already exist.
*
* The operation is idempotent and safe to call multiple times: if the channel
* already exists, the existing channel is left unchanged.
*
* @param context a [Context] used to access resources and the [NotificationManager].
*/

Copilot uses AI. Check for mistakes.
fun ensureDefaultNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return

val channelId = context.getString(R.string.fcm_default_channel_id)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(channelId) != null) return

val channelName = context.getString(R.string.fcm_default_channel_name)
val channelDescription = context.getString(R.string.fcm_default_channel_description)
val importance = NotificationManager.IMPORTANCE_HIGH

val channel = NotificationChannel(channelId, channelName, importance).apply {
description = channelDescription
}
manager.createNotificationChannel(channel)
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ class LoginScreenViewModel(
Log.d("LoginVM", "signIn 호출: email=$email, password=$password")

try {
val result = repository.signIn(email, password)
val fcmToken = dataStore.getFcmToken().first()
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

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

Potential null pointer exception when fcmToken is empty. If getFcmToken() returns an empty string (which happens when the token hasn't been saved yet), this empty string will be sent to the API. The login could fail during app's first launch before the FCM token is obtained. Consider handling this case by either waiting for the token to be available, deferring the login, or making the token optional in the sign-in flow.

Suggested change
val fcmToken = dataStore.getFcmToken().first()
val storedFcmToken = dataStore.getFcmToken().first()
val fcmToken = storedFcmToken.takeIf { it.isNotBlank() }
if (fcmToken == null) {
Log.w("LoginVM", "FCM token is not yet available; proceeding with sign-in without it.")
}

Copilot uses AI. Check for mistakes.
val result = repository.signIn(email, password, fcmToken)
if (result != null) {
Log.d("LoginVM", "로그인 성공! token=${result.token}")
TokenProvider.setToken(result.token)
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<resources>
<string name="app_name">en</string>
<string name="default_web_client_id">504737014677-p232m5s4jtl1t1v48ff6ir7gl3pcjokp.apps.googleusercontent.com</string>
</resources>
<string name="fcm_default_channel_id">fcm_default_channel</string>
<string name="fcm_default_channel_name">Default notifications</string>
<string name="fcm_default_channel_description">General notifications</string>
</resources>
Loading