Add logging throughout the Android app and unit tests

Log.d/i/w/e calls covering location resolution, TTS lifecycle and
errors, nearby/search network calls and their failure paths, and
screen-level state transitions. OkHttp's logging interceptor now
routes through Log (tag "OkHttp") instead of println for consistent
filtering. Enabled testOptions.unitTests.isReturnDefaultValues so
android.util.Log calls don't crash plain JVM unit tests.

Added mockk + kotlinx-coroutines-test and a GuideViewModelTest suite
covering the load-success, load-failure, and retry paths — including
a regression test for the infinite-spinner bug (isLoading must clear
and errorMessage must be set on a failed nearby-search call, not left
hanging). Verified: ./gradlew testDebugUnitTest passes (4/4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-09 20:10:31 +00:00
parent be67902b45
commit 6e90956b3e
17 changed files with 323 additions and 18 deletions

View file

@ -67,6 +67,14 @@ android {
compose = true compose = true
buildConfig = true buildConfig = true
} }
testOptions {
unitTests {
// Production code calls android.util.Log for observability; without this,
// the Android stub jar throws on every such call in plain JVM unit tests.
isReturnDefaultValues = true
}
}
} }
dependencies { dependencies {
@ -107,6 +115,8 @@ dependencies {
// isolated integration point in the meantime. // isolated integration point in the meantime.
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View file

@ -1,7 +1,15 @@
package com.guidecity.app package com.guidecity.app
import android.app.Application import android.app.Application
import android.util.Log
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
private const val TAG = "GuideCityApp"
@HiltAndroidApp @HiltAndroidApp
class GuideCityApp : Application() class GuideCityApp : Application() {
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Application created (debug=${BuildConfig.DEBUG}, apiBaseUrl=${BuildConfig.API_BASE_URL})")
}
}

View file

@ -1,6 +1,7 @@
package com.guidecity.app package com.guidecity.app
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -8,10 +9,13 @@ import com.guidecity.app.navigation.GuideCityNavHost
import com.guidecity.app.theme.GuideCityTheme import com.guidecity.app.theme.GuideCityTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
private const val TAG = "MainActivity"
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate")
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
GuideCityTheme { GuideCityTheme {

View file

@ -1,5 +1,6 @@
package com.guidecity.app.data.remote package com.guidecity.app.data.remote
import android.util.Log
import com.guidecity.app.BuildConfig import com.guidecity.app.BuildConfig
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -8,6 +9,8 @@ import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
private const val TAG = "OkHttp"
object RetrofitClient { object RetrofitClient {
private val json = Json { private val json = Json {
@ -18,7 +21,7 @@ object RetrofitClient {
private val okHttpClient: OkHttpClient by lazy { private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder() OkHttpClient.Builder()
.addInterceptor( .addInterceptor(
HttpLoggingInterceptor().apply { HttpLoggingInterceptor { message -> Log.d(TAG, message) }.apply {
level = if (BuildConfig.DEBUG) { level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY HttpLoggingInterceptor.Level.BODY
} else { } else {
@ -30,6 +33,7 @@ object RetrofitClient {
} }
val apiService: ApiService by lazy { val apiService: ApiService by lazy {
Log.i(TAG, "creating Retrofit client, baseUrl=${BuildConfig.API_BASE_URL}")
Retrofit.Builder() Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient) .client(okHttpClient)

View file

@ -4,6 +4,7 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
@ -13,6 +14,8 @@ import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "LocationProvider"
data class LatLon(val lat: Double, val lon: Double) data class LatLon(val lat: Double, val lon: Double)
@Singleton @Singleton
@ -26,18 +29,34 @@ class LocationProvider @Inject constructor(
fun hasLocationPermission(): Boolean { fun hasLocationPermission(): Boolean {
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED val granted = fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED
Log.d(TAG, "hasLocationPermission: fine=$fine coarse=$coarse -> $granted")
return granted
} }
/** Returns null if permission isn't granted or no location could be resolved. */ /** Returns null if permission isn't granted or no location could be resolved. */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
suspend fun getCurrentLocation(): LatLon? { suspend fun getCurrentLocation(): LatLon? {
if (!hasLocationPermission()) return null if (!hasLocationPermission()) {
Log.w(TAG, "getCurrentLocation: permission not granted, returning null")
return null
}
val current = fusedClient return try {
.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) val current = fusedClient
.await() .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
val location = current ?: fusedClient.lastLocation.await() .await()
return location?.let { LatLon(it.latitude, it.longitude) } val location = current ?: fusedClient.lastLocation.await()
if (location == null) {
Log.w(TAG, "getCurrentLocation: no location available from fused client (current or last)")
null
} else {
Log.i(TAG, "getCurrentLocation: resolved lat=${location.latitude} lon=${location.longitude}")
LatLon(location.latitude, location.longitude)
}
} catch (e: Exception) {
Log.e(TAG, "getCurrentLocation failed", e)
null
}
} }
} }

View file

@ -3,12 +3,15 @@ package com.guidecity.app.tts
import android.content.Context import android.content.Context
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "TtsManager"
/** /**
* Wraps Android's built-in, on-device [TextToSpeech] engine this satisfies * Wraps Android's built-in, on-device [TextToSpeech] engine this satisfies
* the app's "local voice narration" requirement without a custom ML model. * the app's "local voice narration" requirement without a custom ML model.
@ -32,29 +35,44 @@ class TtsManager @Inject constructor(
private val tts: TextToSpeech = TextToSpeech(context) { status -> private val tts: TextToSpeech = TextToSpeech(context) { status ->
isReady = status == TextToSpeech.SUCCESS isReady = status == TextToSpeech.SUCCESS
pendingUtterance?.let { (text, onDone) -> speakInternal(text, onDone) } if (!isReady) {
Log.e(TAG, "TextToSpeech engine init failed, status=$status")
} else {
Log.i(TAG, "TextToSpeech engine ready")
}
pendingUtterance?.let { (text, onDone) ->
Log.d(TAG, "flushing pending utterance queued before engine was ready")
speakInternal(text, onDone)
}
pendingUtterance = null pendingUtterance = null
}.apply { }.apply {
setOnUtteranceProgressListener( setOnUtteranceProgressListener(
object : UtteranceProgressListener() { object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit override fun onStart(utteranceId: String?) {
Log.d(TAG, "utterance started: $utteranceId")
}
override fun onDone(utteranceId: String?) { override fun onDone(utteranceId: String?) {
Log.d(TAG, "utterance done: $utteranceId")
onDoneCallback?.invoke() onDoneCallback?.invoke()
} }
@Deprecated("Deprecated in Java, but still the callback the platform invokes") @Deprecated("Deprecated in Java, but still the callback the platform invokes")
override fun onError(utteranceId: String?) = Unit override fun onError(utteranceId: String?) {
Log.e(TAG, "utterance error: $utteranceId")
}
}, },
) )
} }
fun setLanguage(locale: Locale) { fun setLanguage(locale: Locale) {
tts.language = locale val result = tts.setLanguage(locale)
Log.d(TAG, "setLanguage($locale) -> $result")
} }
/** Speaks [text], calling [onDone] on the main thread once narration finishes. */ /** Speaks [text], calling [onDone] on the main thread once narration finishes. */
fun speak(text: String, onDone: (() -> Unit)? = null) { fun speak(text: String, onDone: (() -> Unit)? = null) {
Log.d(TAG, "speak() called, muted=$isMuted ready=$isReady length=${text.length}")
lastSpokenText = text lastSpokenText = text
onDoneCallback = onDone onDoneCallback = onDone
if (isMuted) return if (isMuted) return
@ -67,26 +85,33 @@ class TtsManager @Inject constructor(
private fun speakInternal(text: String, onDone: (() -> Unit)?) { private fun speakInternal(text: String, onDone: (() -> Unit)?) {
onDoneCallback = onDone onDoneCallback = onDone
tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString()) val result = tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, UUID.randomUUID().toString())
if (result != TextToSpeech.SUCCESS) {
Log.e(TAG, "tts.speak() returned error code $result")
}
} }
/** Stops narration and suppresses further [speak] calls until [unmute]. */ /** Stops narration and suppresses further [speak] calls until [unmute]. */
fun mute() { fun mute() {
Log.d(TAG, "mute()")
isMuted = true isMuted = true
tts.stop() tts.stop()
} }
/** Resumes narration from the start of the last spoken text. */ /** Resumes narration from the start of the last spoken text. */
fun unmute() { fun unmute() {
Log.d(TAG, "unmute()")
isMuted = false isMuted = false
lastSpokenText?.let { speakInternal(it, onDoneCallback) } lastSpokenText?.let { speakInternal(it, onDoneCallback) }
} }
fun stop() { fun stop() {
Log.d(TAG, "stop()")
tts.stop() tts.stop()
} }
fun shutdown() { fun shutdown() {
Log.d(TAG, "shutdown()")
tts.shutdown() tts.shutdown()
} }
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.detail package com.guidecity.app.ui.detail
import android.util.Log
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -20,6 +21,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "PlaceDetailViewModel"
data class PlaceDetailUiState( data class PlaceDetailUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val place: PlaceDetailDto? = null, val place: PlaceDetailDto? = null,
@ -52,9 +55,13 @@ class PlaceDetailViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
init { init {
Log.d(TAG, "init: placeId=$placeId")
viewModelScope.launch { viewModelScope.launch {
val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue() val length = (userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM).toApiValue()
val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }.getOrNull() val place = runCatching { placesRepository.getPlace(placeId, lang = "ru", length = length) }
.onFailure { Log.e(TAG, "getPlace($placeId) failed", it) }
.getOrNull()
Log.d(TAG, "loaded place=${place?.slug}")
_uiState.value = _uiState.value.copy(isLoading = false, place = place) _uiState.value = _uiState.value.copy(isLoading = false, place = place)
place?.let { ttsManager.speak(it.content.body) } place?.let { ttsManager.speak(it.content.body) }
} }
@ -62,11 +69,13 @@ class PlaceDetailViewModel @Inject constructor(
fun toggleMute() { fun toggleMute() {
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
} }
fun toggleFavorite() { fun toggleFavorite() {
val place = _uiState.value.place ?: return val place = _uiState.value.place ?: return
Log.d(TAG, "toggleFavorite: placeId=$placeId currentlyFavorite=${isFavorite.value}")
viewModelScope.launch { viewModelScope.launch {
if (isFavorite.value) { if (isFavorite.value) {
favoritesRepository.removeFavorite(placeId) favoritesRepository.removeFavorite(placeId)

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.favorites package com.guidecity.app.ui.favorites
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.db.FavoritePlaceEntity import com.guidecity.app.data.local.db.FavoritePlaceEntity
@ -7,13 +8,17 @@ import com.guidecity.app.data.repository.FavoritesRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "FavoritesViewModel"
@HiltViewModel @HiltViewModel
class FavoritesViewModel @Inject constructor( class FavoritesViewModel @Inject constructor(
favoritesRepository: FavoritesRepository, favoritesRepository: FavoritesRepository,
) : ViewModel() { ) : ViewModel() {
val favorites: StateFlow<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites() val favorites: StateFlow<List<FavoritePlaceEntity>> = favoritesRepository.observeFavorites()
.onEach { Log.d(TAG, "favorites updated: ${it.size} item(s)") }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.guide package com.guidecity.app.ui.guide
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.prefs.ContentLength import com.guidecity.app.data.local.prefs.ContentLength
@ -20,6 +21,8 @@ import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "GuideViewModel"
data class GuideUiState( data class GuideUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val places: List<NearbyPlaceDto> = emptyList(), val places: List<NearbyPlaceDto> = emptyList(),
@ -54,6 +57,7 @@ class GuideViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM contentLength = userPrefsDataStore.contentLength.first() ?: ContentLength.MEDIUM
Log.i(TAG, "init: contentLength=$contentLength")
ttsManager.setLanguage(Locale("ru")) ttsManager.setLanguage(Locale("ru"))
loadNearby(startNarration = true) loadNearby(startNarration = true)
startRescanLoop() startRescanLoop()
@ -63,9 +67,11 @@ class GuideViewModel @Inject constructor(
private fun startRescanLoop() { private fun startRescanLoop() {
if (rescanStarted) return if (rescanStarted) return
rescanStarted = true rescanStarted = true
Log.d(TAG, "starting 60s re-scan loop")
viewModelScope.launch { viewModelScope.launch {
while (isActive) { while (isActive) {
delay(60_000) delay(60_000)
Log.d(TAG, "re-scan tick")
loadNearby(startNarration = false) loadNearby(startNarration = false)
} }
} }
@ -74,10 +80,13 @@ class GuideViewModel @Inject constructor(
private suspend fun loadNearby(startNarration: Boolean) { private suspend fun loadNearby(startNarration: Boolean) {
val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation() val location = selectedLocationHolder.location.value ?: locationProvider.getCurrentLocation()
if (location == null) { if (location == null) {
Log.w(TAG, "loadNearby: no location available")
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable") _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Location unavailable")
return return
} }
Log.d(TAG, "loadNearby: lat=${location.lat} lon=${location.lon} length=${contentLength.toApiValue()}")
runCatching { runCatching {
placesRepository.getNearby( placesRepository.getNearby(
lat = location.lat, lat = location.lat,
@ -87,6 +96,7 @@ class GuideViewModel @Inject constructor(
length = contentLength.toApiValue(), length = contentLength.toApiValue(),
) )
}.onSuccess { response -> }.onSuccess { response ->
Log.i(TAG, "loadNearby success: ${response.places.size} place(s), radius=${response.searchRadiusM}m")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
places = response.places, places = response.places,
@ -98,6 +108,7 @@ class GuideViewModel @Inject constructor(
narrateActive() narrateActive()
} }
}.onFailure { error -> }.onFailure { error ->
Log.e(TAG, "loadNearby failed", error)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
errorMessage = error.message ?: "Network error", errorMessage = error.message ?: "Network error",
@ -107,6 +118,7 @@ class GuideViewModel @Inject constructor(
/** Retries after a failed load (e.g. tap a "retry" button shown on error). */ /** Retries after a failed load (e.g. tap a "retry" button shown on error). */
fun retry() { fun retry() {
Log.d(TAG, "retry() called")
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
loadNearby(startNarration = true) loadNearby(startNarration = true)
@ -116,13 +128,16 @@ class GuideViewModel @Inject constructor(
private fun narrateActive() { private fun narrateActive() {
val state = _uiState.value val state = _uiState.value
val place = state.places.getOrNull(state.activeIndex) ?: return val place = state.places.getOrNull(state.activeIndex) ?: return
Log.d(TAG, "narrateActive: index=${state.activeIndex} placeId=${place.id} slug=${place.slug}")
ttsManager.speak(place.content.body) { onNarrationDone() } ttsManager.speak(place.content.body) { onNarrationDone() }
} }
private fun onNarrationDone() { private fun onNarrationDone() {
val state = _uiState.value val state = _uiState.value
val nextIndex = state.activeIndex + 1 val nextIndex = state.activeIndex + 1
Log.d(TAG, "onNarrationDone: finished index=${state.activeIndex}, nextIndex=$nextIndex")
if (nextIndex >= state.places.size) { if (nextIndex >= state.places.size) {
Log.i(TAG, "onNarrationDone: reached end of nearby list")
_uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex) _uiState.value = state.copy(doneIndices = state.doneIndices + state.activeIndex)
return return
} }
@ -137,17 +152,20 @@ class GuideViewModel @Inject constructor(
fun setActiveIndex(index: Int) { fun setActiveIndex(index: Int) {
val state = _uiState.value val state = _uiState.value
if (index !in state.places.indices || index == state.activeIndex) return if (index !in state.places.indices || index == state.activeIndex) return
Log.d(TAG, "setActiveIndex: ${state.activeIndex} -> $index (manual swipe)")
_uiState.value = state.copy(activeIndex = index) _uiState.value = state.copy(activeIndex = index)
narrateActive() narrateActive()
} }
fun toggleMute() { fun toggleMute() {
if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute() if (ttsManager.isMuted) ttsManager.unmute() else ttsManager.mute()
Log.d(TAG, "toggleMute: isMuted=${ttsManager.isMuted}")
_uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted) _uiState.value = _uiState.value.copy(isMuted = ttsManager.isMuted)
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
Log.d(TAG, "onCleared")
ttsManager.stop() ttsManager.stop()
} }
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.map package com.guidecity.app.ui.map
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.remote.dto.PlaceListItemDto import com.guidecity.app.data.remote.dto.PlaceListItemDto
@ -14,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "MapViewModel"
sealed interface MapUiState { sealed interface MapUiState {
data object CheckingPermission : MapUiState data object CheckingPermission : MapUiState
data object NeedsPermission : MapUiState data object NeedsPermission : MapUiState
@ -35,7 +38,9 @@ class MapViewModel @Inject constructor(
val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow() val searchResults: StateFlow<List<PlaceListItemDto>> = _searchResults.asStateFlow()
fun checkInitialPermission(onLocationResolved: () -> Unit) { fun checkInitialPermission(onLocationResolved: () -> Unit) {
if (locationProvider.hasLocationPermission()) { val hasPermission = locationProvider.hasLocationPermission()
Log.d(TAG, "checkInitialPermission: hasPermission=$hasPermission")
if (hasPermission) {
resolveLocation(onLocationResolved) resolveLocation(onLocationResolved)
} else { } else {
_uiState.value = MapUiState.NeedsPermission _uiState.value = MapUiState.NeedsPermission
@ -43,6 +48,7 @@ class MapViewModel @Inject constructor(
} }
fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) { fun onPermissionResult(granted: Boolean, onLocationResolved: () -> Unit) {
Log.i(TAG, "onPermissionResult: granted=$granted")
if (granted) { if (granted) {
resolveLocation(onLocationResolved) resolveLocation(onLocationResolved)
} else { } else {
@ -55,9 +61,11 @@ class MapViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val location = locationProvider.getCurrentLocation() val location = locationProvider.getCurrentLocation()
if (location != null) { if (location != null) {
Log.i(TAG, "resolveLocation: got lat=${location.lat} lon=${location.lon}")
selectedLocationHolder.set(location) selectedLocationHolder.set(location)
onLocationResolved() onLocationResolved()
} else { } else {
Log.w(TAG, "resolveLocation: location unavailable, falling back to search")
_uiState.value = MapUiState.SearchFallback _uiState.value = MapUiState.SearchFallback
} }
} }
@ -68,14 +76,22 @@ class MapViewModel @Inject constructor(
_searchResults.value = emptyList() _searchResults.value = emptyList()
return return
} }
Log.d(TAG, "searchPlaces: query=$query")
viewModelScope.launch { viewModelScope.launch {
_searchResults.value = runCatching { runCatching {
placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru") placesRepository.searchPlaces(query, citySlug = "moscow", lang = "ru")
}.getOrDefault(emptyList()) }.onSuccess { results ->
Log.d(TAG, "searchPlaces: ${results.size} result(s) for query=$query")
_searchResults.value = results
}.onFailure { error ->
Log.e(TAG, "searchPlaces failed for query=$query", error)
_searchResults.value = emptyList()
}
} }
} }
fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) { fun selectSearchResult(place: PlaceListItemDto, onLocationResolved: () -> Unit) {
Log.i(TAG, "selectSearchResult: placeId=${place.id} slug=${place.slug}")
selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon)) selectedLocationHolder.set(LatLon(place.location.lat, place.location.lon))
onLocationResolved() onLocationResolved()
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.onboarding package com.guidecity.app.ui.onboarding
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.prefs.UserPrefsDataStore import com.guidecity.app.data.local.prefs.UserPrefsDataStore
@ -7,12 +8,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "OnboardingViewModel"
@HiltViewModel @HiltViewModel
class OnboardingViewModel @Inject constructor( class OnboardingViewModel @Inject constructor(
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
) : ViewModel() { ) : ViewModel() {
fun markOnboardingSeen(onDone: () -> Unit) { fun markOnboardingSeen(onDone: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
Log.i(TAG, "markOnboardingSeen")
userPrefsDataStore.setOnboardingSeen(true) userPrefsDataStore.setOnboardingSeen(true)
onDone() onDone()
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.preference package com.guidecity.app.ui.preference
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.prefs.ContentLength import com.guidecity.app.data.local.prefs.ContentLength
@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "ContentLengthViewModel"
@HiltViewModel @HiltViewModel
class ContentLengthViewModel @Inject constructor( class ContentLengthViewModel @Inject constructor(
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
@ -20,11 +23,13 @@ class ContentLengthViewModel @Inject constructor(
val selected: StateFlow<ContentLength> = _selected.asStateFlow() val selected: StateFlow<ContentLength> = _selected.asStateFlow()
fun select(length: ContentLength) { fun select(length: ContentLength) {
Log.d(TAG, "select: $length")
_selected.value = length _selected.value = length
} }
fun confirmSelection(onDone: () -> Unit) { fun confirmSelection(onDone: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
Log.i(TAG, "confirmSelection: ${_selected.value}")
userPrefsDataStore.setContentLength(_selected.value) userPrefsDataStore.setContentLength(_selected.value)
onDone() onDone()
} }

View file

@ -1,5 +1,6 @@
package com.guidecity.app.ui.settings package com.guidecity.app.ui.settings
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.guidecity.app.data.local.prefs.ContentLength import com.guidecity.app.data.local.prefs.ContentLength
@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "SettingsViewModel"
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val userPrefsDataStore: UserPrefsDataStore, private val userPrefsDataStore: UserPrefsDataStore,
@ -19,6 +22,7 @@ class SettingsViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun setContentLength(length: ContentLength) { fun setContentLength(length: ContentLength) {
Log.i(TAG, "setContentLength: $length")
viewModelScope.launch { userPrefsDataStore.setContentLength(length) } viewModelScope.launch { userPrefsDataStore.setContentLength(length) }
} }
} }

View file

@ -0,0 +1,24 @@
package com.guidecity.app
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/** Swaps `Dispatchers.Main` for a test dispatcher so `viewModelScope` coroutines run synchronously. */
@ExperimentalCoroutinesApi
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

View file

@ -0,0 +1,14 @@
package com.guidecity.app.data.local.prefs
import org.junit.Assert.assertEquals
import org.junit.Test
class ContentLengthTest {
@Test
fun `toApiValue lowercases the enum name`() {
assertEquals("short", ContentLength.SHORT.toApiValue())
assertEquals("medium", ContentLength.MEDIUM.toApiValue())
assertEquals("long", ContentLength.LONG.toApiValue())
}
}

View file

@ -0,0 +1,132 @@
package com.guidecity.app.ui.guide
import androidx.lifecycle.viewModelScope
import com.guidecity.app.MainDispatcherRule
import com.guidecity.app.data.local.prefs.ContentLength
import com.guidecity.app.data.local.prefs.UserPrefsDataStore
import com.guidecity.app.data.remote.dto.ContentDto
import com.guidecity.app.data.remote.dto.LatLonDto
import com.guidecity.app.data.remote.dto.NearbyPlaceDto
import com.guidecity.app.data.remote.dto.NearbyResponseDto
import com.guidecity.app.data.repository.PlacesRepository
import com.guidecity.app.location.LatLon
import com.guidecity.app.location.LocationProvider
import com.guidecity.app.location.SelectedLocationHolder
import com.guidecity.app.tts.TtsManager
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.io.IOException
/**
* Covers the loadNearby() success/failure/retry paths in particular a
* regression test for a real bug we hit: on a failed nearby-search call, the
* screen used to stay stuck on the loading spinner forever because the
* early-return path never cleared `isLoading`. See GuideViewModel.loadNearby.
*/
class GuideViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testLocation = LatLon(lat = 55.75, lon = 37.62)
private fun fakePlace(id: Int): NearbyPlaceDto = NearbyPlaceDto(
id = id,
slug = "place-$id",
category = "monument",
location = LatLonDto(lat = 55.75, lon = 37.62),
content = ContentDto(language = "ru", length = "medium", title = "Title $id", body = "Body $id"),
distanceM = 10.0 * id,
)
private fun buildViewModel(
placesRepository: PlacesRepository,
ttsManager: TtsManager = mockk(relaxed = true),
): GuideViewModel {
val locationProvider = mockk<LocationProvider>(relaxed = true)
val selectedLocationHolder = mockk<SelectedLocationHolder>()
every { selectedLocationHolder.location } returns MutableStateFlow(testLocation)
val userPrefsDataStore = mockk<UserPrefsDataStore>()
every { userPrefsDataStore.contentLength } returns flowOf(ContentLength.MEDIUM)
return GuideViewModel(
placesRepository = placesRepository,
locationProvider = locationProvider,
selectedLocationHolder = selectedLocationHolder,
userPrefsDataStore = userPrefsDataStore,
ttsManager = ttsManager,
)
}
@Test
fun `successful load populates places and starts narration`() = runTest {
val placesRepository = mockk<PlacesRepository>()
val response = NearbyResponseDto(searchRadiusM = 300, count = 2, places = listOf(fakePlace(1), fakePlace(2)))
coEvery {
placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any())
} returns response
val ttsManager = mockk<TtsManager>(relaxed = true)
val viewModel = buildViewModel(placesRepository, ttsManager)
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertEquals(2, state.places.size)
assertNull(state.errorMessage)
verify { ttsManager.speak(any(), any()) }
viewModel.viewModelScope.cancel()
}
@Test
fun `failed load clears the spinner and surfaces an error instead of hanging forever`() = runTest {
val placesRepository = mockk<PlacesRepository>()
coEvery {
placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any())
} throws IOException("Cleartext HTTP traffic not permitted")
val viewModel = buildViewModel(placesRepository)
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertTrue(state.places.isEmpty())
assertNotNull(state.errorMessage)
viewModel.viewModelScope.cancel()
}
@Test
fun `retry after a failure can succeed and clears the error`() = runTest {
val placesRepository = mockk<PlacesRepository>()
val response = NearbyResponseDto(searchRadiusM = 300, count = 1, places = listOf(fakePlace(1)))
coEvery {
placesRepository.getNearby(lat = any(), lon = any(), citySlug = any(), lang = any(), length = any())
} throws IOException("network down") andThen response
val viewModel = buildViewModel(placesRepository)
assertNotNull(viewModel.uiState.value.errorMessage)
viewModel.retry()
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertNull(state.errorMessage)
assertEquals(1, state.places.size)
viewModel.viewModelScope.cancel()
}
}

View file

@ -19,6 +19,8 @@ ksp = "2.0.20-1.0.25"
junit = "4.13.2" junit = "4.13.2"
androidxTestExtJunit = "1.2.1" androidxTestExtJunit = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.6.1"
mockk = "1.13.12"
kotlinxCoroutinesTest = "1.8.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -57,6 +59,8 @@ androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }