Skip to content
Open
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@ dependencies {
implementation(projects.core.designsystem)
implementation(projects.core.domain)
implementation(projects.core.navigation)
implementation(projects.feature.alarm)
implementation(projects.feature.home)
}
9 changes: 8 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
Expand All @@ -16,6 +18,11 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.StopReminder">
<service
android:name=".service.AlarmForegroundService"
android:exported="false"
android:foregroundServiceType="location" />

<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package com.choiminjun.stopreminder

import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber

@HiltAndroidApp
class StopReminderApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,90 @@ package com.choiminjun.stopreminder.navigation

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.choiminjun.alarm.navigation.alarmGraph
import com.choiminjun.home.navigation.homeGraph
import com.choiminjun.navigation.HomeBaseRoute
import com.choiminjun.navigation.HomeGraph
import com.choiminjun.stopreminder.service.startAlarmService
import com.choiminjun.stopreminder.service.stopAlarmService

@Composable
fun AppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current

NavHost(
navController = navController,
startDestination = HomeBaseRoute,
modifier = modifier,
) {
homeGraph(
navigateToBusRoute = { routeId, routeNo, routeType, startNodeName, endNodeName, cityCode ->
navController.navigate(HomeGraph.BusRouteRoute(routeId, routeNo, routeType, startNodeName, endNodeName, cityCode))
navigateToBusRoute = { busRoute ->
navController.navigate(
HomeGraph.BusRouteRoute(
busRoute.routeId,
busRoute.routeNo,
busRoute.routeType,
busRoute.startNodeName,
busRoute.endNodeName,
busRoute.cityCode.name,
),
)
},
navigateToBusNode = { nodeId, nodeName, nodeNo, cityCode ->
navController.navigate(HomeGraph.BusNodeRoute(nodeId, nodeName, nodeNo, cityCode))
navigateToBusNode = { busNode ->
navController.navigate(
HomeGraph.BusNodeRoute(busNode.nodeId, busNode.nodeName, busNode.nodeNo, busNode.cityCode.name),
)
},
navigateBack = { navController.popBackStack() },
navigateToHome = {
navController.navigate(HomeGraph.HomeRoute) {
popUpTo(HomeBaseRoute) { inclusive = false }
}
},
navigateToAlarmSetting = { routeId, routeNo ->
navController.navigate(HomeGraph.AlarmSettingRoute(routeId, routeNo))
navigateToAlarmSetting = { routeId, routeNo, boardingNodeId, boardingNodeName ->
navController.navigate(HomeGraph.AlarmSettingRoute(routeId, routeNo, boardingNodeId, boardingNodeName))
},
navigateToAlarmMonitor = {
navController.navigate(HomeGraph.AlarmMonitorRoute) {
popUpTo(HomeGraph.HomeRoute) { inclusive = false }
}
},
navigateToAlarmRing = {
navController.navigate(HomeGraph.AlarmRingRoute)
navController.navigate(HomeGraph.AlarmRingRoute) {
launchSingleTop = true
}
},
)
alarmGraph(
navigateBack = { navController.popBackStack() },
onAlarmSet = {
navController.navigate(HomeGraph.AlarmMonitorRoute) {
popUpTo(HomeGraph.HomeRoute) { inclusive = false }
}
},
onAlarmStopped = {
navController.navigate(HomeGraph.HomeRoute) {
popUpTo(HomeBaseRoute) { inclusive = false }
}
},
navigateToAlarmRing = {
navController.navigate(HomeGraph.AlarmRingRoute) {
launchSingleTop = true
}
},
onAlarmDismissed = {
navController.navigate(HomeGraph.HomeRoute) {
popUpTo(HomeBaseRoute) { inclusive = false }
}
},
startAlarmService = { routeNo, nodeName -> startAlarmService(context, routeNo, nodeName) },
stopAlarmService = { stopAlarmService(context) },
)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.choiminjun.home.service
package com.choiminjun.stopreminder.service

import android.app.Notification
import android.app.NotificationChannel
Expand All @@ -9,26 +9,37 @@ import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.content.ContextCompat
import com.choiminjun.domain.model.alarm.AlarmInfo
import com.choiminjun.domain.repository.AlarmRepository
import com.choiminjun.home.R
import com.choiminjun.domain.usecase.ObserveNearestNodeUseCase
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import com.choiminjun.alarm.R as AlarmR

@AndroidEntryPoint
class AlarmForegroundService : Service() {

@Inject
lateinit var alarmRepository: AlarmRepository

@Inject
lateinit var observeNearestNode: ObserveNearestNodeUseCase

private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var observeJob: Job? = null
private var locationJob: Job? = null

override fun onBind(intent: Intent?): IBinder? = null

Expand All @@ -44,6 +55,10 @@ class AlarmForegroundService : Service() {
if (routeNo != null && nodeName != null) {
startForeground(NOTIFICATION_ID, buildNotification(routeNo, nodeName))
observeAlarmState()
scope.launch {
val alarm = alarmRepository.observeAlarm().first()
if (alarm.routeId.isNotBlank()) startLocationTracking(alarm)
}
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: AlarmDataSource의 AlarmInfo 초기값 확인

rg -nP -A5 'AlarmInfo\(' --type=kt --glob '*AlarmDataSource*'

Repository: mj010504/StopReminder

Length of output: 92


🏁 Script executed:

#!/bin/bash
# Find AlarmDataSource file
fd -i 'AlarmDataSource' --type f

# Find AlarmInfo data class
rg -n 'data class AlarmInfo' --type kotlin

Repository: mj010504/StopReminder

Length of output: 242


🏁 Script executed:

#!/bin/bash
# Search for AlarmInfo instantiation patterns
rg -n 'AlarmInfo(' -A 3 --type kotlin --max-count=5

Repository: mj010504/StopReminder

Length of output: 136


🏁 Script executed:

#!/bin/bash
# Check AlarmForegroundService implementation
fd -i 'AlarmForegroundService' --type f -exec wc -l {} \;

Repository: mj010504/StopReminder

Length of output: 252


🏁 Script executed:

#!/bin/bash
# Read AlarmInfo definition
cat -n core/domain/src/main/java/com/choiminjun/domain/model/alarm/AlarmInfo.kt

Repository: mj010504/StopReminder

Length of output: 493


🏁 Script executed:

#!/bin/bash
# Read AlarmDataSource
cat -n core/datastore/src/main/java/com/choiminjun/datastore/source/AlarmDataSource.kt

Repository: mj010504/StopReminder

Length of output: 4000


🏁 Script executed:

#!/bin/bash
# Read AlarmForegroundService around lines 58-61
cat -n app/src/main/java/com/choiminjun/stopreminder/service/AlarmForegroundService.kt | head -120 | tail -80

Repository: mj010504/StopReminder

Length of output: 3477


AlarmDataSource에서 routeId가 빈 문자열로 초기화되므로 서비스 시작 직후 즉시 종료됩니다.

AlarmInforouteId는 기본값이 빈 문자열 ""이며, AlarmDataSource도 저장된 값이 없으면 빈 문자열을 반환합니다. 따라서 서비스 시작 시 observeAlarmState()는 첫 번째 emission에서 이미 routeId.isBlank()를 감지하고 즉시 stopSelf()를 호출합니다. 동시에 라인 60의 if (alarm.routeId.isNotBlank())는 실패하여 startLocationTracking()이 시작되지 않습니다.

알람이 설정되기 전에 서비스가 시작될 경우 위치 추적이 전혀 시작되지 않고 서비스가 바로 종료되는 문제가 발생합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/choiminjun/stopreminder/service/AlarmForegroundService.kt`
around lines 58 - 61, The code in the scope.launch block uses
observeAlarm().first() which captures the initial emission with a blank routeId
before any alarm is configured, causing the if condition checking
alarm.routeId.isNotBlank() to immediately fail and never start location
tracking. Modify the observeAlarm() chain to filter for alarms with non-blank
routeIds using a filter operation, so that the first() call waits for a valid
alarm with a populated routeId rather than immediately capturing the initial
blank value. This ensures startLocationTracking(alarm) is called with a properly
configured alarm instead of being skipped due to the blank default value.

} else {
scope.launch {
val alarm = alarmRepository.observeAlarm().first()
Expand All @@ -52,6 +67,7 @@ class AlarmForegroundService : Service() {
} else {
startForeground(NOTIFICATION_ID, buildNotification(alarm.routeNo, alarm.destNodeName))
observeAlarmState()
startLocationTracking(alarm)
}
}
}
Expand All @@ -63,7 +79,32 @@ class AlarmForegroundService : Service() {
observeJob?.cancel()
observeJob = scope.launch {
alarmRepository.observeAlarm().collect { alarm ->
if (alarm.routeId.isBlank()) stopSelf()
if (alarm.routeId.isBlank()) {
stopSelf()
}
}
}
}

private fun startLocationTracking(alarmInfo: AlarmInfo) {
locationJob?.cancel()
locationJob = scope.launch {
while (isActive) {
try {
observeNearestNode(alarmInfo.routeId, alarmInfo.boardingNodeId, alarmInfo.destNodeId)
.collect { result ->
alarmRepository.updateNearestNode(result)
if (result.remaining in 0..alarmInfo.stopsBeforeAlarm) {
alarmRepository.triggerAlarm()
stopSelf()
}
Comment on lines +97 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

stopSelf() 호출 후 Flow 수집이 계속될 수 있습니다.

stopSelf()는 비동기적으로 서비스를 종료하며, onDestroy()가 호출될 때까지 시간이 걸립니다. 그 사이에 collect 블록이 계속 실행되어 추가 result를 처리하고 triggerAlarm()을 중복 호출할 수 있습니다.

🛡️ 제안: stopSelf() 전에 Job을 즉시 취소
                         alarmRepository.updateNearestNode(result)
                         if (result.remaining in 0..alarmInfo.stopsBeforeAlarm) {
                             alarmRepository.triggerAlarm()
+                            locationJob?.cancel()
                             stopSelf()
                         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/choiminjun/stopreminder/service/AlarmForegroundService.kt`
around lines 97 - 100, The issue is that stopSelf() asynchronously terminates
the service while the Flow collection continues, potentially causing multiple
triggerAlarm() calls. To fix this, cancel the Job that is managing the Flow
collection immediately before calling stopSelf(). This ensures the collect block
stops executing and prevents duplicate alarm triggers. Locate where the Job is
created for the Flow collection (likely in a lifecycleScope or separate job
variable) and call cancel() on it before the stopSelf() call in the conditional
block where result.remaining is in the range 0..alarmInfo.stopsBeforeAlarm.

}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "위치 추적 오류, 5초 후 재시도")
delay(5.seconds)
}
}
}
}
Comment on lines +89 to 110

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

while (isActive) 무한 루프는 정상 종료 시에도 계속 재시도합니다.

observeNearestNode().collect()가 정상 완료되면 루프는 즉시 다시 구독을 시작합니다. Flow가 upstream에서 완료되는 경우(예: 저장소가 특정 조건에서 Flow를 닫는 경우) 무한 재구독이 발생할 수 있습니다.

♻️ 제안: 정상 완료와 오류를 구분하는 로직 추가
 private fun startLocationTracking(alarmInfo: AlarmInfo) {
     locationJob?.cancel()
     locationJob = scope.launch {
-        while (isActive) {
-            try {
-                observeNearestNode(alarmInfo.routeId, alarmInfo.boardingNodeId, alarmInfo.destNodeId)
-                    .collect { result ->
-                        alarmRepository.updateNearestNode(result)
-                        if (result.remaining in 0..alarmInfo.stopsBeforeAlarm) {
-                            alarmRepository.triggerAlarm()
-                            stopSelf()
-                        }
-                    }
-            } catch (e: CancellationException) {
-                throw e
-            } catch (e: Exception) {
-                Timber.e(e, "위치 추적 오류, 5초 후 재시도")
-                delay(5.seconds)
-            }
+        try {
+            observeNearestNode(alarmInfo.routeId, alarmInfo.boardingNodeId, alarmInfo.destNodeId)
+                .collect { result ->
+                    alarmRepository.updateNearestNode(result)
+                    if (result.remaining in 0..alarmInfo.stopsBeforeAlarm) {
+                        alarmRepository.triggerAlarm()
+                        stopSelf()
+                    }
+                }
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
+            Timber.e(e, "위치 추적 오류")
+            stopSelf()
         }
     }
 }

또는 ObserveNearestNodeUseCase 내부에서 무한 재시도를 처리하도록 설계되었다면, PR 목표에서 언급한 "5초 재시도"가 서비스 레벨이 아닌 UseCase/Repository 레벨에서 처리되는지 확인하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun startLocationTracking(alarmInfo: AlarmInfo) {
locationJob?.cancel()
locationJob = scope.launch {
while (isActive) {
try {
observeNearestNode(alarmInfo.routeId, alarmInfo.boardingNodeId, alarmInfo.destNodeId)
.collect { result ->
alarmRepository.updateNearestNode(result)
if (result.remaining in 0..alarmInfo.stopsBeforeAlarm) {
alarmRepository.triggerAlarm()
stopSelf()
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "위치 추적 오류, 5초 후 재시도")
delay(5.seconds)
}
}
}
}
private fun startLocationTracking(alarmInfo: AlarmInfo) {
locationJob?.cancel()
locationJob = scope.launch {
try {
observeNearestNode(alarmInfo.routeId, alarmInfo.boardingNodeId, alarmInfo.destNodeId)
.collect { result ->
alarmRepository.updateNearestNode(result)
if (result.remaining in 0..alarmInfo.stopsBeforeAlarm) {
alarmRepository.triggerAlarm()
stopSelf()
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "위치 추적 오류")
stopSelf()
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/choiminjun/stopreminder/service/AlarmForegroundService.kt`
around lines 89 - 110, The `while (isActive)` loop in the startLocationTracking
method causes infinite resubscription when observeNearestNode().collect()
completes normally. Instead of looping indefinitely, the flow should only retry
on exceptions, not on normal completion. Modify the logic so that when the Flow
completes without an exception, the loop breaks and stops the resubscription,
while still implementing the 5-second retry delay only for actual Exception
cases caught in the catch block. This way, normal completion (when the
repository intentionally closes the Flow) will properly terminate location
tracking instead of immediately restarting the subscription.

Expand All @@ -72,6 +113,7 @@ class AlarmForegroundService : Service() {
val tapIntent = packageManager.getLaunchIntentForPackage(packageName)
?.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }
?: Intent()

val pendingIntent = PendingIntent.getActivity(
this,
0,
Expand All @@ -80,8 +122,8 @@ class AlarmForegroundService : Service() {
)

return Notification.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.alarm_notification_title))
.setContentText(getString(R.string.alarm_notification_text, routeNo, nodeName))
.setContentTitle(getString(AlarmR.string.alarm_notification_title))
.setContentText(getString(AlarmR.string.alarm_notification_text, routeNo, nodeName))
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentIntent(pendingIntent)
.setOngoing(true)
Expand All @@ -91,7 +133,7 @@ class AlarmForegroundService : Service() {
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.alarm_notification_channel_name),
getString(AlarmR.string.alarm_notification_channel_name),
NotificationManager.IMPORTANCE_HIGH,
)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
Expand All @@ -100,6 +142,7 @@ class AlarmForegroundService : Service() {

override fun onDestroy() {
super.onDestroy()
locationJob?.cancel()
scope.cancel()
}

Expand All @@ -111,14 +154,14 @@ class AlarmForegroundService : Service() {
}
}

internal fun startAlarmService(context: Context, routeNo: String, nodeName: String) {
fun startAlarmService(context: Context, routeNo: String, nodeName: String) {
val intent = Intent(context, AlarmForegroundService::class.java).apply {
putExtra(AlarmForegroundService.EXTRA_ROUTE_NO, routeNo)
putExtra(AlarmForegroundService.EXTRA_NODE_NAME, nodeName)
}
ContextCompat.startForegroundService(context, intent)
}

internal fun stopAlarmService(context: Context) {
fun stopAlarmService(context: Context) {
context.stopService(Intent(context, AlarmForegroundService::class.java))
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ internal fun Project.configureKotlinAndroid() {
val libs = extensions.libs
dependencies {
"detektPlugins"(libs.findLibrary("detekt.formatting").get())
"implementation"(libs.findLibrary("timber").get())
}

configureKotlin()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.choiminjun.common.util

import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt

private const val EARTH_RADIUS_METERS = 6_371_000.0

// 하버사인 공식
fun calculateDistance(
lat1: Double,
lon1: Double,
lat2: Double,
lon2: Double,
): Double {
val phi1 = Math.toRadians(lat1)
val phi2 = Math.toRadians(lat2)
val deltaPhi = Math.toRadians(lat2 - lat1)
val deltaLambda = Math.toRadians(lon2 - lon1)

val a = sin(deltaPhi / 2).pow(2) + cos(phi1) * cos(phi2) * sin(deltaLambda / 2).pow(2)
return EARTH_RADIUS_METERS * 2 * atan2(sqrt(a), sqrt(1 - a))
}
1 change: 1 addition & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ dependencies {
implementation(projects.core.network)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(libs.play.services.location)
}
6 changes: 6 additions & 0 deletions core/data/src/main/java/com/choiminjun/data/di/DataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package com.choiminjun.data.di
import com.choiminjun.data.repository.AlarmRepositoryImpl
import com.choiminjun.data.repository.BusRepositoryImpl
import com.choiminjun.data.repository.FavoriteRepositoryImpl
import com.choiminjun.data.repository.LocationRepositoryImpl
import com.choiminjun.data.repository.RecentSearchRepositoryImpl
import com.choiminjun.domain.repository.AlarmRepository
import com.choiminjun.domain.repository.BusRepository
import com.choiminjun.domain.repository.FavoriteRepository
import com.choiminjun.domain.repository.LocationRepository
import com.choiminjun.domain.repository.RecentSearchRepository
import dagger.Binds
import dagger.Module
Expand Down Expand Up @@ -34,4 +36,8 @@ abstract class DataModule {
@Binds
@Singleton
abstract fun bindFavoriteRepository(impl: FavoriteRepositoryImpl): FavoriteRepository

@Binds
@Singleton
abstract fun bindLocationRepository(impl: LocationRepositoryImpl): LocationRepository
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.choiminjun.data.repository

import com.choiminjun.datastore.source.AlarmDataSource
import com.choiminjun.domain.model.alarm.AlarmInfo
import com.choiminjun.domain.model.location.NearestNodeResult
import com.choiminjun.domain.repository.AlarmRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
Expand All @@ -16,4 +17,10 @@ class AlarmRepositoryImpl @Inject constructor(
override suspend fun setAlarm(alarm: AlarmInfo) = alarmDataSource.setAlarm(alarm)

override suspend fun clearAlarm() = alarmDataSource.clearAlarm()

override suspend fun triggerAlarm() = alarmDataSource.triggerAlarm()

override fun observeNearestNode(): Flow<NearestNodeResult?> = alarmDataSource.nearestNode

override fun updateNearestNode(result: NearestNodeResult) = alarmDataSource.updateNearestNode(result)
}
Loading
Loading