diff --git a/app/src/main/java/com/radiola/data/repository/RegionRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/RegionRepositoryImpl.kt new file mode 100644 index 0000000..6501c0d --- /dev/null +++ b/app/src/main/java/com/radiola/data/repository/RegionRepositoryImpl.kt @@ -0,0 +1,79 @@ +package com.radiola.data.repository + +import android.content.Context +import android.telephony.TelephonyManager +import android.util.Log +import com.radiola.domain.repository.RegionRepository +import com.radiola.domain.repository.SettingsRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Определяет страну пользователя по IP (для гео-фильтрации). Код кэшируется в + * настройках, так что после первого успешного запроса работает мгновенно и + * оффлайн. Источник — публичные гео-IP сервисы (без ключа). + */ +@Singleton +class RegionRepositoryImpl @Inject constructor( + private val okHttpClient: OkHttpClient, + private val settingsRepository: SettingsRepository, + @ApplicationContext private val context: Context +) : RegionRepository { + + override fun countryCode(): Flow = settingsRepository.getCountryCode() + + override suspend fun refresh() { + withContext(Dispatchers.IO) { + val code = fetchCountry() + Log.d("RegionRepo", "country=$code") + if (code != null) settingsRepository.setCountryCode(code) + } + } + + private fun fetchCountry(): String? { + // IP — приоритет (учитывает VPN: при VPN страна = выходного узла, и тогда + // украинские потоки доступны → не скрываем). + ipCountry()?.let { return it } + // Фолбэк, если IP-сервис недоступен (напр. заблокирован): страна SIM/сети/локали. + return deviceCountry() + } + + private fun ipCountry(): String? { + // 1) api.country.is → {"ip":"..","country":"RU"} + request("https://api.country.is/")?.let { body -> + Regex("\"country\"\\s*:\\s*\"([A-Za-z]{2})\"").find(body)?.groupValues?.get(1)?.let { + return it.uppercase() + } + } + // 2) ipapi.co/country/ → "RU" + request("https://ipapi.co/country/")?.trim()?.let { body -> + if (body.matches(Regex("[A-Za-z]{2}"))) return body.uppercase() + } + return null + } + + private fun deviceCountry(): String? = try { + val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + val net = tm?.networkCountryIso?.takeIf { it.isNotBlank() } + val sim = tm?.simCountryIso?.takeIf { it.isNotBlank() } + (net ?: sim ?: Locale.getDefault().country.takeIf { it.isNotBlank() })?.uppercase() + } catch (e: Exception) { + null + } + + private fun request(url: String): String? = try { + okHttpClient.newCall( + Request.Builder().url(url).header("User-Agent", "radiOLA").build() + ).execute().use { if (it.isSuccessful) it.body?.string() else null } + } catch (e: Exception) { + Log.w("RegionRepo", "geo fetch fail ($url): ${e.message}") + null + } +} diff --git a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt index b6100b9..0f682c8 100644 --- a/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt @@ -30,6 +30,7 @@ class SettingsRepositoryImpl @Inject constructor( private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset") private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled") private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate") + private val COUNTRY_CODE = stringPreferencesKey("country_code") } override fun getLastStationId(): Flow = dataStore.data.map { it[LAST_STATION_ID] } @@ -53,4 +54,7 @@ class SettingsRepositoryImpl @Inject constructor( override fun getPreferredBitrate(): Flow = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 } override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } } + + override fun getCountryCode(): Flow = dataStore.data.map { it[COUNTRY_CODE] } + override suspend fun setCountryCode(code: String) { dataStore.edit { it[COUNTRY_CODE] = code } } } diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index 0f02f84..cd10085 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -20,6 +20,7 @@ import com.radiola.data.repository.FavoritesRepositoryImpl import com.radiola.data.repository.LyricsRepositoryImpl import com.radiola.data.repository.NowPlayingRepositoryImpl import com.radiola.data.repository.RecordingRepositoryImpl +import com.radiola.data.repository.RegionRepositoryImpl import com.radiola.data.repository.SettingsRepositoryImpl import com.radiola.data.repository.StationRepositoryImpl import com.radiola.data.repository.SyncRepositoryImpl @@ -31,6 +32,7 @@ import com.radiola.domain.repository.LyricsRepository import com.radiola.domain.repository.SyncRepository import com.radiola.domain.repository.NowPlayingRepository import com.radiola.domain.repository.RecordingRepository +import com.radiola.domain.repository.RegionRepository import com.radiola.domain.repository.SettingsRepository import com.radiola.domain.repository.StationRepository import com.radiola.domain.repository.TrackHistoryRepository @@ -168,6 +170,10 @@ object AppModule { @Singleton fun provideSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository = impl + @Provides + @Singleton + fun provideRegionRepository(impl: RegionRepositoryImpl): RegionRepository = impl + @Provides @Singleton fun provideRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository = impl diff --git a/app/src/main/java/com/radiola/domain/geo/GeoBlock.kt b/app/src/main/java/com/radiola/domain/geo/GeoBlock.kt new file mode 100644 index 0000000..d5c47de --- /dev/null +++ b/app/src/main/java/com/radiola/domain/geo/GeoBlock.kt @@ -0,0 +1,26 @@ +package com.radiola.domain.geo + +import com.radiola.domain.model.Station + +/** + * Гео-ограничения станций. Украинские потоки (TavR Media: Radio ROKS, Kiss FM) + * не отдаются на российские IP (geoblock) — для пользователей из РФ их полностью + * скрываем (и сами станции, и их чипы-категории). При VPN (не-RU IP) — показываем, + * т.к. потоки тогда доступны. + */ +object GeoBlock { + // Хосты украинских станций. id 741 «Радио РОКС» (stream.roks.com) — российская, + // под это правило НЕ попадает. + private val UA_HOSTS = listOf("radioroks.ua", "kissfm.ua") + private val BLOCKED_COUNTRIES = setOf("RU") + + fun isUaStation(station: Station): Boolean = + UA_HOSTS.any { station.streamUrl.contains(it, ignoreCase = true) } + + /** Скрыта ли станция для пользователя из данной страны. */ + fun isHidden(station: Station, countryCode: String?): Boolean = + countryCode in BLOCKED_COUNTRIES && isUaStation(station) + + /** Нужно ли вообще скрывать украинские станции в этой стране. */ + fun shouldHideUa(countryCode: String?): Boolean = countryCode in BLOCKED_COUNTRIES +} diff --git a/app/src/main/java/com/radiola/domain/repository/RegionRepository.kt b/app/src/main/java/com/radiola/domain/repository/RegionRepository.kt new file mode 100644 index 0000000..70c651b --- /dev/null +++ b/app/src/main/java/com/radiola/domain/repository/RegionRepository.kt @@ -0,0 +1,12 @@ +package com.radiola.domain.repository + +import kotlinx.coroutines.flow.Flow + +/** Регион пользователя (по IP) — для гео-фильтрации станций. */ +interface RegionRepository { + /** Код страны пользователя (напр. "RU"), null пока не определён. */ + fun countryCode(): Flow + + /** Обновить код страны по IP (кэшируется). */ + suspend fun refresh() +} diff --git a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt index 2f11578..0235c0c 100644 --- a/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt @@ -17,4 +17,7 @@ interface SettingsRepository { // Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции). fun getPreferredBitrate(): Flow suspend fun setPreferredBitrate(bitrate: Int) + // Код страны пользователя (по IP), напр. "RU". null — не определён. + fun getCountryCode(): Flow + suspend fun setCountryCode(code: String) } diff --git a/app/src/main/java/com/radiola/domain/usecase/GetStationsUseCase.kt b/app/src/main/java/com/radiola/domain/usecase/GetStationsUseCase.kt index a1eda8c..6333b73 100644 --- a/app/src/main/java/com/radiola/domain/usecase/GetStationsUseCase.kt +++ b/app/src/main/java/com/radiola/domain/usecase/GetStationsUseCase.kt @@ -1,12 +1,23 @@ package com.radiola.domain.usecase +import com.radiola.domain.geo.GeoBlock import com.radiola.domain.model.Station +import com.radiola.domain.repository.RegionRepository import com.radiola.domain.repository.StationRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import javax.inject.Inject class GetStationsUseCase @Inject constructor( - private val stationRepository: StationRepository + private val stationRepository: StationRepository, + private val regionRepository: RegionRepository ) { - operator fun invoke(): Flow> = stationRepository.getStations() + // Гео-фильтр: для пользователей из РФ убираем недоступные украинские станции + // (Radio ROKS, Kiss FM) из всех мест, где используется список станций. + operator fun invoke(): Flow> = combine( + stationRepository.getStations(), + regionRepository.countryCode() + ) { stations, country -> + stations.filterNot { GeoBlock.isHidden(it, country) } + } } diff --git a/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt b/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt index d79b07b..c5a09d1 100644 --- a/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt +++ b/app/src/main/java/com/radiola/ui/stations/StationsViewModel.kt @@ -2,10 +2,12 @@ package com.radiola.ui.stations import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.radiola.domain.geo.GeoBlock import com.radiola.domain.model.Station import com.radiola.domain.model.Track import com.radiola.domain.repository.FavoritesRepository import com.radiola.domain.repository.NowPlayingRepository +import com.radiola.domain.repository.RegionRepository import com.radiola.domain.repository.StationRepository import com.radiola.domain.usecase.GetStationsUseCase import com.radiola.domain.usecase.PlayStationUseCase @@ -27,6 +29,7 @@ class StationsViewModel @Inject constructor( private val favoritesRepository: FavoritesRepository, private val stationRepository: StationRepository, private val nowPlayingRepository: NowPlayingRepository, + private val regionRepository: RegionRepository, private val playerController: PlayerController ) : ViewModel() { @@ -64,8 +67,20 @@ class StationsViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val tags: StateFlow> = stationRepository.getTags() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + // Чипы-жанры. Для пользователей из РФ убираем жанры украинских станций + // (Radio ROKS, Kiss FM) — их чипы не показываем вовсе. + val tags: StateFlow> = combine( + stationRepository.getTags(), + stationRepository.getStations(), + regionRepository.countryCode() + ) { tags, allStations, country -> + if (GeoBlock.shouldHideUa(country)) { + val uaGenres = allStations.filter { GeoBlock.isUaStation(it) }.map { it.genre }.toSet() + tags.filterNot { it in uaGenres } + } else { + tags + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val favoriteIds: StateFlow> = favoritesRepository.getFavorites() .map { list -> list.map { it.id }.toSet() } @@ -76,6 +91,8 @@ class StationsViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) init { + // Определяем страну пользователя по IP (для гео-фильтрации станций). + viewModelScope.launch { regionRepository.refresh() } viewModelScope.launch { _isLoading.value = true refreshStationsUseCase()