diff --git a/.gitignore b/.gitignore
index 3f9dbee21..97b7840bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -170,3 +170,4 @@ fabric.properties
#Login API KEY
/app/src/main/res/values/kakao_strings.xml
gradle/gradle-daemon-jvm.properties
+gps-sim-results/
diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/run/RunStatsPanelTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/run/RunStatsPanelTest.kt
new file mode 100644
index 000000000..1a7abc2a7
--- /dev/null
+++ b/app/src/androidTest/java/com/runnect/runnect/presentation/run/RunStatsPanelTest.kt
@@ -0,0 +1,47 @@
+package com.runnect.runnect.presentation.run
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import com.runnect.runnect.presentation.ui.theme.RunnectTheme
+import org.junit.Rule
+import org.junit.Test
+
+class RunStatsPanelTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private fun setPanel(state: RunTrackingState) {
+ composeTestRule.setContent { RunnectTheme { RunStatsPanel(state = state) } }
+ }
+
+ @Test
+ fun 거리_시간_페이스가_각_칸에_표시된다() {
+ setPanel(RunTrackingState(elapsedSec = 3_725, distanceM = 1_200.0, paceSecPerKm = 334.0))
+
+ composeTestRule.onNodeWithTag(RunStatsPanelTestTags.DISTANCE).assertTextEquals("1.2")
+ composeTestRule.onNodeWithTag(RunStatsPanelTestTags.TIME).assertTextEquals("01:02:05")
+ composeTestRule.onNodeWithTag(RunStatsPanelTestTags.PACE).assertTextEquals("5'34\"")
+ }
+
+ @Test
+ fun 목표_페이스를_정했으면_페이스_아래에_목표가_보인다() {
+ setPanel(RunTrackingState(paceSecPerKm = 358.0, targetPaceSecPerKm = 300.0))
+
+ composeTestRule.onNodeWithTag(RunStatsPanelTestTags.TARGET)
+ .assertIsDisplayed()
+ .assertTextEquals("목표 5'00\"")
+ }
+
+ @Test
+ fun 목표_페이스가_없어도_같은_자리에_목표_없음을_표시한다() {
+ setPanel(RunTrackingState(paceSecPerKm = null, targetPaceSecPerKm = null))
+
+ composeTestRule.onNodeWithTag(RunStatsPanelTestTags.TARGET)
+ .assertIsDisplayed()
+ .assertTextEquals("목표 없음")
+ composeTestRule.onNodeWithTag(RunStatsPanelTestTags.PACE).assertTextEquals("-")
+ }
+}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index 5fc6110b8..40d501506 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -6,6 +6,11 @@
android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
+
+
+
) {
+
+ enum class Speed(val label: String, val metersPerSec: Double) {
+ SLOW("느림 7'35\"", 2.2),
+ NORMAL("보통 5'57\"", 2.8),
+ FAST("빠름 4'46\"", 3.5),
+ }
+
+ // 화면 회전으로 Activity가 재생성돼도 시뮬레이션이 끊기지 않도록 Activity 수명과 무관한 scope에서 돈다.
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private val fusedClient = LocationServices.getFusedLocationProviderClient(context.applicationContext)
+ private val path = route.zipWithNext().filter { (a, b) -> a.distanceTo(b) > 0.0 }
+ private val totalLengthM = path.sumOf { (a, b) -> a.distanceTo(b) }
+
+ private var job: Job? = null
+ private var traveledM = 0.0
+
+ var speed = Speed.NORMAL
+ var offRouteM = 0.0 // 0이면 코스 위, 양수면 그만큼 코스 옆으로 벗어나서 달린다
+ set(value) {
+ field = value
+ if (value > 0.0) offsetSide = pickOffsetSide(value)
+ }
+ // 벗어날 방향(1=진행 방향 왼쪽, -1=오른쪽). 코스가 꺾이는 곳에서 안쪽으로 벗어나면 다른 구간과 가까워져
+ // 실제로는 코스 근처인 위치가 되므로, 앞으로 달릴 구간 기준으로 코스 전체에서 더 멀어지는 쪽을 고른다.
+ private var offsetSide = 1.0
+ // 실제 사람처럼 코스에서 점점 멀어지고/돌아오도록 1초에 OFFSET_STEP_M씩만 목표 이탈 거리로 옮겨간다.
+ // (한 번에 50m를 옮기면 앱의 GPS 튐 필터에 걸려 실제와 다른 경로로 검증된다)
+ private var currentOffsetM = 0.0
+ var isHalted = false // true면 제자리에 서 있는다(자동 일시정지 확인용)
+ val isRunning get() = job?.isActive == true
+
+ var onError: (Throwable) -> Unit = {}
+
+ fun start() {
+ if (isRunning || path.isEmpty()) return
+ fusedClient.setMockMode(true)
+ .addOnSuccessListener {
+ job = scope.launch {
+ while (isActive) {
+ if (!isHalted) {
+ traveledM = (traveledM + speed.metersPerSec).coerceAtMost(totalLengthM)
+ val targetOffset = offRouteM * offsetSide
+ currentOffsetM += (targetOffset - currentOffsetM).coerceIn(-OFFSET_STEP_M, OFFSET_STEP_M)
+ }
+ pushLocation(positionAt(traveledM))
+ delay(TICK_MILLIS)
+ }
+ }
+ }
+ .addOnFailureListener(onError)
+ }
+
+ /** 코스 출발점으로 되돌린다(TC를 같은 조건에서 다시 시작할 때). */
+ fun rewind() {
+ traveledM = 0.0
+ currentOffsetM = 0.0
+ offRouteM = 0.0
+ }
+
+ fun stop() {
+ job?.cancel()
+ job = null
+ runCatching { fusedClient.setMockMode(false) }
+ }
+
+ private fun positionAt(distanceM: Double): LatLng {
+ var remaining = distanceM
+ val (a, b) = path.firstOrNull { (a, b) ->
+ val length = a.distanceTo(b)
+ (remaining <= length).also { if (!it) remaining -= length }
+ } ?: return path.last().second.let { offset(path.last().first, it, it) }
+
+ val t = remaining / a.distanceTo(b)
+ val point = LatLng(
+ a.latitude + (b.latitude - a.latitude) * t,
+ a.longitude + (b.longitude - a.longitude) * t,
+ )
+ return offset(a, b, point)
+ }
+
+ private fun pickOffsetSide(meters: Double): Double {
+ val lookahead = (0..LOOKAHEAD_SEC step 2).map { traveledM + speed.metersPerSec * it }
+ fun worstCase(side: Double) = lookahead.minOf { d ->
+ val point = offsetAt(d, meters * side)
+ RouteGeometry.distanceToPathM(point, route) ?: 0.0
+ }
+ return if (worstCase(1.0) >= worstCase(-1.0)) 1.0 else -1.0
+ }
+
+ /** 경로상 distanceM 지점에서 진행 방향 왼쪽으로 signedOffsetM(음수면 오른쪽)만큼 떨어진 위치. */
+ private fun offsetAt(distanceM: Double, signedOffsetM: Double): LatLng {
+ val saved = currentOffsetM
+ currentOffsetM = signedOffsetM
+ return positionAt(distanceM).also { currentOffsetM = saved }
+ }
+
+ /** 진행 방향(a→b)의 법선 방향으로 currentOffsetM만큼 이동시킨다. */
+ private fun offset(a: LatLng, b: LatLng, point: LatLng): LatLng {
+ if (currentOffsetM == 0.0) return point
+ val metersPerDegLat = 111_320.0
+ val metersPerDegLng = metersPerDegLat * cos(Math.toRadians(point.latitude))
+ val dx = (b.longitude - a.longitude) * metersPerDegLng
+ val dy = (b.latitude - a.latitude) * metersPerDegLat
+ val length = sqrt(dx * dx + dy * dy).takeIf { it > 0.0 } ?: return point
+ val nx = -dy / length
+ val ny = dx / length
+ return LatLng(
+ point.latitude + ny * currentOffsetM / metersPerDegLat,
+ point.longitude + nx * currentOffsetM / metersPerDegLng,
+ )
+ }
+
+ private fun pushLocation(latLng: LatLng) {
+ val location = Location(LocationManager.GPS_PROVIDER).apply {
+ latitude = latLng.latitude
+ longitude = latLng.longitude
+ accuracy = 5f
+ speed = if (isHalted) 0f else this@GpsRouteSimulator.speed.metersPerSec.toFloat()
+ time = System.currentTimeMillis()
+ elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
+ }
+ fusedClient.setMockLocation(location).addOnFailureListener(onError)
+ }
+
+ companion object {
+ private const val TICK_MILLIS = 1_000L
+ private const val OFFSET_STEP_M = 10.0
+ private const val LOOKAHEAD_SEC = 30
+ }
+}
diff --git a/app/src/debug/java/com/runnect/runnect/presentation/run/RunDebugTools.kt b/app/src/debug/java/com/runnect/runnect/presentation/run/RunDebugTools.kt
new file mode 100644
index 000000000..ea41603e8
--- /dev/null
+++ b/app/src/debug/java/com/runnect/runnect/presentation/run/RunDebugTools.kt
@@ -0,0 +1,225 @@
+package com.runnect.runnect.presentation.run
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.util.Log
+import android.view.Gravity
+import android.widget.FrameLayout
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import com.naver.maps.geometry.LatLng
+
+/**
+ * debug 빌드 전용 러닝 화면 GPS 시뮬레이터 패널. 화면 왼쪽에 조작 버튼을 띄우고, adb로도 조작할 수 있다.
+ *
+ * adb shell am broadcast -a com.runnect.debug.GPS_SIM -p com.runnect.runnect --es cmd <명령>
+ * - start / stop / rewind(출발점으로) / halt(제자리) / go / slow / normal / fast
+ * - offroute(--ei meters , 기본 50m) / onroute
+ * - target(--ei sec <초/km>, 0이면 해제): 목표 페이스 지정
+ * - resume: 일시정지 해제(재개 버튼과 동일)
+ * - scenario(--es id ): 패널의 시나리오 버튼과 같은 상황을 만든다
+ * 러닝 상태는 onServiceTick이 1초마다 logcat 태그 RunSimState로 남긴다.
+ */
+object RunDebugTools {
+ const val ACTION = "com.runnect.debug.GPS_SIM"
+ private const val DEFAULT_OFF_ROUTE_M = 50
+
+ // 화면 회전 등으로 RunActivity가 재생성돼도 같은 러닝의 시뮬레이션을 이어가기 위해 보관한다.
+ private var retained: GpsRouteSimulator? = null
+
+ fun attach(activity: AppCompatActivity, route: List, controller: () -> RunController?) {
+ val simulator = retained?.takeIf { it.route == route } ?: GpsRouteSimulator(activity, route).also {
+ retained?.stop()
+ retained = it
+ }
+ simulator.apply {
+ onError = {
+ Toast.makeText(
+ activity.applicationContext, // 보관된 시뮬레이터가 재생성 전 Activity를 붙잡지 않게
+ "모의 위치 권한 필요: adb shell appops set ${activity.packageName} android:mock_location allow",
+ Toast.LENGTH_LONG
+ ).show()
+ stop()
+ }
+ }
+ // 버튼/adb 어느 쪽으로 바꿔도 패널 표시가 갱신되도록 상태 변경 시 증가시키는 값
+ var version by mutableIntStateOf(0)
+ var lastScenario by mutableStateOf(null)
+ fun runScenario(scenario: RunSimScenario) {
+ scenario.apply(simulator, controller(), activity.lifecycleScope)
+ lastScenario = scenario
+ Log.i("RunSimScenario", "apply ${scenario.id}")
+ version++
+ }
+ fun runCommand(command: String, meters: Int = DEFAULT_OFF_ROUTE_M, paceSec: Int = 0) {
+ when (command) {
+ "rewind" -> simulator.rewind()
+ "target" -> controller()?.setTargetPace(paceSec.takeIf { it > 0 }?.toDouble())
+ "resume" -> controller()?.resume()
+ "start" -> simulator.start()
+ "stop" -> simulator.stop()
+ "offroute" -> simulator.offRouteM = meters.toDouble()
+ "onroute" -> simulator.offRouteM = 0.0
+ "halt" -> simulator.isHalted = true
+ "go" -> simulator.isHalted = false
+ "slow" -> simulator.speed = GpsRouteSimulator.Speed.SLOW
+ "normal" -> simulator.speed = GpsRouteSimulator.Speed.NORMAL
+ "fast" -> simulator.speed = GpsRouteSimulator.Speed.FAST
+ }
+ version++
+ }
+
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val command = intent.getStringExtra("cmd") ?: return
+ if (command == "scenario") {
+ RunSimScenario.of(intent.getStringExtra("id"))?.let(::runScenario)
+ return
+ }
+ runCommand(command, intent.getIntExtra("meters", DEFAULT_OFF_ROUTE_M), intent.getIntExtra("sec", 0))
+ }
+ }
+ ContextCompat.registerReceiver(activity, receiver, IntentFilter(ACTION), ContextCompat.RECEIVER_EXPORTED)
+ activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ activity.unregisterReceiver(receiver)
+ if (activity.isFinishing) {
+ simulator.stop()
+ retained = null
+ }
+ }
+ })
+
+ val panel = ComposeView(activity).apply {
+ setContent {
+ version // 상태 변경 시 리컴포지션
+ SimulatorPanel(simulator, lastScenario, ::runCommand, ::runScenario)
+ }
+ }
+ activity.addContentView(
+ panel,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ Gravity.START or Gravity.CENTER_VERTICAL
+ )
+ )
+ }
+
+ /**
+ * 러닝 서비스가 1초마다 호출한다. 서비스에서 남기므로 화면이 꺼지거나 백그라운드여도 계속 기록된다.
+ * scripts/gps-sim-scenarios.sh가 이 로그로 TC를 판정한다.
+ */
+ fun onServiceTick(state: RunTrackingState) {
+ val alert = when (state.alert) {
+ is RunAlert.OffRoute -> "OFF_ROUTE"
+ is RunAlert.PaceDrop -> "PACE_DROP"
+ null -> "NONE"
+ }
+ Log.i(
+ "RunSimState",
+ "distKm=${state.distanceKm} distM=${state.distanceM.toInt()} pace=${state.paceSecPerKm?.toInt()} alert=$alert " +
+ "paused=${state.isPaused} sim=${retained?.isRunning == true}"
+ )
+ }
+
+ @Composable
+ private fun SimulatorPanel(
+ simulator: GpsRouteSimulator,
+ lastScenario: RunSimScenario?,
+ onCommand: (String) -> Unit,
+ onScenario: (RunSimScenario) -> Unit,
+ ) {
+ var isExpanded by rememberSaveable { mutableStateOf(false) }
+ Column(
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .width(if (isExpanded) 168.dp else 84.dp)
+ .background(Color(0xE6171717), RoundedCornerShape(10.dp))
+ .padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
+ // 제목과 상태 줄 전체를 눌러 펼치기/접기 — 작은 글자만 누르게 하면 터치가 잘 안 먹는다.
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { isExpanded = !isExpanded }
+ .padding(vertical = 2.dp),
+ verticalArrangement = Arrangement.spacedBy(3.dp),
+ ) {
+ PanelText(
+ text = if (isExpanded) "GPS 시뮬 ▾ 접기" else "GPS 시뮬 ▸",
+ color = Color(0xFF9C9AFD),
+ )
+ PanelText(
+ text = buildString {
+ append(if (simulator.isRunning) "● " else "○ ")
+ append(simulator.speed.label)
+ if (simulator.offRouteM > 0) append(" · 이탈")
+ if (simulator.isHalted) append(" · 멈춤")
+ },
+ color = Color(0xFFC1C1C1),
+ )
+ }
+ if (!isExpanded) return@Column
+
+ PanelButton(if (simulator.isRunning) "■ 시뮬 정지" else "▶ 시뮬 시작", Color(0xFF444444)) {
+ onCommand(if (simulator.isRunning) "stop" else "start")
+ }
+ RunSimScenario.entries.forEach { scenario ->
+ PanelButton(
+ text = scenario.title,
+ color = if (scenario == lastScenario) Color(0xFF7E71FF) else Color(0xFF593EEC),
+ ) { onScenario(scenario) }
+ }
+ lastScenario?.let {
+ PanelText(text = "기대: ${it.expectation}", color = Color(0xFFFFC617))
+ }
+ PanelText(text = "같은 알림은 60초 동안 다시 안 뜸", color = Color(0xFF8B8B8B))
+ }
+ }
+
+ @Composable
+ private fun PanelButton(text: String, color: Color = Color(0xFF593EEC), onClick: () -> Unit) {
+ PanelText(
+ text = text,
+ color = Color.White,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color, RoundedCornerShape(6.dp))
+ .clickable(onClick = onClick)
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ )
+ }
+
+ @Composable
+ private fun PanelText(text: String, color: Color, modifier: Modifier = Modifier) {
+ Text(text = text, color = color, fontSize = 11.sp, modifier = modifier)
+ }
+}
diff --git a/app/src/debug/java/com/runnect/runnect/presentation/run/RunSimScenario.kt b/app/src/debug/java/com/runnect/runnect/presentation/run/RunSimScenario.kt
new file mode 100644
index 000000000..0e9041b54
--- /dev/null
+++ b/app/src/debug/java/com/runnect/runnect/presentation/run/RunSimScenario.kt
@@ -0,0 +1,69 @@
+package com.runnect.runnect.presentation.run
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * 러닝 화면 기능별 테스트 상황(debug 전용). 시뮬레이터 패널 버튼과 scripts/gps-sim-scenarios.sh(adb)가
+ * 같은 정의를 쓰므로, 손으로 눌러보는 상황과 자동 TC가 항상 같은 조건이다.
+ * adb: am broadcast -a com.runnect.debug.GPS_SIM -p com.runnect.runnect --es cmd scenario --es id
+ */
+enum class RunSimScenario(
+ val id: String,
+ val title: String,
+ val expectation: String,
+ private val setUp: (GpsRouteSimulator, RunController?, CoroutineScope) -> Unit,
+) {
+ // 정상 주행은 사용자가 고른 목표 페이스를 건드리지 않는다(목표를 바꾸는 시나리오는 안내 문구에 명시).
+ NORMAL("normal", "정상 주행", "코스 위 5'57\" 주행, 이탈 알림 없음 (목표 페이스는 그대로)", { sim, _, _ ->
+ sim.offRouteM = 0.0
+ sim.isHalted = false
+ sim.speed = GpsRouteSimulator.Speed.NORMAL
+ }),
+ FASTER_THAN_TARGET("faster", "목표보다 빠르게", "목표를 5'30\"로 바꿔요 · 실제 4'46\" → 알림 없음", { sim, run, _ ->
+ run?.setTargetPace(330.0)
+ sim.speed = GpsRouteSimulator.Speed.FAST
+ }),
+ SLIGHTLY_SLOWER("slightly_slower", "목표보다 조금 느리게", "목표를 5'30\"로 바꿔요 · 실제 5'57\"(20% 미만 느림) → 알림 없음", { sim, run, _ ->
+ run?.setTargetPace(330.0)
+ sim.speed = GpsRouteSimulator.Speed.NORMAL
+ }),
+ PACE_DROP("pace_drop", "페이스 저하", "목표를 5'00\"로 바꿔요 · 실제 7'35\" → 약 20초 뒤 노란 배너 + 긴 진동 1번", { sim, run, _ ->
+ run?.setTargetPace(300.0)
+ sim.speed = GpsRouteSimulator.Speed.SLOW
+ }),
+ PACE_RECOVER("pace_recover", "페이스 회복", "약 15초 안에 노란 배너 사라짐 (4'46\")", { sim, _, _ ->
+ sim.speed = GpsRouteSimulator.Speed.FAST
+ }),
+ SHORT_DETOUR("short_detour", "잠깐 이탈 후 복귀", "알림 없음 (6초만 벗어났다 돌아옴)", { sim, _, scope ->
+ sim.offRouteM = 50.0
+ scope.launch {
+ delay(6_000)
+ sim.offRouteM = 0.0
+ }
+ }),
+ OFF_ROUTE("off_route", "코스 이탈", "약 10초 뒤 빨간 배너 + 짧은 진동 2번 (50m 벗어남)", { sim, _, _ ->
+ sim.offRouteM = 50.0
+ }),
+ BACK_ON_ROUTE("back_on_route", "코스 복귀", "몇 초 안에 빨간 배너 사라짐", { sim, _, _ ->
+ sim.offRouteM = 0.0
+ }),
+ HALT("halt", "제자리 멈춤", "약 20초 뒤 자동 일시정지", { sim, _, _ ->
+ sim.isHalted = true
+ }),
+ RESUME("resume", "다시 달리기", "일시정지 해제, 달리는 동안 다시 멈추지 않음", { sim, run, _ ->
+ sim.isHalted = false
+ run?.resume()
+ });
+
+ /** 시뮬레이터가 멈춰 있으면 먼저 출발시키고 이 상황을 만든다. */
+ fun apply(simulator: GpsRouteSimulator, controller: RunController?, scope: CoroutineScope) {
+ if (!simulator.isRunning) simulator.start()
+ setUp(simulator, controller, scope)
+ }
+
+ companion object {
+ fun of(id: String?) = entries.firstOrNull { it.id == id }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2008ace76..98475be65 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
+
diff --git a/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt
index efd10937a..e09f1177f 100644
--- a/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt
+++ b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownActivity.kt
@@ -1,9 +1,20 @@
package com.runnect.runnect.presentation.countdown
+import android.Manifest
import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runnect.runnect.data.dto.CourseData
import com.runnect.runnect.presentation.run.RunActivity
import com.runnect.runnect.presentation.ui.theme.RunnectTheme
@@ -11,9 +22,21 @@ import com.runnect.runnect.util.analytics.Analytics
import com.runnect.runnect.util.analytics.EventName
import com.runnect.runnect.util.analytics.EventName.Param
import com.runnect.runnect.util.extension.getCompatibleParcelableExtra
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class CountDownActivity : AppCompatActivity() {
private val courseData: CourseData? by lazy { intent.getCompatibleParcelableExtra(EXTRA_COURSE_DATA) }
+ private val targetPaceViewModel: TargetPaceViewModel by viewModels()
+
+ // 러닝 중 알림(타이머, 코스 이탈/페이스 저하 상단 알림)을 띄우려면 Android 13+에서 알림 권한이 필요하다.
+ // 거절해도 러닝은 그대로 진행한다(진동 알림은 권한 없이도 울린다).
+ private var onNotificationPermissionResult: (() -> Unit)? = null
+ private val notificationPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ onNotificationPermissionResult?.invoke()
+ onNotificationPermissionResult = null
+ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -23,11 +46,28 @@ class CountDownActivity : AppCompatActivity() {
Param.COURSE_ID to courseData?.courseId
)
+ targetPaceViewModel.loadRecommendations(
+ publicCourseId = courseData?.publicCourseId,
+ distanceKm = courseData?.distance?.toDouble(),
+ )
+
setContent {
RunnectTheme {
- CountDownRoute(
- onFinished = ::moveToRun
- )
+ // 목표 페이스를 고른 뒤에 카운트다운을 시작한다.
+ var isPaceChosen by rememberSaveable { mutableStateOf(false) }
+ if (isPaceChosen) {
+ CountDownRoute(
+ onFinished = ::moveToRun
+ )
+ } else {
+ val uiState by targetPaceViewModel.uiState.collectAsStateWithLifecycle()
+ TargetPaceScreen(
+ uiState = uiState,
+ onSelect = targetPaceViewModel::select,
+ onAdjustCustomPace = targetPaceViewModel::adjustCustomPace,
+ onStart = { requestNotificationPermissionThen { isPaceChosen = true } },
+ )
+ }
}
}
}
@@ -45,11 +85,26 @@ class CountDownActivity : AppCompatActivity() {
)
}
+ private fun requestNotificationPermissionThen(onDone: () -> Unit) {
+ val needsRequest = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ if (!needsRequest) {
+ onDone()
+ return
+ }
+ onNotificationPermissionResult = onDone
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+
private fun moveToRun() {
val intentToRun = Intent(this, RunActivity::class.java)
courseData?.let { courseData ->
intentToRun.putExtra(EXTRA_COUNTDOWN_TO_RUN, courseData)
}
+ targetPaceViewModel.uiState.value.selectedPaceSecPerKm?.let {
+ intentToRun.putExtra(RunActivity.EXTRA_TARGET_PACE_SEC_PER_KM, it)
+ }
startActivity(intentToRun)
finish()
}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownScreen.kt
index b1f92de0d..9913d6eca 100644
--- a/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownScreen.kt
+++ b/app/src/main/java/com/runnect/runnect/presentation/countdown/CountDownScreen.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -71,8 +72,9 @@ fun CountDownRoute(
onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
- var currentCount by remember { mutableIntStateOf(CountDownStateMachine.INITIAL_COUNT) }
- var isFinished by remember { mutableStateOf(false) }
+ // 화면 회전 시 카운트다운이 3부터 다시 시작하지 않도록 저장한다.
+ var currentCount by rememberSaveable { mutableIntStateOf(CountDownStateMachine.INITIAL_COUNT) }
+ var isFinished by rememberSaveable { mutableStateOf(false) }
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(lifecycle) {
diff --git a/app/src/main/java/com/runnect/runnect/presentation/countdown/TargetPaceScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/countdown/TargetPaceScreen.kt
new file mode 100644
index 000000000..feb28e5d5
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/countdown/TargetPaceScreen.kt
@@ -0,0 +1,203 @@
+package com.runnect.runnect.presentation.countdown
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.runnect.runnect.R
+import com.runnect.runnect.presentation.run.PaceFormat
+import com.runnect.runnect.presentation.ui.theme.G1
+import com.runnect.runnect.presentation.ui.theme.G2
+import com.runnect.runnect.presentation.ui.theme.G4
+import com.runnect.runnect.presentation.ui.theme.G5
+import com.runnect.runnect.presentation.ui.theme.M1
+import com.runnect.runnect.presentation.ui.theme.RunnectTheme
+import com.runnect.runnect.presentation.ui.theme.White
+
+object TargetPaceScreenTestTags {
+ const val START_BUTTON = "target_pace_start_button"
+ const val CUSTOM_VALUE = "target_pace_custom_value"
+ fun option(option: TargetPaceOption) = "target_pace_option_${option.name}"
+}
+
+/** 카운트다운 전에 목표 페이스를 고르는 화면. 목표 페이스는 러닝 중 페이스 저하 알림 기준으로만 쓰인다. */
+@Composable
+fun TargetPaceScreen(
+ uiState: TargetPaceUiState,
+ onSelect: (TargetPaceOption) -> Unit,
+ onAdjustCustomPace: (Int) -> Unit,
+ onStart: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(White)
+ .systemBarsPadding()
+ .padding(horizontal = 20.dp, vertical = 24.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.target_pace_title),
+ style = RunnectTheme.textStyle.bold20,
+ color = G1,
+ )
+ Text(
+ modifier = Modifier.padding(top = 6.dp),
+ text = stringResource(R.string.target_pace_subtitle),
+ style = RunnectTheme.textStyle.regular14,
+ color = G2,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ PaceChip(TargetPaceOption.NONE, stringResource(R.string.target_pace_none), null, uiState, onSelect)
+ uiState.topRankPaceSecPerKm?.let {
+ PaceChip(TargetPaceOption.TOP_RANK, stringResource(R.string.target_pace_top_rank), it, uiState, onSelect)
+ }
+ uiState.myBestPaceSecPerKm?.let {
+ PaceChip(TargetPaceOption.MY_BEST, stringResource(R.string.target_pace_my_best), it, uiState, onSelect)
+ }
+ PaceChip(TargetPaceOption.CUSTOM, stringResource(R.string.target_pace_custom), null, uiState, onSelect)
+ }
+
+ if (uiState.selection == TargetPaceOption.CUSTOM) {
+ CustomPaceStepper(
+ paceSecPerKm = uiState.customPaceSecPerKm,
+ onAdjust = onAdjustCustomPace,
+ modifier = Modifier.padding(top = 20.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(M1)
+ .clickable(role = Role.Button, onClick = onStart)
+ .testTag(TargetPaceScreenTestTags.START_BUTTON),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.target_pace_start),
+ style = RunnectTheme.textStyle.bold17,
+ color = White,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PaceChip(
+ option: TargetPaceOption,
+ label: String,
+ paceSecPerKm: Double?,
+ uiState: TargetPaceUiState,
+ onSelect: (TargetPaceOption) -> Unit,
+) {
+ val isSelected = uiState.selection == option
+ val text = if (paceSecPerKm != null) "$label ${PaceFormat.format(paceSecPerKm)}/km" else label
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(if (isSelected) M1 else White)
+ .border(1.dp, if (isSelected) M1 else G4, CircleShape)
+ .clickable(role = Role.RadioButton) { onSelect(option) }
+ .semantics { selected = isSelected }
+ .padding(horizontal = 14.dp, vertical = 9.dp)
+ .testTag(TargetPaceScreenTestTags.option(option)),
+ ) {
+ Text(text = text, style = RunnectTheme.textStyle.semiBold13, color = if (isSelected) White else G1)
+ }
+}
+
+@Composable
+private fun CustomPaceStepper(paceSecPerKm: Double, onAdjust: (Int) -> Unit, modifier: Modifier = Modifier) {
+ val step = TargetPaceViewModel.CUSTOM_PACE_STEP_SEC
+ val decreaseDesc = stringResource(R.string.target_pace_decrease)
+ val increaseDesc = stringResource(R.string.target_pace_increase)
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(G5)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ StepperButton(text = "−", description = decreaseDesc) { onAdjust(-step) }
+ Text(
+ modifier = Modifier
+ .weight(1f)
+ .testTag(TargetPaceScreenTestTags.CUSTOM_VALUE),
+ text = "${PaceFormat.format(paceSecPerKm)}/km",
+ style = RunnectTheme.textStyle.bold22,
+ color = G1,
+ textAlign = TextAlign.Center,
+ )
+ StepperButton(text = "+", description = increaseDesc) { onAdjust(step) }
+ }
+}
+
+@Composable
+private fun StepperButton(text: String, description: String, onClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(White)
+ .clickable(role = Role.Button, onClick = onClick)
+ .semantics { contentDescription = description },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = text, style = RunnectTheme.textStyle.bold20, color = M1)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun TargetPaceScreenPreview() {
+ RunnectTheme {
+ TargetPaceScreen(
+ uiState = TargetPaceUiState(
+ topRankPaceSecPerKm = 298.0,
+ myBestPaceSecPerKm = 388.0,
+ selection = TargetPaceOption.CUSTOM,
+ ),
+ onSelect = {},
+ onAdjustCustomPace = {},
+ onStart = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/countdown/TargetPaceViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/countdown/TargetPaceViewModel.kt
new file mode 100644
index 000000000..e1daea31f
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/countdown/TargetPaceViewModel.kt
@@ -0,0 +1,102 @@
+package com.runnect.runnect.presentation.countdown
+
+import androidx.lifecycle.SavedStateHandle
+import com.runnect.runnect.domain.repository.CourseRepository
+import com.runnect.runnect.presentation.base.BaseViewModel
+import com.runnect.runnect.presentation.run.PaceFormat
+import com.runnect.runnect.util.extension.collectResult
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+
+enum class TargetPaceOption { NONE, TOP_RANK, MY_BEST, CUSTOM }
+
+data class TargetPaceUiState(
+ val topRankPaceSecPerKm: Double? = null,
+ val myBestPaceSecPerKm: Double? = null,
+ val selection: TargetPaceOption = TargetPaceOption.NONE,
+ val customPaceSecPerKm: Double = TargetPaceViewModel.DEFAULT_CUSTOM_PACE_SEC,
+) {
+ /** 현재 선택된 목표 페이스(초/km). 설정 안 함이거나 선택한 추천값이 없으면 null. */
+ val selectedPaceSecPerKm: Double?
+ get() = when (selection) {
+ TargetPaceOption.NONE -> null
+ TargetPaceOption.TOP_RANK -> topRankPaceSecPerKm
+ TargetPaceOption.MY_BEST -> myBestPaceSecPerKm
+ TargetPaceOption.CUSTOM -> customPaceSecPerKm
+ }
+}
+
+@HiltViewModel
+class TargetPaceViewModel @Inject constructor(
+ private val courseRepository: CourseRepository,
+ private val savedStateHandle: SavedStateHandle,
+) : BaseViewModel() {
+
+ // 사용자가 고른 값(선택지, 직접 입력 페이스)은 Activity가 시스템에 의해 재생성돼도 유지한다. 추천값은 다시 불러온다.
+ private val _uiState = MutableStateFlow(
+ TargetPaceUiState(
+ selection = savedStateHandle.get(KEY_SELECTION)
+ ?.let { runCatching { TargetPaceOption.valueOf(it) }.getOrNull() }
+ ?: TargetPaceOption.NONE,
+ customPaceSecPerKm = savedStateHandle[KEY_CUSTOM_PACE] ?: DEFAULT_CUSTOM_PACE_SEC,
+ )
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var loadedCourseId: Int? = null
+
+ /**
+ * 공개 코스면 랭킹 1위 / 내 최고기록을 추천값으로 불러온다. 내가 그린 코스(publicCourseId 없음)는 랭킹이 없어 추천값 없이 진행.
+ * 서버 pace 문자열은 신뢰할 수 없어서(PaceFormat.parseDurationSec 참고) 기록 시간과 코스 거리로 직접 계산한다.
+ */
+ fun loadRecommendations(publicCourseId: Int?, distanceKm: Double?) {
+ if (publicCourseId == null || loadedCourseId == publicCourseId) return
+ loadedCourseId = publicCourseId
+
+ launchWithHandler {
+ courseRepository.getCourseRanking(courseId = publicCourseId, limit = 1).collectResult(
+ onSuccess = { ranking ->
+ val pace = PaceFormat.paceFromDuration(ranking.entries.firstOrNull()?.time, distanceKm)
+ _uiState.update { it.copy(topRankPaceSecPerKm = pace) }
+ }
+ )
+ }
+ launchWithHandler {
+ courseRepository.getMyCourseRanking(courseId = publicCourseId).collectResult(
+ onSuccess = { myRanking ->
+ val pace = if (myRanking.hasRecord) PaceFormat.paceFromDuration(myRanking.time, distanceKm) else null
+ _uiState.update { it.copy(myBestPaceSecPerKm = pace) }
+ }
+ )
+ }
+ }
+
+ fun select(option: TargetPaceOption) {
+ _uiState.update { it.copy(selection = option) }
+ savedStateHandle[KEY_SELECTION] = option.name
+ }
+
+ /** 직접 입력 페이스를 5초 단위로 조정한다. 너무 빠르거나 느린 값은 범위 안으로 제한한다. */
+ fun adjustCustomPace(deltaSec: Int) {
+ _uiState.update {
+ it.copy(
+ customPaceSecPerKm = (it.customPaceSecPerKm + deltaSec)
+ .coerceIn(MIN_CUSTOM_PACE_SEC, MAX_CUSTOM_PACE_SEC)
+ )
+ }
+ savedStateHandle[KEY_CUSTOM_PACE] = _uiState.value.customPaceSecPerKm
+ }
+
+ companion object {
+ private const val KEY_SELECTION = "targetPaceSelection"
+ private const val KEY_CUSTOM_PACE = "targetPaceCustom"
+ const val DEFAULT_CUSTOM_PACE_SEC = 360.0 // 6'00"
+ const val MIN_CUSTOM_PACE_SEC = 180.0 // 3'00"
+ const val MAX_CUSTOM_PACE_SEC = 900.0 // 15'00"
+ const val CUSTOM_PACE_STEP_SEC = 5
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/PaceFormat.kt b/app/src/main/java/com/runnect/runnect/presentation/run/PaceFormat.kt
new file mode 100644
index 000000000..da64769a5
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/PaceFormat.kt
@@ -0,0 +1,30 @@
+package com.runnect.runnect.presentation.run
+
+import kotlin.math.roundToInt
+
+object PaceFormat {
+ /** 초/km 페이스를 5'48" 형식으로 표시. 값이 없거나 비정상이면 "-". */
+ fun format(paceSecPerKm: Double?): String {
+ if (paceSecPerKm == null || paceSecPerKm.isNaN() || paceSecPerKm.isInfinite()) return "-"
+ val totalSec = paceSecPerKm.roundToInt()
+ return "%d'%02d\"".format(totalSec / 60, totalSec % 60)
+ }
+
+ /**
+ * 서버 기록의 소요 시간 문자열("HH:MM:SS" 또는 "MM:SS")을 초로 변환. 형식이 맞지 않으면 null.
+ * 서버의 pace 문자열은 EndRunActivity가 분 단위 소수를 초처럼 이어 붙여 저장해 신뢰할 수 없으므로,
+ * 페이스가 필요하면 이 값과 코스 거리로 직접 계산한다.
+ */
+ fun parseDurationSec(time: String?): Int? {
+ val parts = time?.trim()?.split(":")?.takeIf { it.size in 2..3 } ?: return null
+ val numbers = parts.map { it.toIntOrNull()?.takeIf { n -> n >= 0 } ?: return null }
+ return numbers.fold(0) { acc, n -> acc * 60 + n }
+ }
+
+ /** 소요 시간과 거리로 초/km 페이스 계산. 계산 불가하면 null. */
+ fun paceFromDuration(time: String?, distanceKm: Double?): Double? {
+ val durationSec = parseDurationSec(time) ?: return null
+ if (distanceKm == null || distanceKm <= 0.0 || durationSec <= 0) return null
+ return durationSec / distanceKm
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt
index ffe0b5981..d89dbb9c4 100644
--- a/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt
@@ -1,11 +1,9 @@
package com.runnect.runnect.presentation.run
import kotlin.math.roundToInt
-import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
-import android.content.IntentFilter
import android.content.ServiceConnection
import android.graphics.Color
import android.graphics.PointF
@@ -15,8 +13,12 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.ViewCompositionStrategy
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
@@ -36,11 +38,8 @@ import com.runnect.runnect.R
import com.runnect.runnect.binding.BindingActivity
import com.runnect.runnect.data.dto.CourseData
import com.runnect.runnect.data.dto.RunToEndRunData
-import com.runnect.runnect.data.dto.TimerData
import com.runnect.runnect.databinding.ActivityRunBinding
import com.runnect.runnect.presentation.endrun.EndRunActivity
-import com.runnect.runnect.presentation.run.TimerService.Companion.EXTRA_TIMER_VALUE
-import com.runnect.runnect.presentation.run.TimerService.Companion.TIMER_UPDATE_ACTION
import com.runnect.runnect.presentation.ui.theme.RunnectTheme
import com.runnect.runnect.util.analytics.Analytics
import com.runnect.runnect.util.analytics.EventName
@@ -71,7 +70,6 @@ class RunActivity : BindingActivity(R.layout.activity_run),
private val viewModel: RunViewModel by viewModels()
- lateinit var timerData: TimerData
private var timerService: TimerService? = null
private var isServiceBound = false
lateinit var serviceIntent: Intent
@@ -80,8 +78,9 @@ class RunActivity : BindingActivity(R.layout.activity_run),
//서비스가 연결되었을 때 호출
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as TimerService.LocalBinder
- timerService = binder.getService()
+ timerService = binder.getService().also { it.setUiVisible(isUiStarted) }
isServiceBound = true
+ observeRunState()
}
//서비스 연결이 끊어졌을 때 호출
@@ -97,14 +96,16 @@ class RunActivity : BindingActivity(R.layout.activity_run),
binding.lifecycleOwner = this
initView()
- initDistanceComposeView()
- initPaceComposeView()
+ initStatsComposeView()
+ initAlertComposeView()
initTimerService()
getCurrentLocation()
showRecord()
backButton()
setUpPauseResume()
+ // 러닝 시작 이벤트는 처음 한 번만 — 화면 회전 등으로 재생성될 때는 다시 기록하지 않는다.
+ if (savedInstanceState != null) return
val runCourseData: CourseData? = intent.getParcelableExtra(EXTRA_COUNTDOWN_TO_RUN)
val targetDistanceM = runCourseData?.distance?.let { (it * 1000f).roundToInt() }
Analytics.logEvent(
@@ -129,53 +130,64 @@ class RunActivity : BindingActivity(R.layout.activity_run),
)
}
- private fun initDistanceComposeView() {
- binding.composeRunDistance.apply {
+ private fun initStatsComposeView() {
+ binding.composeRunStats.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
RunnectTheme {
- val distanceKm by viewModel.traveledDistanceKm.observeAsState(0.0)
- RunDistanceStat(distanceKm = distanceKm)
+ val state by viewModel.trackingState.collectAsStateWithLifecycle()
+ RunStatsPanel(state = state)
}
}
}
}
- private fun initPaceComposeView() {
- binding.composeRunPace.apply {
+ private fun initAlertComposeView() {
+ // 진동은 러닝 서비스가 울린다(백그라운드에서도 울려야 하므로). 화면은 배너만 그린다.
+ binding.composeRunAlert.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
RunnectTheme {
- val paceSecPerKm by viewModel.currentPaceSecPerKm.observeAsState(null)
- RunPaceStat(paceSecPerKm = paceSecPerKm)
+ val state by viewModel.trackingState.collectAsStateWithLifecycle()
+ RunAlertBanner(alert = state.alert)
}
}
}
}
private fun initTimerService() {
- serviceIntent = Intent(this, TimerService::class.java)
+ // 서비스는 재생성 시 다시 start돼도 기존 타이머를 유지한다(TimerService.onStartCommand). 알림에서 러닝 화면을
+ // 다시 열 때 필요한 코스 정보를 넘기기 위해 이 화면의 extras를 함께 전달한다.
+ serviceIntent = Intent(this, TimerService::class.java).putExtras(intent)
startService(serviceIntent)
bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)
}
+ /** 러닝 서비스의 상태를 화면에 반영한다. 화면이 재생성돼도 다시 바인딩하면 같은 상태를 이어받는다. */
+ private fun observeRunState() {
+ val service = timerService ?: return
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ service.state.collect { state ->
+ viewModel.render(state)
+ updatePauseResumeUI(state.isPaused)
+ }
+ }
+ }
+ }
+
private fun stopTimer() {
- timerService?.stopTimer()
+ timerService?.stopRun()
stopService(serviceIntent) //서비스 객체 제거
}
private fun setUpPauseResume() {
- binding.btnRunPauseResume.setOnClickListener {
- val isPaused = viewModel.isPaused.value ?: false
- if (isPaused) {
- timerService?.resumeTimer()
- viewModel.onManualResume()
- } else {
- timerService?.pauseTimer()
- }
- viewModel.isPaused.value = !isPaused
- updatePauseResumeUI(!isPaused)
- }
+ binding.btnRunPauseResume.setOnClickListener { togglePauseResume() }
+ }
+
+ private fun togglePauseResume() {
+ val service = timerService ?: return
+ if (service.state.value.isPaused) service.resume() else service.pause()
}
private fun updatePauseResumeUI(isPaused: Boolean) {
@@ -189,39 +201,19 @@ class RunActivity : BindingActivity(R.layout.activity_run),
binding.tvPausedLabel.visibility = if (isPaused) View.VISIBLE else View.GONE
}
+ // 러닝 화면이 보이는 동안엔 알림을 화면 배너로, 안 보이면(백그라운드/화면 꺼짐) 상단 알림으로 띄우도록 서비스에 알린다.
+ private var isUiStarted = false
+
override fun onStart() {
super.onStart()
- // Timer 결과값을 받기 위해 브로드캐스트 리시버 등록
- registerReceiver(timerReceiver, IntentFilter(TIMER_UPDATE_ACTION), RECEIVER_NOT_EXPORTED)
+ isUiStarted = true
+ timerService?.setUiVisible(true)
}
override fun onStop() {
super.onStop()
- // 브로드캐스트 리시버 등록 해제
- unregisterReceiver(timerReceiver)
- }
-
- private val timerReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) { //앱 나가니까 반영 안 되고 다시 들어오면 반영돼있음.
- timerData = intent.getParcelableExtra(EXTRA_TIMER_VALUE)!!
- val timerUI = String.format(
- "%02d:%02d:%02d",
- timerData.hour,
- timerData.minute,
- timerData.second
- )
- updateTimerUI(timerUI)
-
- if (viewModel.shouldAutoPause()) {
- timerService?.pauseTimer()
- viewModel.isPaused.value = true
- updatePauseResumeUI(true)
- }
- }
- }
-
- private fun updateTimerUI(timerValue: String) {
- binding.tvTimer.text = timerValue
+ isUiStarted = false
+ timerService?.setUiVisible(false)
}
override fun onDestroy() {
@@ -229,7 +221,8 @@ class RunActivity : BindingActivity(R.layout.activity_run),
if (isServiceBound) {
unbindService(connection)
}
- stopTimer()
+ // 화면 회전 등으로 재생성되는 경우엔 타이머 서비스를 살려둬야 기록이 이어진다. 러닝을 끝내고 나갈 때만 종료한다.
+ if (isFinishing) stopTimer()
}
private fun backButton() {
@@ -276,7 +269,6 @@ class RunActivity : BindingActivity(R.layout.activity_run),
private fun addCurrentLocationChangeListener(map: NaverMap) {
naverMap.addOnLocationChangeListener { location ->
currentLocation = LatLng(location.latitude, location.longitude)
- viewModel.onLocationUpdated(currentLocation)
map.locationOverlay.run { //현재 위치 마커
isVisible = true //현재 위치 마커 가시성(default = false)
position = LatLng(currentLocation.latitude, currentLocation.longitude)
@@ -334,6 +326,9 @@ class RunActivity : BindingActivity(R.layout.activity_run),
distanceSum = courseData.distance?.toDouble()?.round(1) ?: 0.0
viewModel.distanceSum.value = distanceSum
+ // 이탈 판정 경로와 목표 페이스는 러닝 서비스가 같은 extras에서 읽는다(TimerService.onStartCommand).
+ // debug 빌드에서만 GPS 시뮬레이터 패널을 띄운다.
+ RunDebugTools.attach(this, listOf(startLatLng) + touchList) { timerService }
}
private fun createDepartureMarker() {
@@ -400,8 +395,9 @@ class RunActivity : BindingActivity(R.layout.activity_run),
private fun showRecord() {
binding.btnRunStop.setOnClickListener {
+ val elapsedSec = timerService?.state?.value?.elapsedSec ?: 0
stopTimer()
- val totalTimeSec = ((timerData.hour ?: 0) * 3600) + ((timerData.minute ?: 0) * 60) + (timerData.second ?: 0)
+ val totalTimeSec = elapsedSec
Analytics.logEvent(
EventName.ACTION_RUN_COMPLETE,
Param.COURSE_ID to courseId,
@@ -417,9 +413,9 @@ class RunActivity : BindingActivity(R.layout.activity_run),
totalDistance = distanceSum,
captureUri = captureUri,
departure = departure,
- timerHour = timerData.hour,
- timerMinute = timerData.minute,
- timerSecond = timerData.second,
+ timerHour = elapsedSec / 3600,
+ timerMinute = (elapsedSec % 3600) / 60,
+ timerSecond = elapsedSec % 60,
dataFrom = dataFrom
)
)
@@ -434,5 +430,6 @@ class RunActivity : BindingActivity(R.layout.activity_run),
private const val LOCATION_PERMISSION_REQUEST_CODE = 1000
const val EXTRA_RUN_TO_ENDRUN = "RunToEndRunData"
const val EXTRA_COUNTDOWN_TO_RUN = "CountToRunData"
+ const val EXTRA_TARGET_PACE_SEC_PER_KM = "TargetPaceSecPerKm"
}
}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertBanner.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertBanner.kt
new file mode 100644
index 000000000..57e18e279
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertBanner.kt
@@ -0,0 +1,107 @@
+package com.runnect.runnect.presentation.run
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.runnect.runnect.R
+import com.runnect.runnect.presentation.ui.theme.RunnectTheme
+import com.runnect.runnect.presentation.ui.theme.White
+
+object RunAlertBannerTestTags {
+ const val BANNER = "run_alert_banner"
+}
+
+private val WarnYellow = Color(0xFFFFC617)
+private val WarnInk = Color(0xFF4A3800)
+private val WarnInkSoft = Color(0xFF6B5300)
+private val DangerRed = Color(0xFFFF473A)
+
+/** 러닝 화면 상단 바 아래에 뜨는 코스 이탈 / 페이스 저하 알림. alert가 null이면 사라진다. */
+@Composable
+fun RunAlertBanner(alert: RunAlert?, modifier: Modifier = Modifier) {
+ // 사라지는 애니메이션 동안 마지막 내용을 유지하기 위해 null이 아닌 마지막 값을 기억한다.
+ val lastAlert = remember { LastValueHolder() }
+ alert?.let { lastAlert.value = it }
+
+ AnimatedVisibility(
+ visible = alert != null,
+ modifier = modifier,
+ enter = slideInVertically { -it / 2 } + fadeIn(),
+ exit = slideOutVertically { -it / 2 } + fadeOut(),
+ ) {
+ lastAlert.value?.let { RunAlertContent(it) }
+ }
+}
+
+@Composable
+private fun RunAlertContent(alert: RunAlert) {
+ val (background, titleColor, descColor) = when (alert) {
+ is RunAlert.OffRoute -> Triple(DangerRed, White, White.copy(alpha = 0.85f))
+ is RunAlert.PaceDrop -> Triple(WarnYellow, WarnInk, WarnInkSoft)
+ }
+ val title = when (alert) {
+ is RunAlert.OffRoute -> stringResource(R.string.run_alert_off_route_title)
+ is RunAlert.PaceDrop -> stringResource(R.string.run_alert_pace_drop_title)
+ }
+ val description = when (alert) {
+ is RunAlert.OffRoute -> stringResource(R.string.run_alert_off_route_desc, alert.distanceM)
+ is RunAlert.PaceDrop -> stringResource(
+ R.string.run_alert_pace_drop_desc,
+ PaceFormat.format(alert.targetSecPerKm),
+ PaceFormat.format(alert.currentSecPerKm),
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ .background(background, RoundedCornerShape(14.dp))
+ .padding(horizontal = 14.dp, vertical = 11.dp)
+ .semantics { liveRegion = LiveRegionMode.Polite }
+ .testTag(RunAlertBannerTestTags.BANNER),
+ ) {
+ Text(text = title, style = RunnectTheme.textStyle.semiBold15, color = titleColor)
+ Text(
+ modifier = Modifier.padding(top = 2.dp),
+ text = description,
+ style = RunnectTheme.textStyle.medium12,
+ color = descColor,
+ )
+ }
+}
+
+private class LastValueHolder {
+ var value: T? = null
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun RunAlertBannerOffRoutePreview() {
+ RunnectTheme { RunAlertBanner(alert = RunAlert.OffRoute(distanceM = 45)) }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun RunAlertBannerPaceDropPreview() {
+ RunnectTheme { RunAlertBanner(alert = RunAlert.PaceDrop(targetSecPerKm = 330.0, currentSecPerKm = 412.0)) }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertHaptics.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertHaptics.kt
new file mode 100644
index 000000000..47b337e76
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertHaptics.kt
@@ -0,0 +1,48 @@
+package com.runnect.runnect.presentation.run
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+
+/**
+ * 러닝 중 알림이 새로 뜰 때 진동으로도 알린다. 화면을 보지 않고 달리는 상황이 대부분이라 배너만으로는 놓치기 쉽다.
+ * 같은 알림이 유지되는 동안(거리/페이스 값만 갱신)에는 다시 울리지 않는다.
+ */
+class RunAlertHaptics(context: Context, initialAlert: RunAlert?) {
+ private val vibrator: Vibrator? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ context.getSystemService(VibratorManager::class.java)?.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ }
+
+ // 화면 재생성 직후 이미 떠 있던 알림으로 다시 울리지 않도록 현재 알림에서 시작한다.
+ private var previous: RunAlert? = initialAlert
+
+ fun onAlertChanged(alert: RunAlert?) {
+ val pattern = patternFor(previous, alert)
+ previous = alert
+ if (pattern == null || vibrator?.hasVibrator() != true) return
+ vibrator.vibrate(VibrationEffect.createWaveform(pattern, NO_REPEAT))
+ }
+
+ companion object {
+ private const val NO_REPEAT = -1
+
+ // 코스 이탈은 바로 행동(복귀)이 필요해 두 번, 페이스 저하는 한 번 길게 울려 손목/주머니에서도 구분되게 한다.
+ val OFF_ROUTE_PATTERN = longArrayOf(0, 300, 150, 300)
+ val PACE_DROP_PATTERN = longArrayOf(0, 500)
+
+ /** 알림이 없던 상태에서 새로 떴거나 다른 종류로 바뀌었을 때만 진동 패턴을 반환한다. */
+ fun patternFor(previous: RunAlert?, current: RunAlert?): LongArray? {
+ if (current == null || !current.isNewComparedTo(previous)) return null
+ return when (current) {
+ is RunAlert.OffRoute -> OFF_ROUTE_PATTERN
+ is RunAlert.PaceDrop -> PACE_DROP_PATTERN
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertNotifier.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertNotifier.kt
new file mode 100644
index 000000000..87673cce5
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunAlertNotifier.kt
@@ -0,0 +1,74 @@
+package com.runnect.runnect.presentation.run
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import com.runnect.runnect.R
+
+/**
+ * 러닝 화면을 보고 있지 않을 때(백그라운드, 화면 꺼짐) 코스 이탈/페이스 저하 알림을 상단(헤드업) 알림으로 띄운다.
+ * 진동은 RunAlertHaptics가 따로 울리므로 채널 자체 진동/소리는 끈다(패턴이 두 번 겹치지 않게).
+ */
+object RunAlertNotifier {
+ private const val CHANNEL_ID = "run_alert"
+ private const val NOTI_ID = 2
+
+ // 마지막으로 게시한 알림. 같은 종류의 값만 바뀌면 조용히 내용만 갱신하고, 새 알림일 때만 다시 알린다.
+ private var shown: RunAlert? = null
+
+ fun update(context: Context, alert: RunAlert?, isUiVisible: Boolean, contentIntent: PendingIntent) {
+ val manager = context.getSystemService(NotificationManager::class.java)
+ if (alert == null || isUiVisible) {
+ cancel(context)
+ return
+ }
+ // 새 알림이면 기존 알림을 지우고 다시 게시해야 헤드업/알림음이 다시 뜬다(setOnlyAlertOnce는 떠 있는 알림의 갱신을 조용히 만든다).
+ if (alert.isNewComparedTo(shown)) manager.cancel(NOTI_ID)
+ shown = alert
+ ensureChannel(context, manager)
+ val (title, text) = when (alert) {
+ is RunAlert.OffRoute -> context.getString(R.string.run_alert_off_route_title) to
+ context.getString(R.string.run_alert_off_route_desc, alert.distanceM)
+ is RunAlert.PaceDrop -> context.getString(R.string.run_alert_pace_drop_title) to
+ context.getString(
+ R.string.run_alert_pace_drop_desc,
+ PaceFormat.format(alert.targetSecPerKm),
+ PaceFormat.format(alert.currentSecPerKm),
+ )
+ }
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification_run)
+ .setColor(ContextCompat.getColor(context, R.color.M1))
+ .setContentTitle(title)
+ .setContentText(text)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_STATUS)
+ .setOnlyAlertOnce(true)
+ .setAutoCancel(true)
+ .setContentIntent(contentIntent)
+ .build()
+ manager.notify(NOTI_ID, notification)
+ }
+
+ fun cancel(context: Context) {
+ shown = null
+ context.getSystemService(NotificationManager::class.java).cancel(NOTI_ID)
+ }
+
+ private fun ensureChannel(context: Context, manager: NotificationManager) {
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
+ manager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.run_alert_channel_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ enableVibration(false)
+ setSound(null, null)
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunController.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunController.kt
new file mode 100644
index 000000000..9fb938efb
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunController.kt
@@ -0,0 +1,8 @@
+package com.runnect.runnect.presentation.run
+
+/** 러닝 진행을 조작하는 창구. 러닝 서비스(TimerService)가 구현하고, 화면과 debug 도구가 이걸로 조작한다. */
+interface RunController {
+ fun pause()
+ fun resume()
+ fun setTargetPace(paceSecPerKm: Double?)
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunDistanceStat.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunDistanceStat.kt
deleted file mode 100644
index 8ead7ce64..000000000
--- a/app/src/main/java/com/runnect/runnect/presentation/run/RunDistanceStat.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.runnect.runnect.presentation.run
-
-import androidx.compose.foundation.layout.Row
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.sp
-import com.runnect.runnect.presentation.ui.theme.G1
-import com.runnect.runnect.presentation.ui.theme.G2
-import com.runnect.runnect.presentation.ui.theme.PretendardFontFamily
-import com.runnect.runnect.presentation.ui.theme.RunnectTheme
-
-/**
- * 러닝 화면 상단 정보 바의 실시간 이동 거리 표시.
- * 기존 tv_total_distance_content(값) + tv_total_distance_unit(단위) 두 XML TextView를
- * 대체한다 — 아이콘/라벨("거리")은 위치 고정값이라 XML에 그대로 둔다.
- */
-@Composable
-fun RunDistanceStat(distanceKm: Double, modifier: Modifier = Modifier) {
- Row(modifier = modifier, verticalAlignment = Alignment.Bottom) {
- Text(
- text = distanceKm.toString(),
- fontFamily = PretendardFontFamily,
- fontWeight = FontWeight.Bold,
- fontSize = 24.sp,
- color = G1,
- )
- Text(
- text = "km",
- fontFamily = PretendardFontFamily,
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- color = G2,
- )
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun RunDistanceStatPreview() {
- RunnectTheme {
- RunDistanceStat(distanceKm = 2.3)
- }
-}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunGuardrail.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunGuardrail.kt
new file mode 100644
index 000000000..92cacd7f6
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunGuardrail.kt
@@ -0,0 +1,132 @@
+package com.runnect.runnect.presentation.run
+
+import com.naver.maps.geometry.LatLng
+import kotlin.math.cos
+import kotlin.math.sqrt
+
+/** 러닝 중 화면 상단에 띄우는 알림. 동시에 하나만 노출하며, 코스 이탈이 페이스 저하보다 우선한다. */
+sealed interface RunAlert {
+ data class OffRoute(val distanceM: Int) : RunAlert
+ data class PaceDrop(val targetSecPerKm: Double, val currentSecPerKm: Double) : RunAlert
+
+ /**
+ * 사용자에게 다시 알려야 하는 "새 알림"인지. 알림이 없다가 생겼거나 종류가 바뀌었을 때만 true이고,
+ * 같은 종류에서 값(이탈 거리, 현재 페이스)만 갱신된 경우는 false — 진동/상단 알림이 매초 반복되지 않게 한다.
+ */
+ fun isNewComparedTo(previous: RunAlert?): Boolean = previous == null || previous::class != this::class
+}
+
+object RouteGeometry {
+ private const val EARTH_RADIUS_M = 6_371_000.0
+
+ /**
+ * 현재 좌표에서 경로(선분 목록)까지의 최단 거리(m).
+ * 러닝 코스 규모(수 km)에서는 현재 좌표 기준 등장방형 투영으로 평면 근사해도 오차가 무시할 수준이다.
+ */
+ fun distanceToPathM(point: LatLng, path: List): Double? {
+ if (path.isEmpty()) return null
+ if (path.size == 1) return point.distanceTo(path.first())
+
+ val metersPerDegLat = Math.toRadians(1.0) * EARTH_RADIUS_M
+ val metersPerDegLng = metersPerDegLat * cos(Math.toRadians(point.latitude))
+ fun project(p: LatLng) = Pair(
+ (p.longitude - point.longitude) * metersPerDegLng,
+ (p.latitude - point.latitude) * metersPerDegLat,
+ )
+
+ return path.zipWithNext().minOf { (a, b) ->
+ val (ax, ay) = project(a)
+ val (bx, by) = project(b)
+ distanceFromOriginToSegment(ax, ay, bx, by)
+ }
+ }
+
+ private fun distanceFromOriginToSegment(ax: Double, ay: Double, bx: Double, by: Double): Double {
+ val dx = bx - ax
+ val dy = by - ay
+ val lengthSq = dx * dx + dy * dy
+ val t = if (lengthSq == 0.0) 0.0 else (-(ax * dx + ay * dy) / lengthSq).coerceIn(0.0, 1.0)
+ val x = ax + t * dx
+ val y = ay + t * dy
+ return sqrt(x * x + y * y)
+ }
+}
+
+/**
+ * 코스 이탈 / 페이스 저하 알림 판정. GPS 순간 튐으로 인한 오탐을 막기 위해 조건이 일정 시간 "연속" 유지될 때만 알림을 띄우고,
+ * 조건이 해소되면 바로 내린다. 같은 종류의 알림은 마지막으로 띄운 시점부터 쿨다운 동안 다시 띄우지 않는다.
+ */
+class RunAlertEvaluator {
+ private var offRouteSinceMillis: Long? = null
+ private var paceDropSinceMillis: Long? = null
+ private var lastOffRouteShownAtMillis: Long? = null
+ private var lastPaceDropShownAtMillis: Long? = null
+ private var current: RunAlert? = null
+
+ fun evaluate(
+ now: Long,
+ distanceToRouteM: Double?,
+ paceSecPerKm: Double?,
+ targetPaceSecPerKm: Double?,
+ ): RunAlert? {
+ val offRoute = evaluateOffRoute(now, distanceToRouteM)
+ // 연속 판정 타이머는 항상 갱신하되, 이탈 알림이 떠 있으면 페이스 알림은 띄우지 않는다(쿨다운도 소모하지 않음).
+ val paceDropCandidate = updatePaceDropTimer(now, paceSecPerKm, targetPaceSecPerKm)
+ current = offRoute ?: paceDropCandidate?.let { showPaceDrop(now, it) }
+ return current
+ }
+
+ /** 일시정지 시 호출 — 진행 중이던 연속 판정과 떠 있던 알림을 초기화한다. 쿨다운 기록은 유지한다. */
+ fun reset() {
+ offRouteSinceMillis = null
+ paceDropSinceMillis = null
+ current = null
+ }
+
+ private fun evaluateOffRoute(now: Long, distanceToRouteM: Double?): RunAlert.OffRoute? {
+ if (distanceToRouteM == null || distanceToRouteM <= OFF_ROUTE_THRESHOLD_M) {
+ offRouteSinceMillis = null
+ return null
+ }
+ val since = offRouteSinceMillis ?: now.also { offRouteSinceMillis = it }
+ if (now - since < OFF_ROUTE_DURATION_MILLIS) return null
+
+ val alert = RunAlert.OffRoute(distanceToRouteM.toInt())
+ if (current is RunAlert.OffRoute) return alert
+ if (isCoolingDown(now, lastOffRouteShownAtMillis)) return null
+ lastOffRouteShownAtMillis = now
+ return alert
+ }
+
+ /** 페이스 저하가 임계 시간 이상 연속됐으면 띄울 알림 후보를, 아니면 null을 반환한다. */
+ private fun updatePaceDropTimer(now: Long, paceSecPerKm: Double?, targetPaceSecPerKm: Double?): RunAlert.PaceDrop? {
+ if (paceSecPerKm == null || targetPaceSecPerKm == null ||
+ paceSecPerKm < targetPaceSecPerKm * PACE_DROP_RATIO
+ ) {
+ paceDropSinceMillis = null
+ return null
+ }
+ val since = paceDropSinceMillis ?: now.also { paceDropSinceMillis = it }
+ if (now - since < PACE_DROP_DURATION_MILLIS) return null
+ return RunAlert.PaceDrop(targetPaceSecPerKm, paceSecPerKm)
+ }
+
+ private fun showPaceDrop(now: Long, alert: RunAlert.PaceDrop): RunAlert.PaceDrop? {
+ if (current is RunAlert.PaceDrop) return alert
+ if (isCoolingDown(now, lastPaceDropShownAtMillis)) return null
+ lastPaceDropShownAtMillis = now
+ return alert
+ }
+
+ private fun isCoolingDown(now: Long, lastShownAt: Long?): Boolean =
+ lastShownAt != null && now - lastShownAt < ALERT_COOLDOWN_MILLIS
+
+ companion object {
+ // 도심 GPS 오차가 보통 5~15m라 여유를 둔 초기값. 실측 후 조정 대상.
+ const val OFF_ROUTE_THRESHOLD_M = 30.0
+ const val OFF_ROUTE_DURATION_MILLIS = 8_000L
+ const val PACE_DROP_RATIO = 1.2 // 목표보다 20% 이상 느림 (페이스는 값이 클수록 느림)
+ const val PACE_DROP_DURATION_MILLIS = 10_000L
+ const val ALERT_COOLDOWN_MILLIS = 60_000L
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunPaceStat.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunPaceStat.kt
deleted file mode 100644
index a29b9b032..000000000
--- a/app/src/main/java/com/runnect/runnect/presentation/run/RunPaceStat.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package com.runnect.runnect.presentation.run
-
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.runnect.runnect.presentation.ui.theme.G1
-import com.runnect.runnect.presentation.ui.theme.G2
-import com.runnect.runnect.presentation.ui.theme.PretendardFontFamily
-import com.runnect.runnect.presentation.ui.theme.RunnectTheme
-import kotlin.math.roundToInt
-
-/**
- * 러닝 화면 상단 정보 바의 실시간 페이스(최근 15초 구간 기준) 표시.
- * 아직 측정할 만큼 움직이지 않았으면(null) "-"로 표시해 레이아웃이 흔들리지 않게 한다.
- */
-@Composable
-fun RunPaceStat(paceSecPerKm: Double?, modifier: Modifier = Modifier) {
- Row(modifier = modifier, verticalAlignment = Alignment.Bottom) {
- Text(
- text = "페이스",
- fontFamily = PretendardFontFamily,
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- color = G2,
- )
- Text(
- modifier = Modifier.padding(start = 8.dp),
- text = formatPaceSecPerKm(paceSecPerKm),
- fontFamily = PretendardFontFamily,
- fontWeight = FontWeight.Bold,
- fontSize = 18.sp,
- color = G1,
- )
- if (paceSecPerKm != null) {
- Text(
- modifier = Modifier.padding(start = 2.dp),
- text = "/km",
- fontFamily = PretendardFontFamily,
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- color = G2,
- )
- }
- }
-}
-
-private fun formatPaceSecPerKm(paceSecPerKm: Double?): String {
- if (paceSecPerKm == null || paceSecPerKm.isNaN() || paceSecPerKm.isInfinite()) return "-"
- val totalSec = paceSecPerKm.roundToInt()
- val min = totalSec / 60
- val sec = totalSec % 60
- return "%d'%02d\"".format(min, sec)
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun RunPaceStatPreview() {
- RunnectTheme {
- RunPaceStat(paceSecPerKm = 330.0)
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun RunPaceStatEmptyPreview() {
- RunnectTheme {
- RunPaceStat(paceSecPerKm = null)
- }
-}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunStatsPanel.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunStatsPanel.kt
new file mode 100644
index 000000000..3a6f93bee
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunStatsPanel.kt
@@ -0,0 +1,149 @@
+package com.runnect.runnect.presentation.run
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.runnect.runnect.R
+import com.runnect.runnect.presentation.ui.theme.G1
+import com.runnect.runnect.presentation.ui.theme.G2
+import com.runnect.runnect.presentation.ui.theme.G3
+import com.runnect.runnect.presentation.ui.theme.M1
+import com.runnect.runnect.presentation.ui.theme.PretendardFontFamily
+import com.runnect.runnect.presentation.ui.theme.RunnectTheme
+
+object RunStatsPanelTestTags {
+ const val DISTANCE = "run_stats_distance"
+ const val TIME = "run_stats_time"
+ const val PACE = "run_stats_pace"
+ const val TARGET = "run_stats_target"
+}
+
+/**
+ * 러닝 화면 상단의 거리 / 시간 / 페이스 3칸. 세 칸 모두 같은 크기의 라벨-값-단위 구조로 맞춰 한눈에 읽히게 하고,
+ * 칸은 내용 폭만큼만 차지한 채 칸 사이 간격을 똑같이 나눈다(SpaceBetween) — 칸 폭을 비율로 나누면 내용 길이가 달라
+ * 보이는 간격이 제각각이 된다. 숫자는 고정폭(tnum)이라 값이 바뀌어도 간격이 흔들리지 않는다.
+ * 목표 페이스는 페이스 칸 아래 고정 자리에 둬서(목표가 없으면 "목표 없음") 값이 바뀌어도 배치가 흔들리지 않게 한다.
+ */
+@Composable
+fun RunStatsPanel(state: RunTrackingState, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ StatColumn(
+ label = stringResource(R.string.run_distance_title),
+ value = "%.1f".format(state.distanceKm),
+ unit = stringResource(R.string.run_distance_unit),
+ valueTag = RunStatsPanelTestTags.DISTANCE,
+ )
+ StatColumn(
+ label = stringResource(R.string.run_time_title),
+ value = TimerService.formatElapsed(state.elapsedSec),
+ unit = null,
+ valueTag = RunStatsPanelTestTags.TIME,
+ )
+ StatColumn(
+ label = stringResource(R.string.run_pace_title),
+ value = PaceFormat.format(state.paceSecPerKm),
+ unit = state.paceSecPerKm?.let { stringResource(R.string.run_pace_unit) },
+ valueTag = RunStatsPanelTestTags.PACE,
+ ) {
+ val target = state.targetPaceSecPerKm
+ Text(
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .testTag(RunStatsPanelTestTags.TARGET),
+ text = if (target != null) {
+ stringResource(R.string.run_pace_target, PaceFormat.format(target))
+ } else {
+ stringResource(R.string.run_pace_target_none)
+ },
+ fontFamily = PretendardFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ color = if (target != null) M1 else G3,
+ )
+ }
+ }
+}
+
+@Composable
+private fun StatColumn(
+ label: String,
+ value: String,
+ unit: String?,
+ valueTag: String,
+ modifier: Modifier = Modifier,
+ footer: @Composable () -> Unit = {},
+) {
+ Column(modifier = modifier) {
+ Text(
+ text = label,
+ fontFamily = PretendardFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 13.sp,
+ color = G2,
+ )
+ Row(modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.Bottom) {
+ Text(
+ modifier = Modifier.alignByBaseline().testTag(valueTag),
+ text = value,
+ fontFamily = PretendardFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp,
+ style = TextStyle(fontFeatureSettings = "tnum"),
+ color = G1,
+ maxLines = 1,
+ overflow = TextOverflow.Visible,
+ )
+ if (unit != null) {
+ Text(
+ modifier = Modifier
+ .alignByBaseline()
+ .padding(start = 2.dp),
+ text = unit,
+ fontFamily = PretendardFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 13.sp,
+ color = G2,
+ maxLines = 1,
+ )
+ }
+ }
+ footer()
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360)
+@Composable
+private fun RunStatsPanelPreview() {
+ RunnectTheme {
+ RunStatsPanel(
+ RunTrackingState(elapsedSec = 41, distanceM = 100.0, paceSecPerKm = 334.0, targetPaceSecPerKm = 330.0)
+ )
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360)
+@Composable
+private fun RunStatsPanelNoTargetPreview() {
+ RunnectTheme {
+ RunStatsPanel(RunTrackingState(elapsedSec = 13))
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunTracker.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunTracker.kt
new file mode 100644
index 000000000..2fa6aebad
--- /dev/null
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunTracker.kt
@@ -0,0 +1,171 @@
+package com.runnect.runnect.presentation.run
+
+import com.naver.maps.geometry.LatLng
+import com.runnect.runnect.util.extension.round
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/** 러닝 화면에 그리는 한 시점의 러닝 상태. */
+data class RunTrackingState(
+ val elapsedSec: Int = 0,
+ val distanceM: Double = 0.0, // 실시간 GPS 기반 이동 거리. 코스 목표 거리(고정값)와는 별개.
+ val paceSecPerKm: Double? = null, // 최근 PACE_WINDOW 구간 기준. 충분히 움직이지 않았으면 null.
+ val targetPaceSecPerKm: Double? = null,
+ val alert: RunAlert? = null,
+ val isPaused: Boolean = false,
+) {
+ /** 화면 표시용(소수 첫째 자리). */
+ val distanceKm: Double get() = (distanceM / 1000).round(1)
+}
+
+/**
+ * 러닝 한 번의 진행 상태(시간, 거리, 페이스, 자동 일시정지, 코스 이탈/페이스 저하 알림)를 계산한다.
+ * 화면이 꺼지거나 앱이 백그라운드로 가도 계속 돌아야 하므로 러닝 포그라운드 서비스(TimerService)가 소유하고,
+ * 화면은 state만 구독한다. Android 의존성이 없어 JVM 단위 테스트로 검증한다.
+ */
+class RunTracker {
+ private val _state = MutableStateFlow(RunTrackingState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private var traveledDistanceM = 0.0
+ private var lastLocation: LatLng? = null
+ private var consecutiveJumpCount = 0
+ private val recentSamples = ArrayDeque>()
+
+ private val alertEvaluator = RunAlertEvaluator()
+ private var routePath: List = emptyList()
+
+ private var lastMovementAtMillis: Long? = null
+ // 움직임 판정 기준점. 업데이트 한 번의 이동량(1Hz면 러닝 중에도 2~4m)이 아니라 이 기준점에서 벗어난 거리로 판정해야
+ // 천천히 달리는 중에 "안 움직임"으로 오판해 자동 일시정지되지 않는다.
+ private var movementAnchor: LatLng? = null
+
+ /** 코스 경로(출발점 + 경유 지점 순서)를 설정한다. 비어 있으면 이탈 판정을 하지 않는다. */
+ fun setRoute(path: List) {
+ routePath = path
+ }
+
+ /** 러닝 시작 전 고른 목표 페이스(초/km). null이면 페이스 저하 알림을 쓰지 않는다. */
+ fun setTargetPace(paceSecPerKm: Double?) {
+ _state.update { it.copy(targetPaceSecPerKm = paceSecPerKm?.takeIf { pace -> pace > 0.0 }) }
+ }
+
+ /**
+ * 위치가 갱신될 때마다 호출한다. 일시정지 중에는 거리/페이스를 누적하지 않되, lastLocation은
+ * 갱신해서 재개 시 일시정지 이전 위치와의 거리가 한 번에 더해지지 않게 한다.
+ * GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동(MAX_PLAUSIBLE_JUMP_M 초과)도 무시한다.
+ */
+ fun onLocationUpdated(newLocation: LatLng, now: Long = System.currentTimeMillis()) {
+ val previous = lastLocation
+
+ if (previous == null) {
+ lastLocation = newLocation
+ movementAnchor = newLocation
+ lastMovementAtMillis = now // 무활동 타이머 시작 기준점 (러닝 시작 시점)
+ return
+ }
+ if (_state.value.isPaused) {
+ lastLocation = newLocation
+ return
+ }
+
+ val deltaM = previous.distanceTo(newLocation)
+ // 튐으로 판정해 거부한 좌표는 lastLocation을 갱신하지 않는다 — 다음 정상 좌표와의
+ // 거리가 튄 좌표 기준으로 잘못 계산되는 것을 막기 위해.
+ // 단, 기준점 자체가 틀렸던 경우(첫 측위가 부정확했던 경우 등) 이후 정상 좌표가 전부 거부되므로,
+ // 튐이 연속으로 이어지면 기준점을 새 위치로 옮긴다(이 구간 거리는 누적하지 않음).
+ if (deltaM > MAX_PLAUSIBLE_JUMP_M) {
+ consecutiveJumpCount++
+ if (consecutiveJumpCount >= MAX_CONSECUTIVE_JUMPS) {
+ lastLocation = newLocation
+ movementAnchor = newLocation
+ recentSamples.clear() // 옮기기 전 위치가 페이스 구간에 섞이면 비현실적인 페이스(1km 3초 등)가 나온다
+ consecutiveJumpCount = 0
+ }
+ return
+ }
+ consecutiveJumpCount = 0
+ lastLocation = newLocation
+
+ val anchor = movementAnchor ?: newLocation.also { movementAnchor = it }
+ if (anchor.distanceTo(newLocation) >= MOVEMENT_THRESHOLD_M) {
+ lastMovementAtMillis = now
+ movementAnchor = newLocation
+ }
+
+ traveledDistanceM += deltaM
+ recordPaceSample(now, newLocation)
+ val pace = calculatePaceSecPerKm()
+ val alert = alertEvaluator.evaluate(
+ now = now,
+ distanceToRouteM = RouteGeometry.distanceToPathM(newLocation, routePath),
+ paceSecPerKm = pace,
+ targetPaceSecPerKm = _state.value.targetPaceSecPerKm,
+ )
+ _state.update {
+ it.copy(distanceM = traveledDistanceM, paceSecPerKm = pace, alert = alert)
+ }
+ }
+
+ /** 1초마다 호출. 달리는 중이면 시간을 올리고, 무활동이 이어졌으면 자동 일시정지한다. 자동 일시정지했으면 true. */
+ fun onSecondElapsed(now: Long): Boolean {
+ if (_state.value.isPaused) return false
+ _state.update { it.copy(elapsedSec = it.elapsedSec + 1) }
+ if (!shouldAutoPause(now)) return false
+ pause()
+ return true
+ }
+
+ fun shouldAutoPause(now: Long): Boolean {
+ if (_state.value.isPaused) return false
+ val lastMovement = lastMovementAtMillis ?: return false
+ return (now - lastMovement) >= INACTIVITY_TIMEOUT_MILLIS
+ }
+
+ /** 수동/자동 일시정지. 떠 있던 알림을 내리고 연속 판정을 처음부터 다시 시작하게 한다. */
+ fun pause() {
+ alertEvaluator.reset()
+ recentSamples.clear() // 재개 후 페이스에 정지 중 이동분이 섞이지 않게 구간을 새로 시작한다
+ _state.update { it.copy(isPaused = true, alert = null) } // 페이스 표시는 정지 직전 값을 그대로 둔다
+ }
+
+ /** 재개 — 무활동 타이머를 재개 시점 기준으로 초기화해서 재개 직후 바로 자동 일시정지되지 않게 한다. */
+ fun resume(now: Long) {
+ lastMovementAtMillis = now
+ movementAnchor = lastLocation // 일시정지 중 이동분이 재개 직후 움직임으로 잡히지 않게
+ _state.update { it.copy(isPaused = false) }
+ }
+
+ private fun recordPaceSample(now: Long, location: LatLng) {
+ recentSamples.addLast(now to location)
+ while (recentSamples.isNotEmpty() && now - recentSamples.first().first > PACE_WINDOW_MILLIS) {
+ recentSamples.removeFirst()
+ }
+ }
+
+ private fun calculatePaceSecPerKm(): Double? {
+ if (recentSamples.size < 2) return null
+
+ var windowDistanceM = 0.0
+ for (i in 1 until recentSamples.size) {
+ windowDistanceM += recentSamples[i - 1].second.distanceTo(recentSamples[i].second)
+ }
+ if (windowDistanceM < MIN_PACE_DISTANCE_M) return null
+
+ val windowDurationSec = (recentSamples.last().first - recentSamples.first().first) / 1000.0
+ if (windowDurationSec <= 0) return null
+
+ return (windowDurationSec / windowDistanceM) * 1000
+ }
+
+ companion object {
+ private const val MAX_PLAUSIBLE_JUMP_M = 50.0
+ private const val MAX_CONSECUTIVE_JUMPS = 3
+ private const val MOVEMENT_THRESHOLD_M = 10.0 // 제자리 GPS 흔들림(수 m)은 움직임으로 보지 않는다
+ private const val PACE_WINDOW_MILLIS = 15_000L
+ private const val MIN_PACE_DISTANCE_M = 5.0
+ const val INACTIVITY_TIMEOUT_MILLIS = 20_000L
+ }
+}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt
index c5c152f97..fd8a346b3 100644
--- a/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt
@@ -2,103 +2,26 @@ package com.runnect.runnect.presentation.run
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
-import com.naver.maps.geometry.LatLng
-import com.runnect.runnect.util.extension.round
-
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * 러닝 화면 UI 상태. 러닝 진행 상태의 원본은 러닝 서비스(TimerService)의 RunTracker이고,
+ * 화면은 서비스에 바인딩된 동안 그 상태를 이 ViewModel로 옮겨 그린다. 서비스가 계속 살아 있으므로
+ * 화면이 재생성(configuration change, 시스템 회수)돼도 다시 바인딩하면 같은 상태를 이어받는다.
+ */
class RunViewModel : ViewModel() {
var distanceSum = MutableLiveData(0.0)
val departure = MutableLiveData()
val dataFrom = MutableLiveData()
var courseId = MutableLiveData()
var publicCourseId = MutableLiveData()
- val isPaused = MutableLiveData(false)
-
- // 실시간 GPS 기반 이동 거리 (km). distanceSum(코스 목표 거리, 고정값)과는 별개.
- val traveledDistanceKm = MutableLiveData(0.0)
- private var traveledDistanceM = 0.0
- private var lastLocation: LatLng? = null
-
- // 최근 구간(PACE_WINDOW_MILLIS) 거리/시간 기반 실시간 페이스. 너무 적게 움직였으면 null(표시 안 함).
- val currentPaceSecPerKm = MutableLiveData(null)
- private val recentSamples = ArrayDeque>()
-
- // 무활동 자동 일시정지 트리거 여부를 RunActivity가 매초(타이머 브로드캐스트) 확인한다.
- private var lastMovementAtMillis: Long? = null
-
- /**
- * 위치가 갱신될 때마다 호출한다. 일시정지 중에는 거리/페이스를 누적하지 않되, lastLocation은
- * 갱신해서 재개 시 일시정지 이전 위치와의 거리가 한 번에 더해지지 않게 한다.
- * GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동(MAX_PLAUSIBLE_JUMP_M 초과)도 무시한다.
- */
- fun onLocationUpdated(newLocation: LatLng, now: Long = System.currentTimeMillis()) {
- val previous = lastLocation
-
- if (previous == null) {
- lastLocation = newLocation
- lastMovementAtMillis = now // 무활동 타이머 시작 기준점 (러닝 시작 시점)
- return
- }
- if (isPaused.value == true) {
- lastLocation = newLocation
- return
- }
-
- val deltaM = previous.distanceTo(newLocation)
- // 튐으로 판정해 거부한 좌표는 lastLocation을 갱신하지 않는다 — 다음 정상 좌표와의
- // 거리가 튄 좌표 기준으로 잘못 계산되는 것을 막기 위해.
- if (deltaM > MAX_PLAUSIBLE_JUMP_M) return
- lastLocation = newLocation
-
- if (deltaM >= MOVEMENT_THRESHOLD_M) {
- lastMovementAtMillis = now
- }
-
- traveledDistanceM += deltaM
- traveledDistanceKm.value = (traveledDistanceM / 1000).round(1)
-
- recordPaceSample(now, newLocation)
- }
- /** 수동으로 재개했을 때 호출 — 무활동 타이머를 재개 시점 기준으로 초기화해서 재개 직후 바로 자동 일시정지되지 않게 한다. */
- fun onManualResume(now: Long = System.currentTimeMillis()) {
- lastMovementAtMillis = now
- }
-
- /** 타이머 브로드캐스트(매초)에서 호출. 무활동 시간이 임계값을 넘었으면 true — RunActivity가 실제 일시정지를 수행한다. */
- fun shouldAutoPause(now: Long = System.currentTimeMillis()): Boolean {
- if (isPaused.value == true) return false
- val lastMovement = lastMovementAtMillis ?: return false
- return (now - lastMovement) >= INACTIVITY_TIMEOUT_MILLIS
- }
-
- private fun recordPaceSample(now: Long, location: LatLng) {
- recentSamples.addLast(now to location)
- while (recentSamples.isNotEmpty() && now - recentSamples.first().first > PACE_WINDOW_MILLIS) {
- recentSamples.removeFirst()
- }
- currentPaceSecPerKm.value = calculatePaceSecPerKm()
- }
-
- private fun calculatePaceSecPerKm(): Double? {
- if (recentSamples.size < 2) return null
-
- var windowDistanceM = 0.0
- for (i in 1 until recentSamples.size) {
- windowDistanceM += recentSamples[i - 1].second.distanceTo(recentSamples[i].second)
- }
- if (windowDistanceM < MIN_PACE_DISTANCE_M) return null
-
- val windowDurationSec = (recentSamples.last().first - recentSamples.first().first) / 1000.0
- if (windowDurationSec <= 0) return null
-
- return (windowDurationSec / windowDistanceM) * 1000
- }
+ private val _trackingState = MutableStateFlow(RunTrackingState())
+ val trackingState: StateFlow = _trackingState.asStateFlow()
- companion object {
- private const val MAX_PLAUSIBLE_JUMP_M = 50.0
- private const val MOVEMENT_THRESHOLD_M = 5.0
- private const val PACE_WINDOW_MILLIS = 15_000L
- private const val MIN_PACE_DISTANCE_M = 5.0
- private const val INACTIVITY_TIMEOUT_MILLIS = 60_000L
+ fun render(state: RunTrackingState) {
+ _trackingState.value = state
}
}
diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt b/app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt
index 275b8bb97..cef1bbb7d 100644
--- a/app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt
+++ b/app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt
@@ -1,5 +1,6 @@
package com.runnect.runnect.presentation.run
+import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
@@ -9,142 +10,196 @@ import android.content.Intent
import android.media.MediaPlayer
import android.os.Binder
import android.os.Build
+import android.os.Bundle
+import android.os.Handler
import android.os.IBinder
+import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import com.naver.maps.geometry.LatLng
import com.runnect.runnect.R
-import com.runnect.runnect.data.dto.TimerData
+import com.runnect.runnect.data.dto.CourseData
import com.runnect.runnect.util.ForegroundServiceUtil
import com.runnect.runnect.util.IntentUtil
-import timber.log.Timber
-import java.util.Timer
-import java.util.TimerTask
-
-class TimerService : Service() {
+import com.runnect.runnect.util.extension.getCompatibleParcelableExtra
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.launch
+
+/**
+ * 러닝 한 번의 진행을 책임지는 포그라운드 서비스. 화면이 꺼지거나 앱이 백그라운드로 가도 위치를 계속 받아
+ * 시간/거리/페이스/자동 일시정지/코스 이탈·페이스 저하 알림(진동 포함)을 처리한다. RunActivity는 바인딩해서 state를 그리기만 한다.
+ * RunActivity가 재생성(configuration change 등)돼도 서비스는 그대로이므로 기록이 이어진다.
+ */
+class TimerService : Service(), RunController {
private val binder = LocalBinder()
- private var timer: Timer? = null
- private var time = 0
+ private val tracker = RunTracker()
+ val state: StateFlow get() = tracker.state
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val fusedClient by lazy { LocationServices.getFusedLocationProviderClient(this) }
+ private lateinit var haptics: RunAlertHaptics
+
+ private var isStarted = false
+ private var isUiVisible = true
+ private var runActivityExtras: Bundle? = null
private var player: MediaPlayer? = null
private lateinit var notificationBuilder: NotificationCompat.Builder
- private val NOTI_ID = 1 // 알림 ID
inner class LocalBinder : Binder() {
fun getService(): TimerService = this@TimerService
}
- // 서비스 시작 시 호출
+ private val tick = object : Runnable {
+ override fun run() {
+ tracker.onSecondElapsed(System.currentTimeMillis())
+ updateOngoingNotification()
+ RunDebugTools.onServiceTick(tracker.state.value)
+ mainHandler.postDelayed(this, TICK_MILLIS)
+ }
+ }
+
+ private val locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ result.locations.forEach {
+ tracker.onLocationUpdated(LatLng(it.latitude, it.longitude), System.currentTimeMillis())
+ }
+ }
+ }
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // RunActivity가 재생성되면 startService가 다시 불린다 — 이미 달리는 중이면 아무것도 새로 시작하지 않는다.
+ if (isStarted) return START_NOT_STICKY
+ isStarted = true
+ runActivityExtras = intent?.extras
+
+ intent?.getCompatibleParcelableExtra(RunActivity.EXTRA_COUNTDOWN_TO_RUN)?.let {
+ tracker.setRoute(listOf(it.startLatLng) + it.touchList)
+ }
+ tracker.setTargetPace(
+ intent?.getDoubleExtra(RunActivity.EXTRA_TARGET_PACE_SEC_PER_KM, -1.0)?.takeIf { it > 0.0 }
+ )
+ haptics = RunAlertHaptics(this, initialAlert = null)
+
notifyStartRun()
initNotification()
- startTimer()
- return START_STICKY
+ startLocationUpdates()
+ observeAlerts()
+ mainHandler.postDelayed(tick, TICK_MILLIS)
+ // 프로세스가 종료되면 러닝 상태도 함께 사라지므로, 서비스만 0초부터 다시 살아나지 않게 한다.
+ return START_NOT_STICKY
}
- fun notifyStartRun() {
- if (player == null) {
- player = MediaPlayer.create(this@TimerService, R.raw.start_run)
- player?.setOnCompletionListener { mediaPlayer ->
- mediaPlayer.release()
- player = null
+ @SuppressLint("MissingPermission") // 러닝 시작 전 위치 권한을 받은 상태에서만 이 서비스가 시작된다
+ private fun startLocationUpdates() {
+ val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, LOCATION_INTERVAL_MILLIS).build()
+ runCatching { fusedClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper()) }
+ }
+
+ /** 알림이 새로 뜨면 진동하고, 화면을 보고 있지 않으면 상단(헤드업) 알림으로도 띄운다. */
+ private fun observeAlerts() {
+ scope.launch {
+ tracker.state.distinctUntilChangedBy { it.alert }.collect { state ->
+ haptics.onAlertChanged(state.alert)
+ RunAlertNotifier.update(this@TimerService, state.alert, isUiVisible, contentIntent())
}
}
- player?.start()
}
- fun startTimer() {
- timer = Timer()
- timer?.scheduleAtFixedRate(object : TimerTask() {
- override fun run() {
- time++
-
- val hour = time / 3600
- val minute = (time % 3600) / 60
- val second = time % 60
-
- val timerValue = String.format("%02d:%02d:%02d", hour, minute, second)
- Timber.tag("Timer").d(timerValue)
-
- // 결과를 Activity로 전달
- val intent = Intent(TIMER_UPDATE_ACTION)
- intent.putExtra(
- EXTRA_TIMER_VALUE, TimerData(
- hour = hour,
- minute = minute,
- second = second
- )
- )
- intent.setPackage(packageName)
- sendBroadcast(intent)
-
- // 알림의 내용 업데이트
- notificationBuilder.setContentText(timerValue)
- val notificationManager =
- getSystemService(NOTIFICATION_SERVICE) as NotificationManager
- notificationManager.notify(NOTI_ID, notificationBuilder.build())
- }
- }, 0, 1000)
+ /** 러닝 화면이 보이는지 여부. 보일 때는 화면 배너로 충분하므로 상단 알림을 띄우지 않는다. */
+ fun setUiVisible(visible: Boolean) {
+ isUiVisible = visible
+ if (visible) RunAlertNotifier.cancel(this)
+ else RunAlertNotifier.update(this, tracker.state.value.alert, isUiVisible = false, contentIntent())
}
- fun stopTimer() {
- timer?.cancel()
+ override fun pause() {
+ tracker.pause()
+ updateOngoingNotification()
}
- // 일시정지: 타이머만 멈추고 누적된 time은 유지한다 (resumeTimer에서 이어서 증가)
- fun pauseTimer() {
- timer?.cancel()
- notificationBuilder.setContentText("일시정지 중")
- val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
- notificationManager.notify(NOTI_ID, notificationBuilder.build())
+ override fun resume() {
+ tracker.resume(System.currentTimeMillis())
+ updateOngoingNotification()
}
- // 재개: 기존 time 값에 이어서 타이머를 다시 시작한다
- fun resumeTimer() {
- startTimer()
+ override fun setTargetPace(paceSecPerKm: Double?) = tracker.setTargetPace(paceSecPerKm)
+
+ fun stopRun() {
+ mainHandler.removeCallbacks(tick)
+ fusedClient.removeLocationUpdates(locationCallback)
+ RunAlertNotifier.cancel(this)
}
- override fun onBind(intent: Intent?): IBinder {
- return binder
+ override fun onDestroy() {
+ stopRun()
+ scope.cancel()
+ super.onDestroy()
}
- fun initNotification() {
- notificationBuilder = NotificationCompat.Builder(this, "default")
- .setSmallIcon(R.drawable.ic_notification_run)
- .setColor(ContextCompat.getColor(this@TimerService, R.color.M1))
- .setContentTitle("러닝")
- .setContentText("00:00:00")
- .setOnlyAlertOnce(true)
- .setOngoing(true) // true 일 경우 알림 리스트에서 클릭하거나 좌우로 드래그해도 사라지지 않음
+ override fun onBind(intent: Intent?): IBinder = binder
+
+ private fun notifyStartRun() {
+ if (player == null) {
+ player = MediaPlayer.create(this@TimerService, R.raw.start_run)
+ player?.setOnCompletionListener { mediaPlayer ->
+ mediaPlayer.release()
+ player = null
+ }
+ }
+ player?.start()
+ }
+ private fun updateOngoingNotification() {
+ if (!::notificationBuilder.isInitialized) return
+ val state = tracker.state.value
+ notificationBuilder.setContentText(if (state.isPaused) "일시정지 중" else formatElapsed(state.elapsedSec))
+ getSystemService(NotificationManager::class.java).notify(NOTI_ID, notificationBuilder.build())
+ }
+
+ private fun contentIntent(): PendingIntent {
+ // 알림으로 RunActivity가 새로 만들어져도 코스 정보를 받을 수 있게 시작할 때 받은 extras를 그대로 넘긴다.
val notificationIntent = Intent(this@TimerService, RunActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
-
- val pendingIntent = IntentUtil.createSafePendingIntent(
+ .apply { runActivityExtras?.let(::putExtras) }
+ return IntentUtil.createSafePendingIntent(
this@TimerService,
0,
notificationIntent,
- FLAG_IMMUTABLE,
+ FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, // 이전 러닝의 코스 extras가 남지 않게 갱신
false
)
- notificationBuilder.setContentIntent(pendingIntent) // 알림 클릭 시 이동
+ }
- // 알림 표시
- val notificationManager =
- this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ private fun initNotification() {
+ notificationBuilder = NotificationCompat.Builder(this, "default")
+ .setSmallIcon(R.drawable.ic_notification_run)
+ .setColor(ContextCompat.getColor(this@TimerService, R.color.M1))
+ .setContentTitle("러닝")
+ .setContentText("00:00:00")
+ .setOnlyAlertOnce(true)
+ .setOngoing(true) // true 일 경우 알림 리스트에서 클릭하거나 좌우로 드래그해도 사라지지 않음
+ .setContentIntent(contentIntent()) // 알림 클릭 시 이동
+
+ val notificationManager = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
- NotificationChannel(
- "default",
- "기본 채널",
- NotificationManager.IMPORTANCE_DEFAULT
- )
+ NotificationChannel("default", "기본 채널", NotificationManager.IMPORTANCE_DEFAULT)
)
}
- notificationManager.notify(
- NOTI_ID,
- notificationBuilder.build()
- ) // id : 정의해야하는 각 알림의 고유한 int값
val notification = notificationBuilder.build()
+ notificationManager.notify(NOTI_ID, notification)
ForegroundServiceUtil.startForegroundSafely(
this,
NOTI_ID,
@@ -154,7 +209,11 @@ class TimerService : Service() {
}
companion object {
- const val TIMER_UPDATE_ACTION = "TIMER_UPDATE_ACTION"
- const val EXTRA_TIMER_VALUE = "EXTRA_TIMER_VALUE"
+ private const val NOTI_ID = 1
+ private const val TICK_MILLIS = 1_000L
+ private const val LOCATION_INTERVAL_MILLIS = 1_000L
+
+ fun formatElapsed(totalSec: Int): String =
+ String.format("%02d:%02d:%02d", totalSec / 3600, (totalSec % 3600) / 60, totalSec % 60)
}
}
diff --git a/app/src/main/res/layout/activity_run.xml b/app/src/main/res/layout/activity_run.xml
index ac3a87b3c..fad94d528 100644
--- a/app/src/main/res/layout/activity_run.xml
+++ b/app/src/main/res/layout/activity_run.xml
@@ -15,12 +15,14 @@
android:layout_height="match_parent"
tools:context=".views.location_enroll.LocationEnrollFragment">
+
@@ -35,105 +37,32 @@
android:elevation="11dp"
android:outlineProvider="none"
android:src="@drawable/backbutton"
- app:layout_constraintStart_toStartOf="@id/view_run_top_frame"
- app:layout_constraintTop_toTopOf="@id/view_run_top_frame" />
-
-
-
-
-
-
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
-
-
-
-
+ android:src="@drawable/star_top_radius"
+ app:layout_constraintBottom_toBottomOf="@id/imgBtn_back"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="@id/imgBtn_back" />
+
-
-
-
+ android:paddingBottom="24dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/imgBtn_back" />
+
+
일시정지
재개
러닝 종료
+ 페이스
+ 목표 %1$s
+ 목표 없음
+ /km
+ 러닝 중 알림
+ 코스에서 벗어났어요
+ 약 %1$dm · 코스로 돌아오면 알림이 사라져요
+ 목표 페이스보다 느려지고 있어요
+ 목표 %1$s · 현재 %2$s
+ 이번 러닝, 목표 페이스를 정할까요?
+ 목표보다 느려지면 러닝 중에 알려드려요. 선택하지 않아도 러닝은 그대로 진행돼요
+ 설정 안 함
+ 1위 페이스
+ 내 최고기록
+ 직접 입력
+ 목표 페이스 5초 빠르게
+ 목표 페이스 5초 느리게
+ 러닝 시작
일시정지 중
선택
diff --git a/app/src/release/java/com/runnect/runnect/presentation/run/RunDebugTools.kt b/app/src/release/java/com/runnect/runnect/presentation/run/RunDebugTools.kt
new file mode 100644
index 000000000..bba27e6bd
--- /dev/null
+++ b/app/src/release/java/com/runnect/runnect/presentation/run/RunDebugTools.kt
@@ -0,0 +1,11 @@
+package com.runnect.runnect.presentation.run
+
+import androidx.appcompat.app.AppCompatActivity
+import com.naver.maps.geometry.LatLng
+
+/** release 빌드에서는 아무것도 하지 않는다. 실제 구현(GPS 시뮬레이터)은 debug 소스셋에 있다. */
+object RunDebugTools {
+ fun attach(activity: AppCompatActivity, route: List, controller: () -> RunController?) = Unit
+
+ fun onServiceTick(state: RunTrackingState) = Unit
+}
diff --git a/app/src/test/java/com/runnect/runnect/presentation/countdown/TargetPaceViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/countdown/TargetPaceViewModelTest.kt
new file mode 100644
index 000000000..235089669
--- /dev/null
+++ b/app/src/test/java/com/runnect/runnect/presentation/countdown/TargetPaceViewModelTest.kt
@@ -0,0 +1,136 @@
+package com.runnect.runnect.presentation.countdown
+
+import androidx.lifecycle.SavedStateHandle
+import com.runnect.runnect.domain.entity.CourseRanking
+import com.runnect.runnect.domain.entity.CourseRankingEntry
+import com.runnect.runnect.domain.entity.MyCourseRanking
+import com.runnect.runnect.domain.repository.CourseRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class TargetPaceViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var courseRepository: CourseRepository
+ private lateinit var viewModel: TargetPaceViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ courseRepository = mockk()
+ viewModel = TargetPaceViewModel(courseRepository, SavedStateHandle())
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun givenRanking(topTime: String?, myTime: String?) {
+ val entries = topTime?.let { listOf(CourseRankingEntry(1, 1, "런너A", 100, it, "무시되는 값")) }.orEmpty()
+ coEvery { courseRepository.getCourseRanking(courseId = 7, limit = 1) } returns
+ flowOf(Result.success(CourseRanking(totalCount = entries.size, entries = entries)))
+ coEvery { courseRepository.getMyCourseRanking(courseId = 7) } returns flowOf(
+ Result.success(
+ MyCourseRanking(hasRecord = myTime != null, rank = 3, userId = 2, nickname = "나", time = myTime, pace = null)
+ )
+ )
+ }
+
+ @Test
+ fun `랭킹 1위와 내 기록의 소요 시간과 코스 거리로 추천 페이스를 계산한다`() = runTest(testDispatcher) {
+ givenRanking(topTime = "00:10:00", myTime = "00:12:30")
+
+ viewModel.loadRecommendations(publicCourseId = 7, distanceKm = 2.0)
+ advanceUntilIdle()
+
+ assertEquals(300.0, requireNotNull(viewModel.uiState.value.topRankPaceSecPerKm), 0.001)
+ assertEquals(375.0, requireNotNull(viewModel.uiState.value.myBestPaceSecPerKm), 0.001)
+ }
+
+ @Test
+ fun `내 기록이 없으면 내 최고기록 추천값은 없다`() = runTest(testDispatcher) {
+ givenRanking(topTime = "00:10:00", myTime = null)
+
+ viewModel.loadRecommendations(publicCourseId = 7, distanceKm = 2.0)
+ advanceUntilIdle()
+
+ assertNull(viewModel.uiState.value.myBestPaceSecPerKm)
+ }
+
+ @Test
+ fun `랭킹 조회에 실패하면 추천값 없이 진행한다`() = runTest(testDispatcher) {
+ coEvery { courseRepository.getCourseRanking(any(), any()) } returns flowOf(Result.failure(RuntimeException()))
+ coEvery { courseRepository.getMyCourseRanking(any()) } returns flowOf(Result.failure(RuntimeException()))
+
+ viewModel.loadRecommendations(publicCourseId = 7, distanceKm = 2.0)
+ advanceUntilIdle()
+
+ assertNull(viewModel.uiState.value.topRankPaceSecPerKm)
+ assertNull(viewModel.uiState.value.myBestPaceSecPerKm)
+ }
+
+ @Test
+ fun `공개 코스가 아니면 랭킹을 조회하지 않는다`() = runTest(testDispatcher) {
+ viewModel.loadRecommendations(publicCourseId = null, distanceKm = 2.0)
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { courseRepository.getCourseRanking(any(), any()) }
+ }
+
+ @Test
+ fun `기본 선택은 설정 안 함이라 목표 페이스가 없다`() {
+ assertEquals(TargetPaceOption.NONE, viewModel.uiState.value.selection)
+ assertNull(viewModel.uiState.value.selectedPaceSecPerKm)
+ }
+
+ @Test
+ fun `추천값을 고르면 해당 페이스가 목표 페이스가 된다`() = runTest(testDispatcher) {
+ givenRanking(topTime = "00:10:00", myTime = null)
+ viewModel.loadRecommendations(publicCourseId = 7, distanceKm = 2.0)
+ advanceUntilIdle()
+
+ viewModel.select(TargetPaceOption.TOP_RANK)
+
+ assertEquals(300.0, requireNotNull(viewModel.uiState.value.selectedPaceSecPerKm), 0.001)
+ }
+
+ @Test
+ fun `직접 입력은 5초 단위로 조정되고 허용 범위를 넘지 않는다`() {
+ viewModel.select(TargetPaceOption.CUSTOM)
+ viewModel.adjustCustomPace(5)
+ assertEquals(365.0, requireNotNull(viewModel.uiState.value.selectedPaceSecPerKm), 0.001)
+
+ repeat(200) { viewModel.adjustCustomPace(-5) }
+ assertEquals(TargetPaceViewModel.MIN_CUSTOM_PACE_SEC, viewModel.uiState.value.customPaceSecPerKm, 0.001)
+ }
+
+ @Test
+ fun `Activity가 재생성돼도 고른 선택지와 직접 입력 페이스가 복원된다`() {
+ val savedState = SavedStateHandle()
+ TargetPaceViewModel(courseRepository, savedState).apply {
+ select(TargetPaceOption.CUSTOM)
+ adjustCustomPace(10)
+ }
+
+ val restored = TargetPaceViewModel(courseRepository, savedState)
+
+ assertEquals(TargetPaceOption.CUSTOM, restored.uiState.value.selection)
+ assertEquals(370.0, requireNotNull(restored.uiState.value.selectedPaceSecPerKm), 0.001)
+ }
+}
diff --git a/app/src/test/java/com/runnect/runnect/presentation/run/PaceFormatTest.kt b/app/src/test/java/com/runnect/runnect/presentation/run/PaceFormatTest.kt
new file mode 100644
index 000000000..6faf703bf
--- /dev/null
+++ b/app/src/test/java/com/runnect/runnect/presentation/run/PaceFormatTest.kt
@@ -0,0 +1,45 @@
+package com.runnect.runnect.presentation.run
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class PaceFormatTest {
+
+ @Test
+ fun `초 단위 페이스를 분과 초로 표시한다`() {
+ assertEquals("5'48\"", PaceFormat.format(348.0))
+ assertEquals("6'05\"", PaceFormat.format(365.0))
+ }
+
+ @Test
+ fun `페이스가 없으면 대시로 표시한다`() {
+ assertEquals("-", PaceFormat.format(null))
+ assertEquals("-", PaceFormat.format(Double.POSITIVE_INFINITY))
+ }
+
+ @Test
+ fun `시분초와 분초 형식의 소요 시간을 초로 변환한다`() {
+ assertEquals(3_723, PaceFormat.parseDurationSec("01:02:03"))
+ assertEquals(684, PaceFormat.parseDurationSec("11:24"))
+ }
+
+ @Test
+ fun `형식이 맞지 않는 소요 시간은 null이다`() {
+ assertNull(PaceFormat.parseDurationSec(null))
+ assertNull(PaceFormat.parseDurationSec("abc"))
+ assertNull(PaceFormat.parseDurationSec("5"))
+ assertNull(PaceFormat.parseDurationSec("0:6:-50"))
+ }
+
+ @Test
+ fun `소요 시간과 거리로 초 단위 페이스를 계산한다`() {
+ assertEquals(300.0, requireNotNull(PaceFormat.paceFromDuration("00:10:00", 2.0)), 0.001)
+ }
+
+ @Test
+ fun `거리가 없거나 0이면 페이스를 계산하지 않는다`() {
+ assertNull(PaceFormat.paceFromDuration("00:10:00", null))
+ assertNull(PaceFormat.paceFromDuration("00:10:00", 0.0))
+ }
+}
diff --git a/app/src/test/java/com/runnect/runnect/presentation/run/RunAlertEvaluatorTest.kt b/app/src/test/java/com/runnect/runnect/presentation/run/RunAlertEvaluatorTest.kt
new file mode 100644
index 000000000..25b3448d1
--- /dev/null
+++ b/app/src/test/java/com/runnect/runnect/presentation/run/RunAlertEvaluatorTest.kt
@@ -0,0 +1,139 @@
+package com.runnect.runnect.presentation.run
+
+import com.naver.maps.geometry.LatLng
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RunAlertEvaluatorTest {
+
+ private val evaluator = RunAlertEvaluator()
+
+ private fun offRoute(now: Long, distanceM: Double = 45.0) =
+ evaluator.evaluate(now, distanceToRouteM = distanceM, paceSecPerKm = null, targetPaceSecPerKm = null)
+
+ private fun pace(now: Long, paceSecPerKm: Double, target: Double = 300.0, distanceM: Double = 0.0) =
+ evaluator.evaluate(now, distanceToRouteM = distanceM, paceSecPerKm = paceSecPerKm, targetPaceSecPerKm = target)
+
+ @Test
+ fun `경로에서 30m 넘게 벗어나도 8초 연속되기 전에는 이탈 알림을 띄우지 않는다`() {
+ assertNull(offRoute(0))
+ assertNull(offRoute(7_999))
+ }
+
+ @Test
+ fun `경로에서 30m 넘게 벗어난 상태가 8초 연속되면 이탈 거리와 함께 알림을 띄운다`() {
+ offRoute(0)
+ assertEquals(RunAlert.OffRoute(45), offRoute(8_000))
+ }
+
+ @Test
+ fun `중간에 경로로 돌아오면 연속 시간이 초기화된다`() {
+ offRoute(0)
+ offRoute(5_000, distanceM = 10.0)
+ assertNull(offRoute(9_000)) // 다시 벗어난 시점(9초)부터 새로 셈
+ assertNull(offRoute(16_999))
+ assertEquals(RunAlert.OffRoute(45), offRoute(17_000))
+ }
+
+ @Test
+ fun `경로로 돌아오면 이탈 알림이 바로 사라진다`() {
+ offRoute(0)
+ offRoute(8_000)
+ assertNull(offRoute(9_000, distanceM = 5.0))
+ }
+
+ @Test
+ fun `이탈 알림이 떠 있는 동안에는 거리가 바뀌어도 계속 유지된다`() {
+ offRoute(0)
+ offRoute(8_000)
+ assertEquals(RunAlert.OffRoute(80), offRoute(70_000, distanceM = 80.0))
+ }
+
+ @Test
+ fun `이탈 알림은 마지막으로 띄운 뒤 60초 안에는 다시 띄우지 않는다`() {
+ offRoute(0)
+ offRoute(8_000) // 알림 발생
+ offRoute(9_000, distanceM = 5.0) // 복귀
+ offRoute(10_000)
+ assertNull(offRoute(18_000)) // 조건은 충족했지만 쿨다운 중
+ assertEquals(RunAlert.OffRoute(45), offRoute(68_000)) // 쿨다운 종료
+ }
+
+ @Test
+ fun `목표보다 20퍼센트 이상 느린 상태가 10초 연속되면 페이스 저하 알림을 띄운다`() {
+ assertNull(pace(0, paceSecPerKm = 360.0))
+ assertNull(pace(9_999, paceSecPerKm = 360.0))
+ assertEquals(RunAlert.PaceDrop(300.0, 360.0), pace(10_000, paceSecPerKm = 360.0))
+ }
+
+ @Test
+ fun `목표보다 느리더라도 20퍼센트 미만이면 페이스 저하 알림을 띄우지 않는다`() {
+ pace(0, paceSecPerKm = 359.0)
+ assertNull(pace(20_000, paceSecPerKm = 359.0))
+ }
+
+ @Test
+ fun `목표 페이스가 없으면 페이스 저하 알림을 띄우지 않는다`() {
+ evaluator.evaluate(0, 0.0, 600.0, null)
+ assertNull(evaluator.evaluate(20_000, 0.0, 600.0, null))
+ }
+
+ @Test
+ fun `이탈과 페이스 저하가 동시에 해당하면 이탈 알림을 우선한다`() {
+ pace(0, paceSecPerKm = 400.0, distanceM = 50.0)
+ val alert = pace(10_000, paceSecPerKm = 400.0, distanceM = 50.0)
+ assertEquals(RunAlert.OffRoute(50), alert)
+ }
+
+ @Test
+ fun `이탈 알림에 가려졌던 페이스 저하 알림은 쿨다운 없이 이탈 해소 직후 바로 뜬다`() {
+ pace(0, paceSecPerKm = 400.0, distanceM = 50.0)
+ pace(10_000, paceSecPerKm = 400.0, distanceM = 50.0)
+ val alert = pace(11_000, paceSecPerKm = 400.0, distanceM = 5.0)
+ assertEquals(RunAlert.PaceDrop(300.0, 400.0), alert)
+ }
+
+ @Test
+ fun `reset하면 떠 있던 알림과 연속 판정이 초기화된다`() {
+ offRoute(0)
+ offRoute(8_000)
+ evaluator.reset()
+ assertNull(offRoute(9_000))
+ }
+
+ @Test
+ fun `경로 위 선분까지의 최단 거리를 계산한다`() {
+ // 동서 방향 선분에서 북쪽으로 약 0.0003도(약 33m) 떨어진 점
+ val path = listOf(LatLng(37.5665, 126.9780), LatLng(37.5665, 126.9820))
+ val distance = RouteGeometry.distanceToPathM(LatLng(37.5668, 126.9800), path)
+ requireNotNull(distance)
+ assertTrue("약 33m여야 한다: $distance", distance in 31.0..35.0)
+ }
+
+ @Test
+ fun `선분 끝을 벗어난 점은 가장 가까운 끝점까지의 거리로 계산한다`() {
+ val path = listOf(LatLng(37.5665, 126.9780), LatLng(37.5665, 126.9790))
+ val point = LatLng(37.5665, 126.9800) // 선분 동쪽 끝에서 약 88m 더 동쪽
+ val distance = requireNotNull(RouteGeometry.distanceToPathM(point, path))
+ assertEquals(point.distanceTo(path.last()), distance, 1.0)
+ }
+
+ @Test
+ fun `경로가 비어 있으면 거리를 계산하지 않는다`() {
+ assertNull(RouteGeometry.distanceToPathM(LatLng(37.5665, 126.9780), emptyList()))
+ }
+
+ @Test
+ fun `같은 종류에서 값만 바뀐 알림은 새 알림이 아니다`() {
+ assertEquals(false, RunAlert.OffRoute(55).isNewComparedTo(RunAlert.OffRoute(40)))
+ assertEquals(false, RunAlert.PaceDrop(300.0, 410.0).isNewComparedTo(RunAlert.PaceDrop(300.0, 400.0)))
+ }
+
+ @Test
+ fun `없다가 생기거나 종류가 바뀐 알림은 새 알림이다`() {
+ assertEquals(true, RunAlert.OffRoute(40).isNewComparedTo(null))
+ assertEquals(true, RunAlert.OffRoute(40).isNewComparedTo(RunAlert.PaceDrop(300.0, 400.0)))
+ }
+}
diff --git a/app/src/test/java/com/runnect/runnect/presentation/run/RunAlertHapticsTest.kt b/app/src/test/java/com/runnect/runnect/presentation/run/RunAlertHapticsTest.kt
new file mode 100644
index 000000000..d25c37f0d
--- /dev/null
+++ b/app/src/test/java/com/runnect/runnect/presentation/run/RunAlertHapticsTest.kt
@@ -0,0 +1,39 @@
+package com.runnect.runnect.presentation.run
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class RunAlertHapticsTest {
+
+ @Test
+ fun `이탈 알림이 새로 뜨면 이탈 진동 패턴으로 울린다`() {
+ assertArrayEquals(RunAlertHaptics.OFF_ROUTE_PATTERN, RunAlertHaptics.patternFor(null, RunAlert.OffRoute(40)))
+ }
+
+ @Test
+ fun `페이스 저하 알림이 새로 뜨면 페이스 저하 진동 패턴으로 울린다`() {
+ assertArrayEquals(
+ RunAlertHaptics.PACE_DROP_PATTERN,
+ RunAlertHaptics.patternFor(null, RunAlert.PaceDrop(300.0, 400.0))
+ )
+ }
+
+ @Test
+ fun `같은 알림이 유지되며 값만 바뀌면 다시 울리지 않는다`() {
+ assertNull(RunAlertHaptics.patternFor(RunAlert.OffRoute(40), RunAlert.OffRoute(55)))
+ }
+
+ @Test
+ fun `알림이 사라질 때는 울리지 않는다`() {
+ assertNull(RunAlertHaptics.patternFor(RunAlert.OffRoute(40), null))
+ }
+
+ @Test
+ fun `알림 종류가 바뀌면 새 알림 패턴으로 울린다`() {
+ assertArrayEquals(
+ RunAlertHaptics.OFF_ROUTE_PATTERN,
+ RunAlertHaptics.patternFor(RunAlert.PaceDrop(300.0, 400.0), RunAlert.OffRoute(40))
+ )
+ }
+}
diff --git a/app/src/test/java/com/runnect/runnect/presentation/run/RunTrackerTest.kt b/app/src/test/java/com/runnect/runnect/presentation/run/RunTrackerTest.kt
new file mode 100644
index 000000000..521f0c558
--- /dev/null
+++ b/app/src/test/java/com/runnect/runnect/presentation/run/RunTrackerTest.kt
@@ -0,0 +1,315 @@
+package com.runnect.runnect.presentation.run
+
+import com.naver.maps.geometry.LatLng
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class RunTrackerTest {
+
+ private lateinit var tracker: RunTracker
+
+ @Before
+ fun setUp() {
+ tracker = RunTracker()
+ }
+
+ @Test
+ fun `첫 위치 업데이트는 이전 위치가 없어서 거리를 누적하지 않는다`() {
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780))
+
+ assertEquals(0.0, tracker.state.value.distanceKm, 0.0)
+ }
+
+ @Test
+ fun `연속된 여러 위치 이동만큼 거리가 누적된다`() {
+ val baseLat = 37.5665
+ tracker.onLocationUpdated(LatLng(baseLat, 126.9780))
+ // 각 구간 약 22m씩(임계값 50m 이내), 5구간 합산 약 111m
+ repeat(5) { i ->
+ tracker.onLocationUpdated(LatLng(baseLat + 0.0002 * (i + 1), 126.9780))
+ }
+
+ val distanceKm = tracker.state.value.distanceKm ?: 0.0
+ assertTrue("누적 거리가 0보다 커야 한다: $distanceKm", distanceKm > 0.0)
+ assertTrue("누적 거리가 비정상적으로 크면 안 된다: $distanceKm", distanceKm < 0.3)
+ }
+
+ @Test
+ fun `일시정지 중에는 이동해도 거리가 누적되지 않는다`() {
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780))
+ tracker.pause()
+
+ tracker.onLocationUpdated(LatLng(37.5675, 126.9780))
+
+ assertEquals(0.0, tracker.state.value.distanceKm, 0.0)
+ }
+
+ @Test
+ fun `일시정지 해제 후에는 정지 중 이동분을 제외하고 재개 이후 이동만 누적된다`() {
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780))
+ tracker.pause()
+ tracker.onLocationUpdated(LatLng(37.5715, 126.9780)) // 일시정지 중 약 555m 이동 — 누적되면 안 됨
+ tracker.resume(0L)
+
+ tracker.onLocationUpdated(LatLng(37.5716, 126.9780)) // 재개 후 약 11m만 실제 이동
+
+ val distanceAfterResume = tracker.state.value.distanceKm ?: 0.0
+ assertTrue(
+ "재개 직후 거리는 일시정지 중 이동분(약 555m)을 포함하면 안 된다: $distanceAfterResume",
+ distanceAfterResume < 0.1
+ )
+ }
+
+ @Test
+ fun `GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동은 무시한다`() {
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780))
+ tracker.onLocationUpdated(LatLng(37.5765, 126.9780)) // 약 1.1km, 임계값(50m) 훨씬 초과
+
+ assertEquals(0.0, tracker.state.value.distanceKm, 0.0)
+ }
+
+ @Test
+ fun `거부된 튐 좌표는 기준점으로 남지 않고 다음 정상 이동은 튐 이전 위치 기준으로 계산된다`() {
+ val baseLat = 37.5665
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(baseLat, 126.9780), now) // 기준점
+ // 약 122m 튐 - 거부됨. 이 좌표가 lastLocation으로 남으면 이후 정상 이동까지 잘못 거부/누적된다.
+ tracker.onLocationUpdated(LatLng(baseLat + 0.0011, 126.9780), now + 1_000)
+ // 튐 이전 기준점(baseLat)으로부터 약 44m - 정상 이동이면 누적되어야 한다.
+ tracker.onLocationUpdated(LatLng(baseLat + 0.0004, 126.9780), now + 2_000)
+ // 직전 정상 위치로부터 약 44m 추가 이동
+ tracker.onLocationUpdated(LatLng(baseLat + 0.0008, 126.9780), now + 3_000)
+
+ val distanceKm = tracker.state.value.distanceKm ?: 0.0
+ assertTrue(
+ "튐 좌표가 기준점으로 남으면 이후 정상 이동(총 약 89m)이 누락된다: $distanceKm",
+ distanceKm >= 0.1
+ )
+ }
+
+ @Test
+ fun `충분히 움직이지 않으면 페이스는 null이다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+
+ assertEquals(null, tracker.state.value.paceSecPerKm)
+ }
+
+ @Test
+ fun `최근 구간 이동 거리와 시간으로 페이스가 계산된다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+ // 페이스 샘플은 두 번째 위치 업데이트부터 기록되므로, 최소 2개 샘플을 쌓기 위해 3번 갱신한다.
+ tracker.onLocationUpdated(LatLng(37.5667, 126.9780), now + 5_000)
+ tracker.onLocationUpdated(LatLng(37.5669, 126.9780), now + 10_000)
+
+ val pace = tracker.state.value.paceSecPerKm
+ assertTrue("페이스가 계산되어야 한다: $pace", pace != null && pace > 0)
+ }
+
+ @Test
+ fun `일시정지 중에는 페이스가 갱신되지 않는다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+ tracker.pause()
+
+ tracker.onLocationUpdated(LatLng(37.5675, 126.9780), now + 10_000)
+
+ assertEquals(null, tracker.state.value.paceSecPerKm)
+ }
+
+ @Test
+ fun `무활동 시간이 임계값을 넘으면 자동 일시정지가 필요하다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+
+ assertTrue(tracker.shouldAutoPause(now + 20_000))
+ }
+
+ @Test
+ fun `무활동 시간이 임계값 미만이면 자동 일시정지가 필요없다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+
+ assertEquals(false, tracker.shouldAutoPause(now + 19_000))
+ }
+
+ @Test
+ fun `이미 일시정지 상태면 자동 일시정지를 다시 트리거하지 않는다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+ tracker.pause()
+
+ assertEquals(false, tracker.shouldAutoPause(now + 20_000))
+ }
+
+ @Test
+ fun `수동 재개 직후에는 무활동 타이머가 초기화되어 바로 자동 일시정지되지 않는다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+
+ tracker.resume(now + 60_000)
+
+ assertEquals(false, tracker.shouldAutoPause(now + 60_000 + 19_000))
+ }
+
+ @Test
+ fun `움직임이 감지되면 무활동 타이머가 갱신된다`() {
+ val now = 0L
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), now)
+ // 임계값(10m) 이상 이동(약 11m) -> 무활동 타이머 갱신
+ tracker.onLocationUpdated(LatLng(37.5666, 126.9780), now + 30_000)
+
+ assertEquals(false, tracker.shouldAutoPause(now + 30_000 + 19_000))
+ assertTrue(tracker.shouldAutoPause(now + 30_000 + 20_000))
+ }
+
+ // 동서 방향 약 350m 직선 코스
+ private val straightRoute = listOf(LatLng(37.5665, 126.9780), LatLng(37.5665, 126.9820))
+
+ /** 코스와 평행하게 북쪽으로 latOffset만큼 떨어져 1초에 약 18m씩 동쪽으로 달린다. */
+ private fun runAlongside(latOffset: Double, seconds: Int, startMillis: Long = 0L) {
+ repeat(seconds + 1) { i ->
+ tracker.onLocationUpdated(
+ LatLng(37.5665 + latOffset, 126.9780 + 0.0002 * i),
+ startMillis + i * 1_000L
+ )
+ }
+ }
+
+ @Test
+ fun `코스에서 벗어난 채로 8초 이상 달리면 이탈 알림이 뜬다`() {
+ tracker.setRoute(straightRoute)
+
+ // 첫 위치는 기준점으로만 쓰여 판정이 1초부터 시작되므로 9초 달린다. 약 55m 이탈.
+ runAlongside(latOffset = 0.0005, seconds = 9)
+
+ assertTrue(tracker.state.value.alert is RunAlert.OffRoute)
+ }
+
+ @Test
+ fun `코스 위를 달리면 이탈 알림이 뜨지 않는다`() {
+ tracker.setRoute(straightRoute)
+
+ runAlongside(latOffset = 0.0001, seconds = 10) // 약 11m, GPS 오차 범위
+
+ assertEquals(null, tracker.state.value.alert)
+ }
+
+ @Test
+ fun `일시정지하면 떠 있던 이탈 알림이 사라진다`() {
+ tracker.setRoute(straightRoute)
+ runAlongside(latOffset = 0.0005, seconds = 9)
+ assertTrue(tracker.state.value.alert is RunAlert.OffRoute)
+
+ tracker.pause()
+
+ assertEquals(null, tracker.state.value.alert)
+ }
+
+ @Test
+ fun `목표 페이스보다 20퍼센트 넘게 느리게 10초 이상 달리면 페이스 저하 알림이 뜬다`() {
+ tracker.setRoute(straightRoute)
+ tracker.setTargetPace(30.0) // 초/km, 1초에 약 18m(약 56초/km)면 목표보다 훨씬 느림
+
+ runAlongside(latOffset = 0.0, seconds = 12)
+
+ assertTrue(tracker.state.value.alert is RunAlert.PaceDrop)
+ }
+
+ @Test
+ fun `목표 페이스를 설정하지 않으면 느리게 달려도 페이스 저하 알림이 뜨지 않는다`() {
+ tracker.setRoute(straightRoute)
+ tracker.setTargetPace(null)
+
+ runAlongside(latOffset = 0.0, seconds = 12)
+
+ assertEquals(null, tracker.state.value.alert)
+ }
+
+ @Test
+ fun `기준점이 틀려 튐이 연속되면 기준점을 새 위치로 옮기고 이후 이동은 정상 누적한다`() {
+ tracker.onLocationUpdated(LatLng(37.5000, 126.9000), 0L) // 부정확한 첫 측위(실제 위치에서 수 km)
+ // 실제 위치 근처에서 약 18m씩 이동 — 처음 3번은 틀린 기준점 대비 튐으로 거부되고, 3번째에 기준점이 옮겨진다
+ repeat(8) { i ->
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780 + 0.0002 * i), (i + 1) * 1_000L)
+ }
+
+ val distanceKm = tracker.state.value.distanceKm ?: 0.0
+ assertTrue("기준점 이동 후 약 88m가 누적돼야 한다: $distanceKm", distanceKm in 0.05..0.15)
+ }
+
+ @Test
+ fun `위치 갱신 한 번의 이동량이 작아도 계속 달리고 있으면 자동 일시정지되지 않는다`() {
+ // 1초마다 약 2.8m(5'57" 페이스) 이동 — 한 번의 이동량은 움직임 임계값보다 작다
+ repeat(61) { i ->
+ tracker.onLocationUpdated(LatLng(37.5665 + 0.0000252 * i, 126.9780), i * 1_000L)
+ }
+
+ assertEquals(false, tracker.shouldAutoPause(60_000L))
+ }
+
+ @Test
+ fun `제자리에서 GPS가 몇 미터씩 흔들리기만 하면 움직임으로 보지 않는다`() {
+ repeat(21) { i ->
+ val jitter = if (i % 2 == 0) 0.0 else 0.00003 // 약 3m 왕복
+ tracker.onLocationUpdated(LatLng(37.5665 + jitter, 126.9780), i * 1_000L)
+ }
+
+ assertTrue(tracker.shouldAutoPause(20_000L))
+ }
+
+ @Test
+ fun `달리는 동안에만 경과 시간이 올라간다`() {
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), 0L)
+ tracker.onSecondElapsed(1_000L)
+ tracker.onSecondElapsed(2_000L)
+ tracker.pause()
+ tracker.onSecondElapsed(3_000L)
+
+ assertEquals(2, tracker.state.value.elapsedSec)
+ }
+
+ @Test
+ fun `무활동이 이어지면 1초 틱에서 자동 일시정지하고 true를 반환한다`() {
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780), 0L)
+
+ assertEquals(false, tracker.onSecondElapsed(19_000L))
+ assertEquals(true, tracker.onSecondElapsed(20_000L))
+ assertEquals(true, tracker.state.value.isPaused)
+ }
+
+ @Test
+ fun `목표 페이스는 상태에 담겨 화면에 표시된다`() {
+ tracker.setTargetPace(300.0)
+ assertEquals(300.0, requireNotNull(tracker.state.value.targetPaceSecPerKm), 0.0)
+
+ tracker.setTargetPace(null)
+ assertEquals(null, tracker.state.value.targetPaceSecPerKm)
+ }
+
+ @Test
+ fun `기준점을 옮긴 직후에는 옮기기 전 위치가 페이스 계산에 섞이지 않는다`() {
+ tracker.onLocationUpdated(LatLng(37.5000, 126.9000), 0L) // 멀리 떨어진 부정확한 첫 측위
+ repeat(8) { i ->
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9780 + 0.0000318 * i), (i + 1) * 1_000L) // 약 2.8m/s
+ }
+
+ val pace = requireNotNull(tracker.state.value.paceSecPerKm)
+ assertTrue("5'57\" 근처여야 한다: $pace", pace in 300.0..420.0)
+ }
+
+ @Test
+ fun `재개 직후 페이스에는 일시정지 중 이동분이 섞이지 않는다`() {
+ repeat(6) { i -> tracker.onLocationUpdated(LatLng(37.5665, 126.9780 + 0.0000318 * i), i * 1_000L) }
+ tracker.pause()
+ tracker.onLocationUpdated(LatLng(37.5665, 126.9790), 7_000L) // 정지 중 약 60m 이동
+ tracker.resume(8_000L)
+ repeat(4) { i -> tracker.onLocationUpdated(LatLng(37.5665, 126.9790 + 0.0000318 * (i + 1)), 9_000L + i * 1_000L) }
+
+ val pace = requireNotNull(tracker.state.value.paceSecPerKm)
+ assertTrue("정지 중 이동이 섞이면 비정상적으로 빨라진다: $pace", pace in 300.0..420.0)
+ }
+}
diff --git a/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt
deleted file mode 100644
index 1f3f66729..000000000
--- a/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-package com.runnect.runnect.presentation.run
-
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import com.naver.maps.geometry.LatLng
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-
-class RunViewModelTest {
-
- @get:Rule
- val instantTaskExecutorRule = InstantTaskExecutorRule()
-
- private lateinit var viewModel: RunViewModel
-
- @Before
- fun setUp() {
- viewModel = RunViewModel()
- }
-
- @Test
- fun `첫 위치 업데이트는 이전 위치가 없어서 거리를 누적하지 않는다`() {
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780))
-
- assertEquals(0.0, viewModel.traveledDistanceKm.value)
- }
-
- @Test
- fun `연속된 여러 위치 이동만큼 거리가 누적된다`() {
- val baseLat = 37.5665
- viewModel.onLocationUpdated(LatLng(baseLat, 126.9780))
- // 각 구간 약 22m씩(임계값 50m 이내), 5구간 합산 약 111m
- repeat(5) { i ->
- viewModel.onLocationUpdated(LatLng(baseLat + 0.0002 * (i + 1), 126.9780))
- }
-
- val distanceKm = viewModel.traveledDistanceKm.value ?: 0.0
- assertTrue("누적 거리가 0보다 커야 한다: $distanceKm", distanceKm > 0.0)
- assertTrue("누적 거리가 비정상적으로 크면 안 된다: $distanceKm", distanceKm < 0.3)
- }
-
- @Test
- fun `일시정지 중에는 이동해도 거리가 누적되지 않는다`() {
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780))
- viewModel.isPaused.value = true
-
- viewModel.onLocationUpdated(LatLng(37.5675, 126.9780))
-
- assertEquals(0.0, viewModel.traveledDistanceKm.value)
- }
-
- @Test
- fun `일시정지 해제 후에는 정지 중 이동분을 제외하고 재개 이후 이동만 누적된다`() {
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780))
- viewModel.isPaused.value = true
- viewModel.onLocationUpdated(LatLng(37.5715, 126.9780)) // 일시정지 중 약 555m 이동 — 누적되면 안 됨
- viewModel.isPaused.value = false
-
- viewModel.onLocationUpdated(LatLng(37.5716, 126.9780)) // 재개 후 약 11m만 실제 이동
-
- val distanceAfterResume = viewModel.traveledDistanceKm.value ?: 0.0
- assertTrue(
- "재개 직후 거리는 일시정지 중 이동분(약 555m)을 포함하면 안 된다: $distanceAfterResume",
- distanceAfterResume < 0.1
- )
- }
-
- @Test
- fun `GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동은 무시한다`() {
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780))
- viewModel.onLocationUpdated(LatLng(37.5765, 126.9780)) // 약 1.1km, 임계값(50m) 훨씬 초과
-
- assertEquals(0.0, viewModel.traveledDistanceKm.value)
- }
-
- @Test
- fun `거부된 튐 좌표는 기준점으로 남지 않고 다음 정상 이동은 튐 이전 위치 기준으로 계산된다`() {
- val baseLat = 37.5665
- val now = 0L
- viewModel.onLocationUpdated(LatLng(baseLat, 126.9780), now) // 기준점
- // 약 122m 튐 - 거부됨. 이 좌표가 lastLocation으로 남으면 이후 정상 이동까지 잘못 거부/누적된다.
- viewModel.onLocationUpdated(LatLng(baseLat + 0.0011, 126.9780), now + 1_000)
- // 튐 이전 기준점(baseLat)으로부터 약 44m - 정상 이동이면 누적되어야 한다.
- viewModel.onLocationUpdated(LatLng(baseLat + 0.0004, 126.9780), now + 2_000)
- // 직전 정상 위치로부터 약 44m 추가 이동
- viewModel.onLocationUpdated(LatLng(baseLat + 0.0008, 126.9780), now + 3_000)
-
- val distanceKm = viewModel.traveledDistanceKm.value ?: 0.0
- assertTrue(
- "튐 좌표가 기준점으로 남으면 이후 정상 이동(총 약 89m)이 누락된다: $distanceKm",
- distanceKm >= 0.1
- )
- }
-
- @Test
- fun `충분히 움직이지 않으면 페이스는 null이다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
-
- assertEquals(null, viewModel.currentPaceSecPerKm.value)
- }
-
- @Test
- fun `최근 구간 이동 거리와 시간으로 페이스가 계산된다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
- // 페이스 샘플은 두 번째 위치 업데이트부터 기록되므로, 최소 2개 샘플을 쌓기 위해 3번 갱신한다.
- viewModel.onLocationUpdated(LatLng(37.5667, 126.9780), now + 5_000)
- viewModel.onLocationUpdated(LatLng(37.5669, 126.9780), now + 10_000)
-
- val pace = viewModel.currentPaceSecPerKm.value
- assertTrue("페이스가 계산되어야 한다: $pace", pace != null && pace > 0)
- }
-
- @Test
- fun `일시정지 중에는 페이스가 갱신되지 않는다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
- viewModel.isPaused.value = true
-
- viewModel.onLocationUpdated(LatLng(37.5675, 126.9780), now + 10_000)
-
- assertEquals(null, viewModel.currentPaceSecPerKm.value)
- }
-
- @Test
- fun `무활동 시간이 임계값을 넘으면 자동 일시정지가 필요하다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
-
- assertTrue(viewModel.shouldAutoPause(now + 60_000))
- }
-
- @Test
- fun `무활동 시간이 임계값 미만이면 자동 일시정지가 필요없다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
-
- assertEquals(false, viewModel.shouldAutoPause(now + 30_000))
- }
-
- @Test
- fun `이미 일시정지 상태면 자동 일시정지를 다시 트리거하지 않는다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
- viewModel.isPaused.value = true
-
- assertEquals(false, viewModel.shouldAutoPause(now + 60_000))
- }
-
- @Test
- fun `수동 재개 직후에는 무활동 타이머가 초기화되어 바로 자동 일시정지되지 않는다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
-
- viewModel.onManualResume(now + 60_000)
-
- assertEquals(false, viewModel.shouldAutoPause(now + 60_000 + 30_000))
- }
-
- @Test
- fun `움직임이 감지되면 무활동 타이머가 갱신된다`() {
- val now = 0L
- viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now)
- // 임계값(5m) 이상 이동 -> 무활동 타이머 갱신
- viewModel.onLocationUpdated(LatLng(37.56655, 126.9780), now + 30_000)
-
- assertEquals(false, viewModel.shouldAutoPause(now + 30_000 + 59_000))
- assertTrue(viewModel.shouldAutoPause(now + 30_000 + 60_000))
- }
-}
diff --git a/scripts/gps-sim-scenarios.sh b/scripts/gps-sim-scenarios.sh
new file mode 100755
index 000000000..0155082d8
--- /dev/null
+++ b/scripts/gps-sim-scenarios.sh
@@ -0,0 +1,217 @@
+#!/bin/bash
+# 러닝 화면 GPS 시뮬레이터 E2E 시나리오 (debug 빌드 전용)
+#
+# 사용법: 폰에서 러닝 화면(RunActivity)에 막 들어온 상태에서 실행 (목표 페이스는 '설정 안 함')
+# bash scripts/gps-sim-scenarios.sh [결과 저장 폴더]
+#
+# 앱의 러닝 서비스가 1초마다 남기는 logcat(RunSimState)으로 상태를, dumpsys로 진동/상단 알림 발생을 확인해
+# TC별 PASS/FAIL을 판정하고, 판정 시점 스크린샷과 화면 녹화를 결과 폴더에 저장한다.
+# 알림은 종류별 60초 쿨다운이 있어 TC 순서에 의존한다 — 순서를 바꾸면 기대값이 달라질 수 있다.
+
+PKG=com.runnect.runnect
+ADB=${ADB:-$(command -v adb || echo "$ANDROID_HOME/platform-tools/adb")}
+[ -x "$ADB" ] || ADB="$HOME/Library/Android/sdk/platform-tools/adb"
+OUT=${1:-./gps-sim-results/$(date +%Y%m%d-%H%M%S)}
+mkdir -p "$OUT"
+
+PASS=0; FAIL=0; RESULTS=()
+REQUIRE_FOREGROUND=1 # 백그라운드 TC 구간에서는 0
+REC_PART=0
+
+cleanup() {
+ "$ADB" shell svc power stayon false
+ "$ADB" shell pkill -INT screenrecord 2>/dev/null
+}
+
+ensure_foreground() { # 러닝 화면을 벗어나면(앱 나감, 화면 꺼짐) 결과가 무의미하므로 FAIL이 아니라 중단으로 끝낸다
+ [ "$REQUIRE_FOREGROUND" = 1 ] || return 0
+ if ! "$ADB" shell dumpsys activity activities | grep -m1 topResumedActivity | grep -q "run.RunActivity"; then
+ echo "[ABORTED] 러닝 화면을 벗어나 테스트를 중단합니다 (앱 이탈 또는 화면 꺼짐)"
+ cleanup; exit 2
+ fi
+}
+
+sim() { "$ADB" shell am broadcast -a com.runnect.debug.GPS_SIM -p $PKG --es cmd "$@" >/dev/null; }
+# 러닝 화면 디버그 패널의 시나리오 버튼과 같은 상황을 만든다(RunSimScenario)
+scenario() { sim scenario --es id "$1"; }
+latest_state() { "$ADB" logcat -d -s RunSimState:I | grep RunSimState | tail -1 | sed 's/.*RunSimState: //'; }
+state_field() { latest_state | tr ' ' '\n' | grep "^$1=" | cut -d= -f2; }
+last_vibration() { "$ADB" shell dumpsys vibrator_manager | grep "$PKG" | awk '{print $1, $2}' | sort | tail -1; }
+last_vibration_ms() { "$ADB" shell dumpsys vibrator_manager | grep "$PKG" | sort | tail -1 | sed -E 's/.*duration: *([0-9]+)ms.*/\1/'; }
+# 현재 떠 있는 알림만 본다 — dumpsys에는 지워진 알림 보관함(mArchive)과 시스템 자동 그룹 요약(AUTOGROUP_SUMMARY)도 섞여 나온다.
+has_alert_notification() {
+ "$ADB" shell dumpsys notification --noredact | sed '/mArchive=/,$d' | grep "channel=run_alert" | grep -vq "AUTOGROUP_SUMMARY"
+}
+shot() { "$ADB" shell screencap -p /sdcard/tc.png && "$ADB" pull -q /sdcard/tc.png "$OUT/$1.png"; }
+
+start_recording() { # screenrecord는 최대 180초라 구간별로 나눠 녹화한다
+ "$ADB" shell pkill -INT screenrecord 2>/dev/null; sleep 1
+ REC_PART=$((REC_PART+1))
+ "$ADB" shell screenrecord --time-limit 180 /sdcard/tc_part$REC_PART.mp4 &
+}
+
+record() { # $1=TC id, $2=설명, $3=PASS|FAIL, $4=근거
+ RESULTS+=("| $1 | $2 | $3 | \`$4\` |")
+ if [ "$3" = PASS ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); fi
+ echo "[$3] $1 $2 — $4"
+ shot "$1"
+}
+
+# 판정은 반복 횟수가 아니라 실제 경과 시간(초) 기준 — adb 호출 자체에 시간이 걸려 횟수로 세면 대기 시간이 늘어난다.
+
+expect_within() { # $1=초 $2=패턴 $3=TC id $4=설명 — 제한 시간 안에 최신 상태가 패턴과 일치하면 PASS
+ local state start=$SECONDS
+ while [ $((SECONDS - start)) -lt "$1" ]; do
+ sleep 1; ensure_foreground; state=$(latest_state)
+ if echo "$state" | grep -Eq "$2"; then record "$3" "$4" PASS "$state"; return; fi
+ done
+ record "$3" "$4" FAIL "$state"
+}
+
+expect_never() { # $1=초 $2=패턴 $3=TC id $4=설명 — 지정 시간 동안 한 번이라도 패턴과 일치하면 FAIL
+ local state start=$SECONDS
+ while [ $((SECONDS - start)) -lt "$1" ]; do
+ sleep 1; ensure_foreground; state=$(latest_state)
+ if echo "$state" | grep -Eq "$2"; then record "$3" "$4" FAIL "$state"; return; fi
+ done
+ record "$3" "$4" PASS "$state"
+}
+
+expect_after() { # $1=since $2=min초 $3=max초 $4=패턴 $5=TC id $6=설명 — 처음 나타난 시점이 min~max초 사이면 PASS
+ local state elapsed
+ while [ $((SECONDS - $1)) -le "$3" ]; do
+ sleep 1; ensure_foreground; state=$(latest_state)
+ if echo "$state" | grep -Eq "$4"; then
+ elapsed=$((SECONDS - $1))
+ if [ "$elapsed" -ge "$2" ]; then record "$5" "$6" PASS "${elapsed}초 후: $state"
+ else record "$5" "$6" FAIL "${elapsed}초 만에 발생(너무 빠름): $state"; fi
+ return
+ fi
+ done
+ record "$5" "$6" FAIL "$3초 안에 발생 안 함: $state"
+}
+
+expect_vibrated_since() { # $1=기준 진동 기록 $2=최소ms $3=최대ms $4=TC id $5=설명
+ local now_vib ms
+ now_vib=$(last_vibration); ms=$(last_vibration_ms)
+ if [ -n "$now_vib" ] && [ "$now_vib" != "$1" ] && [ "$ms" -ge "$2" ] && [ "$ms" -le "$3" ]; then
+ record "$4" "$5" PASS "진동 $now_vib (${ms}ms)"
+ else
+ record "$4" "$5" FAIL "마지막 진동: ${now_vib:-없음} (${ms:-?}ms)"
+ fi
+}
+
+top=$("$ADB" shell dumpsys activity activities | grep -m1 topResumedActivity)
+if ! echo "$top" | grep -q "run.RunActivity"; then
+ echo "러닝 화면(RunActivity)을 띄운 상태에서 실행하세요. 현재: $top"; exit 1
+fi
+
+"$ADB" shell appops set $PKG android:mock_location allow
+"$ADB" shell pm grant $PKG android.permission.POST_NOTIFICATIONS 2>/dev/null # 상단 알림 TC용
+"$ADB" shell svc power stayon true # 테스트 중 화면이 꺼지면 결과가 틀어지지 않게
+"$ADB" logcat -c
+start_recording
+
+# 준비: 출발점에서 보통 속도, 목표 페이스 없음, 코스 위
+sim resume; sim target --ei sec 0; sim rewind; scenario normal
+sleep 5 # 가짜 위치로 기준점이 옮겨질 시간
+
+# ── 포그라운드: 정상 주행 / 페이스 ──
+expect_within 30 "pace=[0-9]+ alert=NONE paused=false" TC-01 "코스 위 정상 주행 시 거리/페이스 표시, 알림 없음"
+expect_never 15 "alert=(OFF_ROUTE|PACE_DROP)" TC-02 "코스 위 주행 중 오탐 알림 없음"
+
+scenario faster # 목표 5'30", 실제 약 4'46"
+expect_never 15 "alert=PACE_DROP" TC-03 "목표보다 빠르면 페이스 저하 알림 없음"
+
+scenario slightly_slower # 약 5'57" — 목표보다 느리지만 20% 미만(6'36" 기준)
+expect_never 20 "alert=PACE_DROP" TC-04 "목표보다 20% 미만으로 느리면 알림 없음"
+
+VIB=$(last_vibration)
+scenario pace_drop # 목표 5'00", 실제 약 7'35" — 20% 넘게 느림
+expect_within 35 "alert=PACE_DROP" TC-05 "목표보다 20% 넘게 10초 이상 느리면 페이스 저하 알림"
+sleep 2; expect_vibrated_since "$VIB" 400 700 TC-06 "페이스 저하 알림 시 길게 한 번(500ms) 진동"
+
+scenario pace_recover
+expect_within 25 "alert=NONE" TC-07 "페이스를 회복하면 페이스 저하 알림 사라짐"
+sim target --ei sec 0; scenario normal
+
+# ── 포그라운드: 코스 이탈 ──
+scenario short_detour # 6초만 벗어났다 돌아옴 — 이탈 상태가 8초를 못 채움
+expect_never 15 "alert=OFF_ROUTE" TC-08 "잠깐(8초 미만) 벗어났다 돌아오면 이탈 알림 없음"
+
+start_recording
+VIB=$(last_vibration)
+scenario off_route
+expect_within 20 "alert=OFF_ROUTE" TC-09 "코스에서 30m 넘게 8초 이상 벗어나면 이탈 알림"
+OFF_ROUTE_AT=$SECONDS
+sleep 2; expect_vibrated_since "$VIB" 650 900 TC-10 "이탈 알림 시 짧게 두 번(300+150+300ms) 진동"
+
+scenario back_on_route
+expect_within 10 "alert=NONE" TC-11 "코스로 돌아오면 이탈 알림 사라짐"
+
+# ── 포그라운드: 자동 일시정지 ──
+scenario halt; HALT_AT=$SECONDS
+# 기준 20초. 로그가 1초 간격이라 판정 오차를 감안해 18~25초 사이면 정상으로 본다.
+expect_after $HALT_AT 18 25 "paused=true" TC-12 "제자리에 멈추면 약 20초 뒤 자동 일시정지(그 전엔 안 걸림)"
+
+scenario resume
+expect_within 5 "paused=false" TC-13 "재개하면 일시정지 해제"
+expect_never 20 "paused=true" TC-14 "재개 후 달리는 동안 다시 자동 일시정지 안 됨"
+
+# ── 백그라운드(홈으로 나감): 화면을 안 보고 뛰는 상황 ──
+start_recording
+REQUIRE_FOREGROUND=0
+"$ADB" shell input keyevent KEYCODE_HOME; sleep 2
+DIST_BEFORE=$(state_field distM) # 화면 표시값(0.1km 단위)은 15초 이동량(약 40m)을 못 보여줘서 미터로 비교
+sleep 15
+DIST_AFTER=$(state_field distM)
+if [ "$DIST_AFTER" -gt $((DIST_BEFORE + 20)) ]; then
+ record TC-15 "백그라운드에서도 거리가 계속 쌓임" PASS "${DIST_BEFORE}m → ${DIST_AFTER}m"
+else
+ record TC-15 "백그라운드에서도 거리가 계속 쌓임" FAIL "${DIST_BEFORE}m → ${DIST_AFTER}m"
+fi
+
+# 이탈 알림 60초 쿨다운이 끝난 뒤에 백그라운드 이탈을 검증한다
+WAIT=$((60 - (SECONDS - OFF_ROUTE_AT)))
+[ "$WAIT" -gt 0 ] && sleep "$WAIT"
+VIB=$(last_vibration)
+scenario off_route
+expect_within 20 "alert=OFF_ROUTE" TC-16 "백그라운드에서도 이탈 알림 판정"
+sleep 2
+expect_vibrated_since "$VIB" 650 900 TC-17 "백그라운드에서도 이탈 진동"
+if has_alert_notification; then record TC-18 "백그라운드면 상단 알림(헤드업)으로 표시" PASS "run_alert 알림 게시됨"
+else record TC-18 "백그라운드면 상단 알림(헤드업)으로 표시" FAIL "run_alert 알림 없음"; fi
+
+scenario back_on_route
+expect_within 10 "alert=NONE" TC-19 "백그라운드에서 코스 복귀 시 알림 해제"
+sleep 2
+if has_alert_notification; then record TC-20 "알림 해제 시 상단 알림도 제거" FAIL "run_alert 알림이 남아 있음"
+else record TC-20 "알림 해제 시 상단 알림도 제거" PASS "run_alert 알림 없음"; fi
+
+# ── 앱으로 복귀: 기록이 그대로 이어지는지 ──
+DIST_BG=$(state_field distM)
+"$ADB" shell monkey -p $PKG -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1; sleep 3
+REQUIRE_FOREGROUND=1
+DIST_FG=$(state_field distM)
+if "$ADB" shell dumpsys activity activities | grep -m1 topResumedActivity | grep -q "run.RunActivity" &&
+ [ "$DIST_FG" -ge "$DIST_BG" ]; then
+ record TC-21 "앱으로 돌아오면 러닝 화면과 기록이 그대로 이어짐" PASS "${DIST_BG}m → ${DIST_FG}m"
+else
+ record TC-21 "앱으로 돌아오면 러닝 화면과 기록이 그대로 이어짐" FAIL "${DIST_BG}m → ${DIST_FG}m"
+fi
+
+sim stop
+cleanup
+sleep 2
+for i in $(seq $REC_PART); do "$ADB" pull -q /sdcard/tc_part$i.mp4 "$OUT/tc_part$i.mp4"; done
+"$ADB" logcat -d -s RunSimState:I > "$OUT/run_sim_state.log"
+
+{
+ echo "| TC | 시나리오 | 결과 | 판정 근거 |"
+ echo "| --- | --- | --- | --- |"
+ printf '%s\n' "${RESULTS[@]}"
+ echo
+ echo "PASS $PASS / FAIL $FAIL"
+} > "$OUT/result.md"
+cat "$OUT/result.md"
+[ "$FAIL" -eq 0 ]