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 ]