feat(stations): скрывать украинские станции (ROKS, Kiss FM) для РФ

Radio ROKS и Kiss FM (TavR Media, хосты radioroks.ua / kissfm.ua) недоступны
с российских IP без VPN. Теперь для пользователей из РФ они полностью скрыты
— и сами станции (везде, где используется список), и их чипы-категории.

Страна определяется по IP (api.country.is → ipapi.co; при VPN вернёт страну
выходного узла, тогда станции доступны и НЕ скрываются), с фолбэком на страну
SIM/сети/локали устройства, если IP-сервис недоступен (в РФ часто заблокирован).
Код страны кэшируется (DataStore). Фильтр в GetStationsUseCase (combine со
страной) + чипы в StationsViewModel. id 741 «Радио РОКС» (stream.roks.com) —
российская, под правило не попадает.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 14:46:42 +03:00
parent 4697e27eb4
commit 8d2c53c441
8 changed files with 162 additions and 4 deletions

View File

@@ -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<String?> = 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
}
}

View File

@@ -30,6 +30,7 @@ class SettingsRepositoryImpl @Inject constructor(
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset") private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled") private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate") private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
private val COUNTRY_CODE = stringPreferencesKey("country_code")
} }
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] } override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
@@ -53,4 +54,7 @@ class SettingsRepositoryImpl @Inject constructor(
override fun getPreferredBitrate(): Flow<Int> = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 } override fun getPreferredBitrate(): Flow<Int> = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 }
override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } } override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } }
override fun getCountryCode(): Flow<String?> = dataStore.data.map { it[COUNTRY_CODE] }
override suspend fun setCountryCode(code: String) { dataStore.edit { it[COUNTRY_CODE] = code } }
} }

View File

@@ -20,6 +20,7 @@ import com.radiola.data.repository.FavoritesRepositoryImpl
import com.radiola.data.repository.LyricsRepositoryImpl import com.radiola.data.repository.LyricsRepositoryImpl
import com.radiola.data.repository.NowPlayingRepositoryImpl import com.radiola.data.repository.NowPlayingRepositoryImpl
import com.radiola.data.repository.RecordingRepositoryImpl import com.radiola.data.repository.RecordingRepositoryImpl
import com.radiola.data.repository.RegionRepositoryImpl
import com.radiola.data.repository.SettingsRepositoryImpl import com.radiola.data.repository.SettingsRepositoryImpl
import com.radiola.data.repository.StationRepositoryImpl import com.radiola.data.repository.StationRepositoryImpl
import com.radiola.data.repository.SyncRepositoryImpl 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.SyncRepository
import com.radiola.domain.repository.NowPlayingRepository import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.RecordingRepository import com.radiola.domain.repository.RecordingRepository
import com.radiola.domain.repository.RegionRepository
import com.radiola.domain.repository.SettingsRepository import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository import com.radiola.domain.repository.StationRepository
import com.radiola.domain.repository.TrackHistoryRepository import com.radiola.domain.repository.TrackHistoryRepository
@@ -168,6 +170,10 @@ object AppModule {
@Singleton @Singleton
fun provideSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository = impl fun provideSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository = impl
@Provides
@Singleton
fun provideRegionRepository(impl: RegionRepositoryImpl): RegionRepository = impl
@Provides @Provides
@Singleton @Singleton
fun provideRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository = impl fun provideRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository = impl

View File

@@ -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
}

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.repository
import kotlinx.coroutines.flow.Flow
/** Регион пользователя (по IP) — для гео-фильтрации станций. */
interface RegionRepository {
/** Код страны пользователя (напр. "RU"), null пока не определён. */
fun countryCode(): Flow<String?>
/** Обновить код страны по IP (кэшируется). */
suspend fun refresh()
}

View File

@@ -17,4 +17,7 @@ interface SettingsRepository {
// Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции). // Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции).
fun getPreferredBitrate(): Flow<Int> fun getPreferredBitrate(): Flow<Int>
suspend fun setPreferredBitrate(bitrate: Int) suspend fun setPreferredBitrate(bitrate: Int)
// Код страны пользователя (по IP), напр. "RU". null — не определён.
fun getCountryCode(): Flow<String?>
suspend fun setCountryCode(code: String)
} }

View File

@@ -1,12 +1,23 @@
package com.radiola.domain.usecase package com.radiola.domain.usecase
import com.radiola.domain.geo.GeoBlock
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.domain.repository.RegionRepository
import com.radiola.domain.repository.StationRepository import com.radiola.domain.repository.StationRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject import javax.inject.Inject
class GetStationsUseCase @Inject constructor( class GetStationsUseCase @Inject constructor(
private val stationRepository: StationRepository private val stationRepository: StationRepository,
private val regionRepository: RegionRepository
) { ) {
operator fun invoke(): Flow<List<Station>> = stationRepository.getStations() // Гео-фильтр: для пользователей из РФ убираем недоступные украинские станции
// (Radio ROKS, Kiss FM) из всех мест, где используется список станций.
operator fun invoke(): Flow<List<Station>> = combine(
stationRepository.getStations(),
regionRepository.countryCode()
) { stations, country ->
stations.filterNot { GeoBlock.isHidden(it, country) }
}
} }

View File

@@ -2,10 +2,12 @@ package com.radiola.ui.stations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.radiola.domain.geo.GeoBlock
import com.radiola.domain.model.Station import com.radiola.domain.model.Station
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.domain.repository.FavoritesRepository import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.NowPlayingRepository import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.RegionRepository
import com.radiola.domain.repository.StationRepository import com.radiola.domain.repository.StationRepository
import com.radiola.domain.usecase.GetStationsUseCase import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.PlayStationUseCase import com.radiola.domain.usecase.PlayStationUseCase
@@ -27,6 +29,7 @@ class StationsViewModel @Inject constructor(
private val favoritesRepository: FavoritesRepository, private val favoritesRepository: FavoritesRepository,
private val stationRepository: StationRepository, private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository, private val nowPlayingRepository: NowPlayingRepository,
private val regionRepository: RegionRepository,
private val playerController: PlayerController private val playerController: PlayerController
) : ViewModel() { ) : ViewModel() {
@@ -64,8 +67,20 @@ class StationsViewModel @Inject constructor(
} }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val tags: StateFlow<List<String>> = stationRepository.getTags() // Чипы-жанры. Для пользователей из РФ убираем жанры украинских станций
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) // (Radio ROKS, Kiss FM) — их чипы не показываем вовсе.
val tags: StateFlow<List<String>> = 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<Set<Int>> = favoritesRepository.getFavorites() val favoriteIds: StateFlow<Set<Int>> = favoritesRepository.getFavorites()
.map { list -> list.map { it.id }.toSet() } .map { list -> list.map { it.id }.toSet() }
@@ -76,6 +91,8 @@ class StationsViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init { init {
// Определяем страну пользователя по IP (для гео-фильтрации станций).
viewModelScope.launch { regionRepository.refresh() }
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
refreshStationsUseCase() refreshStationsUseCase()