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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
26
app/src/main/java/com/radiola/domain/geo/GeoBlock.kt
Normal file
26
app/src/main/java/com/radiola/domain/geo/GeoBlock.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user