22 Commits

Author SHA1 Message Date
nk
55a380d34b chore(app): bump 9 / 1.8 (фикс восстановления потока после смены сети) 2026-06-11 23:25:36 +03:00
nk
75d256eda5 fix(player): восстановление потока после смены/пропажи сети (машина, туннели)
Причина «падений через 10-15 мин в машине»: при смене сети (Wi-Fi↔LTE) или долгом
обрыве поток рвался, а переподключение СДАВАЛОСЬ навсегда после 10 попыток (~100с),
и не было реакции на возврат сети → радио не оживало, foreground-сервис отваливался,
процесс убивала система (это выглядело как «падение», хотя крэша не было).

- scheduleReconnect больше не сдаётся: переподключается, пока пользователь хочет
  играть (флаг intendedToPlay; пауза/стоп его снимают).
- Добавлен ConnectivityManager.registerDefaultNetworkCallback: при возврате/смене
  сети мгновенно re-prepare потока, не дожидаясь бэк-оффа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:22:39 +03:00
nk
1771a5b975 chore(repo): игнор tempfiles/, фикс дизайн-файла и ассетов RuStore
- tempfiles/ в .gitignore (скрэтч: картинки, HTML-эксперименты, мокапы),
  кроме tempfiles/radiOLA.pen — дизайн-файл остаётся под версией
- коммит изменений radiOLA.pen
- docs/rustore-listing.md и design/logos/*.html — ассеты карточки RuStore

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:48 +03:00
nk
91777fc459 feat(background): подсказка ColorOS «Разрешить работу в фоне» + bump 8/1.7
На Oppo/OnePlus/Realme стандартного исключения из оптимизации батареи мало —
система схлопывает приложение в фоне. Один раз показываем пояснение и открываем
настройки приложения, чтобы юзер включил «Разрешить работу в фоновом режиме».
2026-06-11 14:46:06 +03:00
nk
7a00f53b20 fix(now-playing): мгновенный рефреш эфира при возврате из фона + восстановление сессии
Опрос now-playing был привязан к play()/жизни ViewModel — при заморозке ColorOS
в фоне или пересоздании ViewModel трек «застывал». Теперь: startNowPlaying()
с мгновенным refresh, восстановление привязки к играющей станции из
PlayerController.currentStationId, и onAppForeground() на ON_RESUME.
2026-06-10 18:05:32 +03:00
nk
3c7ae1eb4c fix(deeplink): объявить видимость пакета re.sova.five (Android 11+ queries) 2026-06-08 14:52:05 +03:00
nk
d31e5d1119 chore(app): bump версии до 7 / 1.6 (RuStore-релиз) 2026-06-08 14:45:36 +03:00
nk
ab09d92b0d feat(deeplink): кнопка поиска в SOVA (re.sova.five, только sideload) 2026-06-08 14:41:42 +03:00
nk
c75ff8cb9a build(app): релизная подпись из keystore.properties (Задача 6) 2026-06-08 14:26:02 +03:00
nk
9e729512e9 build: игнорировать keystore.properties и *.jks (релизные секреты) 2026-06-08 14:06:26 +03:00
nk
92a7c614c1 feat(deeplink): прямое открытие в пакете стороннего сервиса (packageName) 2026-06-08 14:05:59 +03:00
nk
cbd6451ee0 refactor(settings): убрать осиротевший тумблер записи; тестер под SHOW_DEV_TOOLS 2026-06-08 14:05:11 +03:00
nk
6a21a84b86 build(app): REQUEST_INSTALL_PACKAGES только в sideload 2026-06-08 14:03:21 +03:00
nk
8dc0d46c40 feat(app): апдейтер только в sideload; API/BASE_URL на https 2026-06-08 14:03:21 +03:00
nk
34bd6ab02e build(app): product flavors store/sideload + BuildConfig флаги 2026-06-08 14:03:21 +03:00
nk
86b39f9fea chore: bump backend submodule (privacy) 2026-06-08 13:58:13 +03:00
nk
dbc99bcb10 docs: план реализации подготовки к RuStore 2026-06-08 13:45:21 +03:00
nk
6159cc13cc docs(rustore-spec): добавить кнопку SOVA (дип-линк, только sideload) 2026-06-08 13:38:23 +03:00
nk
56d96382fa docs(rustore-spec): убрать тумблер записи (мёртвый) + тестер станций под флаг store 2026-06-08 13:33:05 +03:00
nk
4391f3ec33 docs: дизайн-спек подготовки к публикации в RuStore 2026-06-08 09:47:10 +03:00
nk
bb40d26621 chore: bump backend submodule (compose: конфиг авто-обновления) 2026-06-07 19:27:36 +03:00
nk
9828bdf8d1 chore(app): bump версии до 6 / 1.5 (релиз: распознавание Shazam + история) 2026-06-07 19:22:06 +03:00
26 changed files with 6461 additions and 365 deletions

10
.gitignore vendored
View File

@@ -26,6 +26,16 @@ app/build/
# Kotlin # Kotlin
.kotlin/ .kotlin/
# Релизная подпись (секреты — никогда в git)
keystore.properties
*.jks
*.keystore
# Логотип: промежуточные генерации (тяжёлые), ключ — вне репо # Логотип: промежуточные генерации (тяжёлые), ключ — вне репо
design/logos/gen/ design/logos/gen/
design/logos/ref_*.png design/logos/ref_*.png
# Скрэтч-папка (картинки, HTML-эксперименты, мокапы RuStore) — не версионируем
tempfiles/
# ...кроме дизайн-файла Pencil — он остаётся под версией
!tempfiles/radiOLA.pen

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -7,6 +9,12 @@ plugins {
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
} }
// Релизная подпись: пароли/путь к keystore — в keystore.properties (в .gitignore).
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) keystorePropsFile.inputStream().use { load(it) }
}
android { android {
namespace = "com.radiola" namespace = "com.radiola"
compileSdk = 34 compileSdk = 34
@@ -15,8 +23,8 @@ android {
applicationId = "com.radiola" applicationId = "com.radiola"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 5 versionCode = 9
versionName = "1.4" versionName = "1.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -24,8 +32,20 @@ android {
} }
} }
signingConfigs {
create("release") {
if (keystorePropsFile.exists()) {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes { buildTypes {
release { release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -33,6 +53,22 @@ android {
) )
} }
} }
// Каналы дистрибуции: store — для RuStore (без авто-апдейтера, без dev-тестера
// и кнопки SOVA); sideload — прямой APK с авто-обновлением.
flavorDimensions += "distribution"
productFlavors {
create("store") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "false")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "false")
}
create("sideload") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "true")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "true")
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17

View File

@@ -11,8 +11,6 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" /> <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Авто-обновление: установка скачанного APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране <!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). --> (иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />

View File

@@ -109,6 +109,19 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState() val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false) val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
// Возврат на передний план → мгновенно освежаем now-playing
// (фоновая заморозка ColorOS может останавливать опрос эфира).
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
playerViewModel.onAppForeground()
}
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
// --- Авто-обновление: проверяем версию на старте, показываем диалог --- // --- Авто-обновление: проверяем версию на старте, показываем диалог ---
var pendingUpdate by remember { mutableStateOf<com.radiola.update.VersionInfo?>(null) } var pendingUpdate by remember { mutableStateOf<com.radiola.update.VersionInfo?>(null) }
var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) } var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) }
@@ -117,6 +130,8 @@ class MainActivity : ComponentActivity() {
var downloadedApk by remember { mutableStateOf<java.io.File?>(null) } var downloadedApk by remember { mutableStateOf<java.io.File?>(null) }
val updateScope = rememberCoroutineScope() val updateScope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Авто-обновление только в sideload-сборке (в store обновляет RuStore).
if (!BuildConfig.ENABLE_SELF_UPDATE) return@LaunchedEffect
val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect
if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info
} }
@@ -355,18 +370,64 @@ class MainActivity : ComponentActivity() {
private fun maybeRequestBatteryExemption() { private fun maybeRequestBatteryExemption() {
val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
// Спрашиваем один раз на установку, чтобы не надоедать.
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE) val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
if (prefs.getBoolean("battery_opt_asked", false)) return // Спрашиваем один раз на установку, чтобы не надоедать.
prefs.edit().putBoolean("battery_opt_asked", true).apply() if (!pm.isIgnoringBatteryOptimizations(packageName) &&
runCatching { !prefs.getBoolean("battery_opt_asked", false)
startActivity( ) {
Intent( prefs.edit().putBoolean("battery_opt_asked", true).apply()
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, runCatching {
android.net.Uri.parse("package:$packageName") startActivity(
Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
android.net.Uri.parse("package:$packageName")
)
) )
) }
}
maybeGuideColorOsBackground()
}
/**
* ColorOS/OxygenOS (Oppo, OnePlus, Realme) агрессивно «схлопывают» приложение в
* фоне при выключённом экране — стандартного исключения из оптимизации батареи
* мало. Реально помогает галочка «Разрешить работу в фоновом режиме» в разделе
* «Использование батареи» приложения. Прямого API для неё нет, поэтому один раз
* показываем пояснение и открываем экран настроек приложения, чтобы юзер включил.
*/
private fun maybeGuideColorOsBackground() {
val m = android.os.Build.MANUFACTURER.lowercase()
val isColorOs = m.contains("oppo") || m.contains("oneplus") || m.contains("realme")
if (!isColorOs) return
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
if (prefs.getBoolean("bg_activity_guided", false)) return
prefs.edit().putBoolean("bg_activity_guided", true).apply()
runCatching {
android.app.AlertDialog.Builder(this)
.setTitle("Фоновое воспроизведение")
.setMessage(
"На вашем устройстве система может выгружать приложение при " +
"выключённом экране, и радио прерывается.\n\n" +
"Чтобы этого не происходило, откройте «Использование батареи» " +
"и включите «Разрешить работу в фоновом режиме»."
)
.setPositiveButton("Открыть настройки") { _, _ -> openAppBatterySettings() }
.setNegativeButton("Позже", null)
.show()
}
}
/** Экран «Использование батареи» приложения; фолбэк — страница «О приложении». */
private fun openAppBatterySettings() {
val uri = android.net.Uri.parse("package:$packageName")
val candidates = listOf(
// На части ColorOS открывает прямо управление батареей приложения.
Intent("android.settings.APP_BATTERY_SETTINGS").apply { data = uri },
// Универсальный фолбэк — «О приложении», оттуда «Использование батареи».
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
)
for (intent in candidates) {
if (runCatching { startActivity(intent); true }.getOrDefault(false)) return
} }
} }
} }

View File

@@ -28,7 +28,6 @@ class SettingsRepositoryImpl @Inject constructor(
private val SLEEP_TIMER = intPreferencesKey("sleep_timer_minutes") private val SLEEP_TIMER = intPreferencesKey("sleep_timer_minutes")
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services") private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset") private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
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") private val COUNTRY_CODE = stringPreferencesKey("country_code")
private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style") private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style")
@@ -57,9 +56,6 @@ class SettingsRepositoryImpl @Inject constructor(
override fun getEqualizerPreset(): Flow<String> = dataStore.data.map { it[EQUALIZER_PRESET] ?: "Flat" } override fun getEqualizerPreset(): Flow<String> = dataStore.data.map { it[EQUALIZER_PRESET] ?: "Flat" }
override suspend fun setEqualizerPreset(preset: String) { dataStore.edit { it[EQUALIZER_PRESET] = preset } } override suspend fun setEqualizerPreset(preset: String) { dataStore.edit { it[EQUALIZER_PRESET] = preset } }
override fun isRecordingEnabled(): Flow<Boolean> = dataStore.data.map { it[RECORDING_ENABLED] ?: false }
override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
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 } }

View File

@@ -15,6 +15,32 @@ object DeeplinkNavigator {
val url = service.buildSearchUrl(track.artist, track.song) val url = service.buildSearchUrl(track.artist, track.song)
Log.d("DeeplinkNavigator", "url=$url") Log.d("DeeplinkNavigator", "url=$url")
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val pkg = service.packageName
if (pkg != null) {
// Сторонний клиент: открыть напрямую в его пакете, если установлен.
val installed = try {
context.packageManager.getPackageInfo(pkg, 0)
true
} catch (e: Exception) {
false
}
if (installed) {
intent.setPackage(pkg)
try {
context.startActivity(intent)
return
} catch (e: Exception) {
Log.e("DeeplinkNavigator", "Не удалось открыть в $pkg", e)
// упадём в общий путь ниже (системный выбор / браузер)
}
} else {
Toast.makeText(context, "${service.displayName} не установлено", Toast.LENGTH_SHORT).show()
return
}
}
// Обычные сервисы (или фолбэк) — системный выбор приложения.
try { try {
context.startActivity(Intent.createChooser(intent, "Открыть в...")) context.startActivity(Intent.createChooser(intent, "Открыть в..."))
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -123,7 +123,7 @@ object AppModule {
@Named("radiolaClient") okHttpClient: OkHttpClient, @Named("radiolaClient") okHttpClient: OkHttpClient,
json: Json json: Json
): Retrofit = Retrofit.Builder() ): Retrofit = Retrofit.Builder()
.baseUrl("http://121.127.37.212:3000/") .baseUrl("https://api.radiola.nexaweb.su/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build() .build()

View File

@@ -3,7 +3,10 @@ package com.radiola.domain.model
enum class DeeplinkService( enum class DeeplinkService(
val serviceId: String, val serviceId: String,
val displayName: String, val displayName: String,
val searchUrlTemplate: String val searchUrlTemplate: String,
// Пакет стороннего приложения: если задан — открываем поиск прямо в нём
// (setPackage), иначе через системный выбор «Открыть в...».
val packageName: String? = null
) { ) {
YANDEX("yandex", "Яндекс Музыка", "https://music.yandex.ru/search?text=%s"), YANDEX("yandex", "Яндекс Музыка", "https://music.yandex.ru/search?text=%s"),
VK("vk", "ВК Музыка", "https://vk.com/audio?q=%s"), VK("vk", "ВК Музыка", "https://vk.com/audio?q=%s"),
@@ -12,7 +15,10 @@ enum class DeeplinkService(
APPLE_MUSIC("apple", "Apple Music", "https://music.apple.com/search?term=%s"), APPLE_MUSIC("apple", "Apple Music", "https://music.apple.com/search?term=%s"),
YOUTUBE_MUSIC("youtube", "YouTube Music", "https://music.youtube.com/search?q=%s"), YOUTUBE_MUSIC("youtube", "YouTube Music", "https://music.youtube.com/search?q=%s"),
TIDAL("tidal", "Tidal", "https://listen.tidal.com/search?q=%s"), TIDAL("tidal", "Tidal", "https://listen.tidal.com/search?q=%s"),
DEEZER("deezer", "Deezer", "https://www.deezer.com/search/%s"); DEEZER("deezer", "Deezer", "https://www.deezer.com/search/%s"),
// Сторонний клиент ВК (мод VK 6.12). Открываем поиск музыки напрямую в его
// пакете через LinkRedirActivity. Доступен только в sideload-сборке.
SOVA("sova", "SOVA", "https://vk.com/audio?q=%s", packageName = "re.sova.five");
fun buildSearchUrl(artist: String, song: String): String { fun buildSearchUrl(artist: String, song: String): String {
val query = java.net.URLEncoder.encode("$artist $song", "UTF-8") val query = java.net.URLEncoder.encode("$artist $song", "UTF-8")

View File

@@ -12,8 +12,6 @@ interface SettingsRepository {
suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>) suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>)
fun getEqualizerPreset(): Flow<String> fun getEqualizerPreset(): Flow<String>
suspend fun setEqualizerPreset(preset: String) suspend fun setEqualizerPreset(preset: String)
fun isRecordingEnabled(): Flow<Boolean>
suspend fun setRecordingEnabled(enabled: Boolean)
// Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции). // Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции).
fun getPreferredBitrate(): Flow<Int> fun getPreferredBitrate(): Flow<Int>
suspend fun setPreferredBitrate(bitrate: Int) suspend fun setPreferredBitrate(bitrate: Int)

View File

@@ -4,6 +4,8 @@ import android.content.Context
import android.media.AudioDeviceCallback import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.Uri import android.net.Uri
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
@@ -90,6 +92,31 @@ class PlayerController @Inject constructor(
private var retryCount = 0 private var retryCount = 0
private var reconnectJob: Job? = null private var reconnectJob: Job? = null
// Намерение играть: пользователь включил станцию и не ставил паузу/стоп.
// По нему решаем, переподключаться ли после обрыва — НЕ сдаёмся навсегда.
@Volatile private var intendedToPlay = false
// Слушатель сети: возврат/смена сети (Wi-Fi↔LTE в машине, выход из туннеля)
// мгновенно переподключает поток, не дожидаясь бэк-оффа. Главная причина, по
// которой радио раньше «не оживало» после смены сети.
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (!intendedToPlay) return
// Колбэк — на системном потоке; ExoPlayer трогаем на main (timerScope).
timerScope.launch {
if (!exoPlayer.isPlaying) {
retryCount = 0
runCatching {
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
}
}
}
private companion object { private companion object {
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро) const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
@@ -212,7 +239,10 @@ class PlayerController @Inject constructor(
*/ */
private fun scheduleReconnect() { private fun scheduleReconnect() {
reconnectJob?.cancel() reconnectJob?.cancel()
if (retryCount >= 10) return // Пока пользователь хочет играть — пробуем переподключаться бесконечно
// (с бэк-оффом до 15с). Раньше сдавались навсегда после 10 попыток (~100с) →
// длинный туннель/смена сети глушили радио до ручного перезапуска.
if (!intendedToPlay) return
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L) val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
retryCount++ retryCount++
reconnectJob = timerScope.launch { reconnectJob = timerScope.launch {
@@ -244,6 +274,7 @@ class PlayerController @Inject constructor(
init { init {
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
runCatching { connectivityManager?.registerDefaultNetworkCallback(networkCallback) }
} }
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) { fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
@@ -251,6 +282,7 @@ class PlayerController @Inject constructor(
// Новая станция — сбрасываем переподключение предыдущего потока. // Новая станция — сбрасываем переподключение предыдущего потока.
reconnectJob?.cancel() reconnectJob?.cancel()
retryCount = 0 retryCount = 0
intendedToPlay = true
_currentStationId.value = stationId _currentStationId.value = stationId
_icyTitle.value = null _icyTitle.value = null
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()
@@ -412,17 +444,19 @@ class PlayerController @Inject constructor(
} }
fun pause() { fun pause() {
// Пауза пользователем — отменяем отложенное переподключение, иначе оно // Пауза пользователем — больше не хотим играть, отменяем переподключение.
// позже само возобновит воспроизведение. intendedToPlay = false
reconnectJob?.cancel() reconnectJob?.cancel()
exoPlayer.pause() exoPlayer.pause()
} }
fun play() { fun play() {
intendedToPlay = true
exoPlayer.play() exoPlayer.play()
} }
fun stop() { fun stop() {
intendedToPlay = false
reconnectJob?.cancel() reconnectJob?.cancel()
exoPlayer.stop() exoPlayer.stop()
_currentStationPrefix.value = null _currentStationPrefix.value = null
@@ -432,6 +466,7 @@ class PlayerController @Inject constructor(
fun release() { fun release() {
timerScope.cancel() timerScope.cancel()
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
runCatching { connectivityManager?.unregisterNetworkCallback(networkCallback) }
sleepSoundPlayer.stop() sleepSoundPlayer.stop()
exoPlayer.release() exoPlayer.release()
} }

View File

@@ -108,12 +108,30 @@ class PlayerViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
settingsRepository.getEnabledDeeplinkServices().collect { ids -> settingsRepository.getEnabledDeeplinkServices().collect { ids ->
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids } _enabledServices.value = DeeplinkService.entries.filter {
it.serviceId in ids &&
// SOVA (сторонний мод ВК) — только в sideload-сборке.
(com.radiola.BuildConfig.SHOW_DEV_TOOLS || it != DeeplinkService.SOVA)
}
} }
} }
viewModelScope.launch { viewModelScope.launch {
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it } settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
} }
// Восстановление сессии: если процесс/Activity пересоздались, а станция уже
// играет в фоновом сервисе (PlayerController помнит id) — заново привязываемся
// и запускаем опрос now-playing. Иначе мини-плеер/эфир «застывают».
viewModelScope.launch {
combine(playerController.currentStationId, _stations) { id, list ->
id?.let { sid -> list.firstOrNull { it.id == sid } }
}.collect { station ->
if (station != null && _currentStation.value == null) {
_currentStation.value = station
_playlist.value = _stations.value
startNowPlaying(station)
}
}
}
viewModelScope.launch { viewModelScope.launch {
_currentTrack _currentTrack
.filterNotNull() .filterNotNull()
@@ -146,8 +164,20 @@ class PlayerViewModel @Inject constructor(
playerController.play(url, station.prefix, station.name, station.id) playerController.play(url, station.prefix, station.name, station.id)
} }
viewModelScope.launch { pushHistoryUseCase(station.id) } viewModelScope.launch { pushHistoryUseCase(station.id) }
startNowPlaying(station)
}
/**
* Запускает опрос now-playing для станции: мгновенный рефреш + цикл раз в 5с
* (пока играем) + сбор трека из API (приоритет) и ICY (фолбэк). Вынесено из
* play(), чтобы переиспользовать при восстановлении сессии (возврат из фона /
* пересоздание ViewModel) — иначе эфир «застывает» на последнем значении.
*/
private fun startNowPlaying(station: Station) {
nowPlayingJob?.cancel() nowPlayingJob?.cancel()
nowPlayingJob = viewModelScope.launch { nowPlayingJob = viewModelScope.launch {
// Сразу тянем свежий эфир — не ждём первые 5с цикла.
launch { nowPlayingRepository.refreshNowPlaying() }
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет // Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть // внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
// каждые 5с → батарея + лишняя нагрузка на бэкенд). // каждые 5с → батарея + лишняя нагрузка на бэкенд).
@@ -204,6 +234,17 @@ class PlayerViewModel @Inject constructor(
} }
} }
/**
* Возврат приложения на передний план: мгновенно освежаем эфир (чтобы юзер не
* видел залипший трек после фоновой заморозки) и, если опрос почему-то не идёт,
* перезапускаем его для текущей станции.
*/
fun onAppForeground() {
val station = _currentStation.value ?: return
viewModelScope.launch { nowPlayingRepository.refreshNowPlaying() }
if (nowPlayingJob?.isActive != true) startNowPlaying(station)
}
/** Стартовое качество станции с учётом предпочтения пользователя. */ /** Стартовое качество станции с учётом предпочтения пользователя. */
private fun pickInitialQuality(station: Station): StreamQuality? { private fun pickInitialQuality(station: Station): StreamQuality? {
val list = station.qualities val list = station.qualities

View File

@@ -50,7 +50,6 @@ fun SettingsScreen(
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState() val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val themePalette by viewModel.themePalette.collectAsState() val themePalette by viewModel.themePalette.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val preferredBitrate by viewModel.preferredBitrate.collectAsState() val preferredBitrate by viewModel.preferredBitrate.collectAsState()
val isTesting by viewModel.isTesting.collectAsState() val isTesting by viewModel.isTesting.collectAsState()
val testProgress by viewModel.testProgress.collectAsState() val testProgress by viewModel.testProgress.collectAsState()
@@ -408,7 +407,11 @@ fun SettingsScreen(
.background(colors.surface) .background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp)) .border(1.dp, colors.border, RoundedCornerShape(16.dp))
) { ) {
DeeplinkService.entries.forEachIndexed { index, service -> // В store-сборке скрываем SOVA (сторонний мод ВК) — только sideload.
val services = DeeplinkService.entries.filter {
com.radiola.BuildConfig.SHOW_DEV_TOOLS || it != DeeplinkService.SOVA
}
services.forEachIndexed { index, service ->
val checked = service.serviceId in enabledServices val checked = service.serviceId in enabledServices
val trackColor by animateColorAsState( val trackColor by animateColorAsState(
targetValue = if (checked) colors.accent else colors.surface2, targetValue = if (checked) colors.accent else colors.surface2,
@@ -439,7 +442,7 @@ fun SettingsScreen(
) )
) )
} }
if (index < DeeplinkService.entries.size - 1) { if (index < services.size - 1) {
HorizontalDivider( HorizontalDivider(
color = colors.border, color = colors.border,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
@@ -449,51 +452,8 @@ fun SettingsScreen(
} }
} }
// --- Запись эфира --- // --- Тестирование станций (dev-инструмент, только в sideload) ---
item { if (com.radiola.BuildConfig.SHOW_DEV_TOOLS) item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) }
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Запись эфира",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
text = "Сохранять в файл при воспроизведении",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
Switch(
checked = isRecordingEnabled,
onCheckedChange = { viewModel.setRecordingEnabled(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
)
}
}
}
// --- Тестирование станций ---
item {
SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ") SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ")
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Column( Column(

View File

@@ -38,9 +38,6 @@ class SettingsViewModel @Inject constructor(
val themePalette: StateFlow<String> = settingsRepository.getThemePalette() val themePalette: StateFlow<String> = settingsRepository.getThemePalette()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest") .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest")
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
// Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает). // Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate() val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
@@ -91,10 +88,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setThemePalette(id) } viewModelScope.launch { settingsRepository.setThemePalette(id) }
} }
fun setRecordingEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
}
fun startTesting() { fun startTesting() {
viewModelScope.launch { viewModelScope.launch {
_isTesting.value = true _isTesting.value = true

View File

@@ -32,7 +32,7 @@ data class VersionInfo(
*/ */
object UpdateManager { object UpdateManager {
private const val TAG = "radiOLA/Update" private const val TAG = "radiOLA/Update"
private const val BASE_URL = "http://121.127.37.212:3000" private const val BASE_URL = "https://api.radiola.nexaweb.su"
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Установка скачанного APK — только в sideload-сборке (авто-апдейтер).
В store-сборке этого разрешения нет (требование RuStore). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Видимость пакета SOVA (Android 11+ package visibility): без этого
getPackageInfo/launch в re.sova.five блокируется и кнопка считает,
что приложение не установлено. Только sideload (в store кнопки SOVA нет). -->
<queries>
<package android:name="re.sova.five" />
</queries>
</manifest>

Submodule backend updated: 791156f814...e1bceb8bd1

27
design/logos/icon_01.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_02.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_03.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_04.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_05.html Normal file

File diff suppressed because one or more lines are too long

87
design/logos/sheet.html Normal file

File diff suppressed because one or more lines are too long

83
docs/rustore-listing.md Normal file
View File

@@ -0,0 +1,83 @@
# Карточка radiOLA для RuStore
Дата: 2026-06-08. Черновик контента и ассетов для публикации в RuStore.
Связано: [дизайн релиза](superpowers/specs/2026-06-08-rustore-release-design.md), раздел F.
## Основное
| Поле | Значение |
|---|---|
| Название в Консоли (внутреннее, не меняется) | `radiOLA` |
| Название для пользователей | `radiOLA — радио онлайн` |
| Тип приложения | Универсальное |
| Тип монетизации | Бесплатное |
| Категория | Музыка и аудио |
| Возрастной рейтинг | 12+ (возможна ненормативная лирика в потоках) |
| Политика конфиденциальности | `https://api.radiola.nexaweb.su/privacy` (⏳ захостить) |
## Страны и регионы
Россия, Беларусь, Казахстан, Кыргызстан, Армения (в каталоге есть белорусские
станции — Unistar, Новое Радио BY).
## Краткое описание
```
Онлайн-радио: сотни станций, тексты песен, распознавание треков, запись эфира
```
## Полное описание
```
radiOLA — удобный плеер интернет-радио с сотнями станций и умными функциями.
• Сотни радиостанций — музыка, новости, разговорные
• Что играет сейчас: трек, исполнитель и обложка в реальном времени
• Чарты популярных треков на радио — что крутят чаще всего, с фильтром
по жанрам и периодам, трендами роста и графиком популярности
• Тексты песен прямо во время эфира
• Распознавание треков (даже без метаданных станции)
• Быстрый поиск играющего трека в Яндекс Музыке, ВК Музыке, Spotify,
Apple Music, YouTube Music, BOOM, Tidal и Deezer — одним касанием
• История прослушанного и распознанных песен
• Запись эфира с перемоткой и тайм-кодами
• Эквалайзер и улучшайзеры звука
• Таймер сна и будильник с радио
• Выбор качества потока, 8 цветовых тем
• Фоновое воспроизведение, управление с локскрина
```
## Ассеты (готовы)
Папка: `tempfiles/screensforRuStore/out/`
| Файл | Назначение |
|---|---|
| `icon_512.png` | Иконка 512×512 (тема forest, композит ic_bg/ic_fg_forest) |
| `rustore_1.png` | Каталог станций |
| `rustore_2.png` | Плеер + поиск трека в сервисах |
| `rustore_3.png` | Чарты |
| `rustore_4.png` | Тексты песен |
| `rustore_5.png` | Записи эфира |
| `rustore_6.png` | Эквалайзер |
| `rustore_7.png` | Таймер сна |
| `rustore_8.png` | Будильник / настройки |
Пересборка скриншотов: `tempfiles/screensforRuStore/gen.py``html/` → headless Chrome.
## Заметка модератору
- `SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM` — будильник с радио (точное время срабатывания).
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — фоновое воспроизведение при выключенном
экране / в машине. Готовы убрать по требованию модерации.
- Приложение-агрегатор: воспроизводит публичные интернет-радиопотоки третьих лиц.
## Блокеры загрузки версии (из плана реализации)
1. HTTPS-домен `api.radiola.nexaweb.su` (DNS + Caddy).
2. Сервер: страница `/privacy`, https в `app-version.json`.
3. Gradle: flavors `store`/`sideload` + signingConfig release.
4. Манифест: `REQUEST_INSTALL_PACKAGES` → sideload.
5. Код: gate апдейтера, baseUrl → https, чистка настроек, SOVA (sideload).
6. Keystore (генерирует пользователь).
7. Сборка `:app:assembleStoreRelease`.

View File

@@ -0,0 +1,877 @@
# Подготовка radiOLA к публикации в RuStore — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Подготовить Android-приложение radiOLA к публикации в RuStore: разделить
сборку на флейворы `store`/`sideload`, добавить релизную подпись, перевести API на
HTTPS, оформить политику конфиденциальности, почистить настройки и добавить
дип-линк-кнопку SOVA (только sideload).
**Architecture:** Один кодовый базис, два product flavor. `store` — чистая сборка
для RuStore (без авто-апдейтера и `REQUEST_INSTALL_PACKAGES`, без dev-тестера и
SOVA). `sideload` — текущий канал. API уходит за HTTPS-домен `api.radiola.nexaweb.su`
через уже работающий на сервере хостовый Caddy.
**Tech Stack:** Kotlin/Compose/Hilt (Android), Gradle product flavors, NestJS
(бэкенд-сабмодуль), Caddy (reverse-proxy + авто-TLS), Docker Compose.
**Замечание по методу:** задачи — конфигурация и инфраструктура, классический
TDD неприменим. Роль теста выполняют команды-проверки (gradle-сборка,
`aapt dump permissions`, `curl`, `adb`). Каждая задача: изменение → проверка →
коммит.
**Git:** Android — ветка `feat/bootstrap-project` (репо radiola-android, корень
`C:\radiOLA`). Бэкенд — ветка `main` (сабмодуль `C:\radiOLA\backend`, деплой
scp+`docker compose` на ru-server `121.127.37.212`, `/opt/radiola`НЕ git-репо).
**Зависимости от пользователя (отметить и дождаться):**
- DNS A-запись `api.radiola.nexaweb.su → 121.127.37.212` (Задача 1).
- Генерация release-keystore + `keystore.properties` (Задача 6).
- Полный `IP:порт` adb «Беспроводная отладка» для добычи пакета SOVA (Задача 11).
- Действия в консоли RuStore (Задача 14).
---
## Файловая карта
**Бэкенд / сервер (сабмодуль + ru-server):**
- Caddyfile на сервере (расположение определить) — vhost `api.radiola.nexaweb.su`.
- `backend/public/privacy.html` (создать) или статический роут — текст политики.
- `backend/src/main.ts` — при необходимости статика `/privacy`.
- `/opt/radiola/.env` (сервер) — `PUBLIC_BASE_URL` на https.
- `/opt/radiola/appdist/app-version.json` (сервер) — `download_url` на https.
**Android:**
- `app/build.gradle.kts` — flavors, BuildConfig-поля, signingConfig.
- `keystore.properties`, `.gitignore` (корень) — подпись.
- `app/src/main/AndroidManifest.xml` — убрать `REQUEST_INSTALL_PACKAGES`.
- `app/src/sideload/AndroidManifest.xml` (создать) — `REQUEST_INSTALL_PACKAGES`.
- `app/src/main/java/com/radiola/MainActivity.kt` — gate апдейтера.
- `app/src/main/java/com/radiola/update/UpdateManager.kt` — BASE_URL → https.
- `app/src/main/java/com/radiola/di/AppModule.kt` — baseUrl → https.
- `app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt` — убрать тумблер записи, gate тестера.
- `app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt` — убрать recording.
- `app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt` — убрать recording.
- `app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt` — убрать recording.
- `app/src/main/java/com/radiola/domain/model/DeeplinkService.kt` — packageName + SOVA.
- `app/src/main/java/com/radiola/deeplink/DeeplinkNavigator.kt` — setPackage + фолбэк.
---
## ФАЗА 1 — Инфраструктура HTTPS + политика конфиденциальности
### Задача 1: HTTPS-домен для API (DNS + Caddy)
**Files:** Caddyfile на ru-server (расположение определить в шаге 2).
- [ ] **Шаг 1: Пользователь добавляет DNS A-запись**
Попросить пользователя: в DNS-зоне `nexaweb.su` создать запись
`api.radiola.nexaweb.su A 121.127.37.212`. Дождаться подтверждения.
Проверка резолвинга:
```bash
nslookup api.radiola.nexaweb.su
```
Ожидаемо: возвращает `121.127.37.212`. Если нет — подождать распространения DNS.
- [ ] **Шаг 2: Найти Caddyfile на сервере**
```bash
ssh ru-server 'systemctl status caddy | grep -i caddyfile; ls -la /etc/caddy/Caddyfile 2>/dev/null; caddy version'
```
Ожидаемо: путь к Caddyfile (обычно `/etc/caddy/Caddyfile`).
- [ ] **Шаг 3: Добавить vhost-блок в Caddyfile**
Дописать в Caddyfile (через ssh, с бэкапом):
```bash
ssh ru-server 'cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%s); cat >> /etc/caddy/Caddyfile <<"EOF"
api.radiola.nexaweb.su {
reverse_proxy localhost:3000
}
EOF'
```
(Путь подставить из шага 2. `date` — на сервере, не в JS-окружении.)
- [ ] **Шаг 4: Перезагрузить Caddy и проверить TLS**
```bash
ssh ru-server 'caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy'
sleep 5
curl -s -o /dev/null -w "%{http_code}\n" https://api.radiola.nexaweb.su/app-version
```
Ожидаемо: валидный TLS, HTTP `200`. (Caddy сам выпустит Let's Encrypt cert.)
- [ ] **Шаг 5: Проверить /downloads и /covers по https**
```bash
curl -s -o /dev/null -w "downloads:%{http_code}\n" https://api.radiola.nexaweb.su/downloads/radiola-latest.apk
```
Ожидаемо: `downloads:200`.
(Коммита нет — изменение на сервере. Caddyfile.bak оставлен.)
---
### Задача 2: Политика конфиденциальности (текст + хостинг)
**Files:**
- Create: `backend/src/privacy/privacy.controller.ts`
- Create: `backend/src/privacy/privacy.module.ts`
- Modify: `backend/src/app.module.ts` (импорт PrivacyModule)
- [ ] **Шаг 1: Создать контроллер с инлайн-HTML политики**
Создать `backend/src/privacy/privacy.controller.ts``@Get('privacy')`, отдаёт
`text/html`. HTML хранить константой в файле (без внешних зависимостей/файлов —
проще для Docker). Содержание: заголовок «Политика конфиденциальности radiOLA»,
дата; разделы — какие данные собираются (email для входа по magic-link; история
прослушиваний и распознанных треков; технические логи ошибок), сторонние сервисы
(shazam-api.com — фрагмент аудио для распознавания; Discogs — обложки; радиопотоки
третьих лиц), цели обработки, хранение, удаление данных по запросу, контакт
оператора (`blinnafeg@gmail.com`). Скелет:
```typescript
import { Controller, Get, Header } from '@nestjs/common';
const PRIVACY_HTML = `<!doctype html><html lang="ru"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Политика конфиденциальности radiOLA</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:760px;margin:40px auto;padding:0 16px;color:#1a1a1a}h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2rem}</style>
</head><body>
<h1>Политика конфиденциальности radiOLA</h1>
<p>Дата вступления в силу: 08.06.2026</p>
<!-- ... разделы по содержанию выше ... -->
<h2>Контакты</h2><p>По вопросам обработки данных: blinnafeg@gmail.com</p>
</body></html>`;
@Controller()
export class PrivacyController {
@Get('privacy')
@Header('Content-Type', 'text/html; charset=utf-8')
getPrivacy(): string {
return PRIVACY_HTML;
}
}
```
(Заполнить разделы полностью — не оставлять `<!-- ... -->`.)
- [ ] **Шаг 2: Модуль + регистрация**
Создать `backend/src/privacy/privacy.module.ts`:
```typescript
import { Module } from '@nestjs/common';
import { PrivacyController } from './privacy.controller';
@Module({ controllers: [PrivacyController] })
export class PrivacyModule {}
```
В `backend/src/app.module.ts` импортировать `PrivacyModule` и добавить в `imports`.
- [ ] **Шаг 3: Компиляция бэкенда**
```bash
cd C:/radiOLA/backend && npx tsc --noEmit 2>&1 | grep -viE "sharp|undici" | head; echo "tsc done"
```
Ожидаемо: без ошибок (sharp/undici — предсуществующие, игнорируем).
- [ ] **Шаг 4: Закоммитить (backend, main)**
```bash
cd C:/radiOLA/backend
git add src/privacy/ src/app.module.ts
git commit -m "feat(privacy): страница политики конфиденциальности на /privacy"
git push
```
- [ ] **Шаг 5: Задеплоить и проверить**
```bash
cd C:/radiOLA/backend
ssh ru-server 'mkdir -p /opt/radiola/src/privacy'
scp src/privacy/privacy.controller.ts src/privacy/privacy.module.ts ru-server:/opt/radiola/src/privacy/
scp src/app.module.ts ru-server:/opt/radiola/src/app.module.ts
ssh ru-server 'cd /opt/radiola && docker compose build app && docker compose up -d app'
sleep 8
curl -s -o /dev/null -w "%{http_code}\n" https://api.radiola.nexaweb.su/privacy
```
Ожидаемо: `200`, страница открывается в браузере.
- [ ] **Шаг 6: Закоммитить гитлинк сабмодуля (android, feat/bootstrap-project)**
```bash
cd C:/radiOLA
git add backend
git commit -m "chore: bump backend submodule (privacy)"
git push
```
---
### Задача 3: Перевести серверные ссылки на HTTPS
**Files:** `/opt/radiola/.env`, `/opt/radiola/appdist/app-version.json` (сервер).
- [ ] **Шаг 1: PUBLIC_BASE_URL на https**
```bash
ssh ru-server 'cd /opt/radiola && sed -i "s#PUBLIC_BASE_URL=.*#PUBLIC_BASE_URL=https://api.radiola.nexaweb.su#" .env && grep PUBLIC_BASE_URL .env'
```
Ожидаемо: `PUBLIC_BASE_URL=https://api.radiola.nexaweb.su`.
(Если строки нет — добавить `echo` в .env.)
- [ ] **Шаг 2: download_url в манифесте версии на https**
```bash
ssh ru-server 'cd /opt/radiola/appdist && sed -i "s#http://121.127.37.212:3000/downloads#https://api.radiola.nexaweb.su/downloads#" app-version.json && grep download_url app-version.json'
```
Ожидаемо: `download_url` начинается с `https://api.radiola.nexaweb.su/downloads`.
- [ ] **Шаг 3: Перезапустить контейнер (подхватить PUBLIC_BASE_URL)**
```bash
ssh ru-server 'cd /opt/radiola && docker compose up -d app'
sleep 8
curl -s https://api.radiola.nexaweb.su/app-version
```
Ожидаемо: JSON, `download_url` по https.
- [ ] **Шаг 4: Проверить обложки по https**
```bash
curl -s "https://api.radiola.nexaweb.su/now-playing" | head -c 300
```
Ожидаемо: ответ приходит; `coverUrl` (если есть) — по https.
(Коммита нет — конфиг на сервере.)
---
## ФАЗА 2 — Android: флейворы, подпись, манифест, апдейтер
### Задача 4: Product flavors + BuildConfig-флаги
**Files:** Modify `app/build.gradle.kts`.
- [ ] **Шаг 1: Добавить flavorDimensions + productFlavors**
В `app/build.gradle.kts`, внутри `android { }`, после блока `buildTypes { }`
добавить:
```kotlin
flavorDimensions += "distribution"
productFlavors {
create("store") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "false")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "false")
}
create("sideload") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "true")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "true")
}
}
```
- [ ] **Шаг 2: Проверить, что Gradle видит флейворы**
```bash
cd C:/radiOLA && ./gradlew :app:tasks --all -q 2>&1 | grep -iE "assembleStore|assembleSideload" | head
```
Ожидаемо: присутствуют задачи `assembleStoreDebug`, `assembleSideloadDebug`,
`assembleStoreRelease`, `assembleSideloadRelease`.
- [ ] **Шаг 3: Компиляция (BuildConfig поля сгенерированы)**
```bash
cd C:/radiOLA && ./gradlew :app:compileSideloadDebugKotlin -q 2>&1 | tail -10; echo done
grep -r "ENABLE_SELF_UPDATE\|SHOW_DEV_TOOLS" app/build/generated/source/buildConfig/sideload/debug/com/radiola/BuildConfig.java
```
Ожидаемо: компиляция без ошибок; поля `ENABLE_SELF_UPDATE=true`, `SHOW_DEV_TOOLS=true`.
- [ ] **Шаг 4: Коммит**
```bash
cd C:/radiOLA
git add app/build.gradle.kts
git commit -m "build(app): product flavors store/sideload + BuildConfig флаги"
```
---
### Задача 5: Gate апдейтера + baseUrl/BASE_URL на HTTPS
**Files:**
- Modify `app/src/main/java/com/radiola/MainActivity.kt:119-122`
- Modify `app/src/main/java/com/radiola/update/UpdateManager.kt:35`
- Modify `app/src/main/java/com/radiola/di/AppModule.kt` (baseUrl `radiola`)
- [ ] **Шаг 1: Gate вызова проверки обновления в MainActivity**
Заменить блок (строки ~119-122):
```kotlin
LaunchedEffect(Unit) {
val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect
if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info
}
```
на:
```kotlin
LaunchedEffect(Unit) {
// Авто-обновление только в sideload-сборке (в store обновляет RuStore).
if (!BuildConfig.ENABLE_SELF_UPDATE) return@LaunchedEffect
val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect
if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info
}
```
- [ ] **Шаг 2: UpdateManager BASE_URL → https**
В `app/src/main/java/com/radiola/update/UpdateManager.kt` строка 35:
```kotlin
private const val BASE_URL = "http://121.127.37.212:3000"
```
заменить на:
```kotlin
private const val BASE_URL = "https://api.radiola.nexaweb.su"
```
- [ ] **Шаг 3: AppModule Retrofit baseUrl → https**
В `app/src/main/java/com/radiola/di/AppModule.kt` найти `provideRadiolaRetrofit`
и заменить:
```kotlin
.baseUrl("http://121.127.37.212:3000/")
```
на:
```kotlin
.baseUrl("https://api.radiola.nexaweb.su/")
```
- [ ] **Шаг 4: Компиляция обоих флейворов**
```bash
cd C:/radiOLA && ./gradlew :app:compileStoreDebugKotlin :app:compileSideloadDebugKotlin -q 2>&1 | tail -10; echo done
```
Ожидаемо: без ошибок.
- [ ] **Шаг 5: Коммит**
```bash
cd C:/radiOLA
git add app/src/main/java/com/radiola/MainActivity.kt app/src/main/java/com/radiola/update/UpdateManager.kt app/src/main/java/com/radiola/di/AppModule.kt
git commit -m "feat(app): апдейтер только в sideload; API/BASE_URL на https"
```
---
### Задача 6: Релизная подпись (keystore.properties)
**Files:**
- Modify `app/build.gradle.kts`
- Modify `.gitignore` (корень)
- Create (пользователь): `radiola-release.jks`, `keystore.properties`
- [ ] **Шаг 1: Добавить keystore.properties и *.jks в .gitignore**
В корневой `.gitignore` дописать:
```
# Релизная подпись (секреты — не в git)
keystore.properties
*.jks
*.keystore
```
- [ ] **Шаг 2: Пользователь генерирует keystore**
Дать пользователю команду (выполняет в `C:\radiOLA`, пароли придумывает сам):
```
keytool -genkeypair -v -keystore radiola-release.jks -alias radiola -keyalg RSA -keysize 2048 -validity 10000
```
И создать `C:\radiOLA\keystore.properties`:
```
storeFile=radiola-release.jks
storePassword=<пароль хранилища>
keyAlias=radiola
keyPassword=<пароль ключа>
```
⚠️ Предупредить: keystore нужно сохранить навсегда (потеря = нет обновлений).
Дождаться подтверждения, что файлы созданы.
- [ ] **Шаг 3: Добавить signingConfig в build.gradle.kts**
В `app/build.gradle.kts` перед `android { }` добавить:
```kotlin
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = java.util.Properties().apply {
if (keystorePropsFile.exists()) load(keystorePropsFile.inputStream())
}
```
Внутри `android { }` (перед `buildTypes`) добавить:
```kotlin
signingConfigs {
create("release") {
if (keystorePropsFile.exists()) {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
```
В `buildTypes { release { } }` добавить первой строкой:
```kotlin
signingConfig = signingConfigs.getByName("release")
```
- [ ] **Шаг 4: Проверить подпись release-сборки**
```bash
cd C:/radiOLA && ./gradlew :app:assembleStoreRelease -q 2>&1 | tail -10; echo done
"$ANDROID_HOME/build-tools/34.0.0/apksigner" verify --print-certs app/build/outputs/apk/store/release/app-store-release.apk 2>&1 | head -5
```
Ожидаемо: сборка успешна; apksigner показывает сертификат (CN=...), НЕ debug-ключ.
(Если `apksigner` не в PATH — найти в `$ANDROID_HOME/build-tools/*/`.)
- [ ] **Шаг 5: Коммит (только .gitignore и gradle, без секретов)**
```bash
cd C:/radiOLA
git status --short # убедиться: keystore.properties и *.jks НЕ в списке
git add .gitignore app/build.gradle.kts
git commit -m "build(app): релизная подпись из keystore.properties (в .gitignore)"
```
---
### Задача 7: REQUEST_INSTALL_PACKAGES → sideload sourceSet
**Files:**
- Modify `app/src/main/AndroidManifest.xml:14-15`
- Create `app/src/sideload/AndroidManifest.xml`
- [ ] **Шаг 1: Убрать разрешение из главного манифеста**
В `app/src/main/AndroidManifest.xml` удалить строки:
```xml
<!-- Авто-обновление: установка скачанного APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
```
- [ ] **Шаг 2: Создать sideload-манифест с разрешением**
Создать `app/src/sideload/AndroidManifest.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Установка скачанного APK — только в sideload-сборке (авто-апдейтер). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
</manifest>
```
- [ ] **Шаг 3: Проверить итоговые разрешения каждого флейвора**
```bash
cd C:/radiOLA && ./gradlew :app:assembleStoreDebug :app:assembleSideloadDebug -q 2>&1 | tail -5; echo done
AAPT="$ANDROID_HOME/build-tools/34.0.0/aapt"
echo "=== store (НЕ должно быть REQUEST_INSTALL_PACKAGES) ==="
"$AAPT" dump permissions app/build/outputs/apk/store/debug/app-store-debug.apk | grep -i INSTALL_PACKAGES || echo "нет — верно"
echo "=== sideload (ДОЛЖНО быть) ==="
"$AAPT" dump permissions app/build/outputs/apk/sideload/debug/app-sideload-debug.apk | grep -i INSTALL_PACKAGES
```
Ожидаемо: в store — «нет — верно»; в sideload — строка с `REQUEST_INSTALL_PACKAGES`.
- [ ] **Шаг 4: Коммит**
```bash
cd C:/radiOLA
git add app/src/main/AndroidManifest.xml app/src/sideload/AndroidManifest.xml
git commit -m "build(app): REQUEST_INSTALL_PACKAGES только в sideload"
```
---
## ФАЗА 3 — Чистка экрана настроек
### Задача 8: Убрать осиротевший тумблер «Запись эфира» (G1)
**Files:**
- Modify `app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt:452-493`
- Modify `app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt`
- Modify `app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt:15-16`
- Modify `app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt`
- [ ] **Шаг 1: Удалить секцию «Запись эфира» из SettingsScreen**
В `SettingsScreen.kt` удалить весь блок `// --- Запись эфира ---` целиком — это
`item { ... }` со строки 452 (комментарий) по 493 (закрывающая `}` item'а),
включающий `Column` с `Switch(checked = isRecordingEnabled, ...)`.
- [ ] **Шаг 2: Удалить collectAsState записи в SettingsScreen**
В `SettingsScreen.kt` удалить строку (≈53):
```kotlin
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
```
- [ ] **Шаг 3: Удалить recording из SettingsViewModel**
В `SettingsViewModel.kt` удалить:
```kotlin
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
```
и метод:
```kotlin
fun setRecordingEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
}
```
- [ ] **Шаг 4: Удалить из интерфейса SettingsRepository**
В `SettingsRepository.kt` удалить строки:
```kotlin
fun isRecordingEnabled(): Flow<Boolean>
suspend fun setRecordingEnabled(enabled: Boolean)
```
- [ ] **Шаг 5: Удалить из SettingsRepositoryImpl**
В `SettingsRepositoryImpl.kt` удалить ключ (строка 31):
```kotlin
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
```
и реализации (строки 60-61):
```kotlin
override fun isRecordingEnabled(): Flow<Boolean> = dataStore.data.map { it[RECORDING_ENABLED] ?: false }
override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
```
- [ ] **Шаг 6: Компиляция (проверка, что ничего не ссылается)**
```bash
cd C:/radiOLA && ./gradlew :app:compileSideloadDebugKotlin -q 2>&1 | tail -10; echo done
```
Ожидаемо: без ошибок (если есть «unresolved reference: isRecordingEnabled» —
осталась ссылка, удалить её).
- [ ] **Шаг 7: Коммит**
```bash
cd C:/radiOLA
git add app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt app/src/main/java/com/radiola/domain/repository/SettingsRepository.kt app/src/main/java/com/radiola/data/repository/SettingsRepositoryImpl.kt
git commit -m "refactor(settings): убрать осиротевший тумблер «Запись эфира»"
```
---
### Задача 9: Тестер станций под BuildConfig.SHOW_DEV_TOOLS (G2)
**Files:** Modify `app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt:495-561`
- [ ] **Шаг 1: Обернуть секцию тестирования в флаг**
В `SettingsScreen.kt` секцию `// --- Тестирование станций ---` (это `item { ... }`,
строки ~495-561) обернуть телом в условие. Изменить начало `item {` на:
```kotlin
// Диагностический тестер станций — только в sideload (dev-инструмент).
if (com.radiola.BuildConfig.SHOW_DEV_TOOLS) item {
```
(Остальное тело item'а без изменений.)
- [ ] **Шаг 2: Скрыть диалог отчёта в store (он зависит от testResults)**
Диалог `if (showReport) { AlertDialog(...) }` (строки ~565-615) оставить как есть —
в store `showReport` никогда не станет true (кнопка скрыта). Доп. правок не нужно.
Проверить, что `StationTestStatus`/`testResults` всё ещё импортируются (используются
диалогом) — компиляция покажет.
- [ ] **Шаг 3: Сборка обоих флейворов + проверка**
```bash
cd C:/radiOLA && ./gradlew :app:compileStoreDebugKotlin :app:compileSideloadDebugKotlin -q 2>&1 | tail -10; echo done
```
Ожидаемо: без ошибок в обоих.
- [ ] **Шаг 4: Коммит**
```bash
cd C:/radiOLA
git add app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt
git commit -m "feat(settings): тестер станций только в sideload (SHOW_DEV_TOOLS)"
```
---
## ФАЗА 4 — Кнопка дип-линк-поиска SOVA (только sideload)
### Задача 10: Расширить дип-линк-архитектуру (packageName + прямое открытие)
**Files:**
- Modify `app/src/main/java/com/radiola/domain/model/DeeplinkService.kt`
- Modify `app/src/main/java/com/radiola/deeplink/DeeplinkNavigator.kt`
- [ ] **Шаг 1: Добавить packageName в DeeplinkService**
В `DeeplinkService.kt` изменить сигнатуру enum, добавив 4-й параметр со значением
по умолчанию (существующие записи не меняются):
```kotlin
enum class DeeplinkService(
val serviceId: String,
val displayName: String,
val searchUrlTemplate: String,
val packageName: String? = null
) {
```
(Записи YANDEX..DEEZER остаются как есть — `packageName` у них null.)
- [ ] **Шаг 2: Учесть packageName в DeeplinkNavigator**
В `DeeplinkNavigator.kt` заменить тело `openSearch`:
```kotlin
fun openSearch(context: Context, track: Track, service: DeeplinkService) {
val url = service.buildSearchUrl(track.artist, track.song)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val pkg = service.packageName
if (pkg != null) {
// Сторонний клиент: открыть напрямую в его пакете, если установлен.
val installed = try {
context.packageManager.getPackageInfo(pkg, 0); true
} catch (e: Exception) { false }
if (installed) {
intent.setPackage(pkg)
try {
context.startActivity(intent)
return
} catch (e: Exception) {
Log.e("DeeplinkNavigator", "Не удалось открыть в $pkg", e)
}
} else {
Toast.makeText(context, "${service.displayName} не установлено", Toast.LENGTH_SHORT).show()
return
}
}
// Обычные сервисы (или фолбэк) — системный выбор приложения.
try {
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
} catch (e: Exception) {
Log.e("DeeplinkNavigator", "Failed to open deeplink", e)
Toast.makeText(context, "Не удалось открыть ссылку", Toast.LENGTH_SHORT).show()
}
}
```
- [ ] **Шаг 3: Компиляция**
```bash
cd C:/radiOLA && ./gradlew :app:compileSideloadDebugKotlin -q 2>&1 | tail -10; echo done
```
Ожидаемо: без ошибок (поведение существующих сервисов не изменилось).
- [ ] **Шаг 4: Коммит**
```bash
cd C:/radiOLA
git add app/src/main/java/com/radiola/domain/model/DeeplinkService.kt app/src/main/java/com/radiola/deeplink/DeeplinkNavigator.kt
git commit -m "feat(deeplink): поддержка прямого открытия в пакете стороннего сервиса"
```
---
### Задача 11: Добавить SOVA (пакет/схему добыть с телефона) + фильтр в store
**Files:**
- Modify `app/src/main/java/com/radiola/domain/model/DeeplinkService.kt`
- Modify `app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt` (фильтр сервисов)
- Modify `app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt` (фильтр сервисов)
**ЗАВИСИМОСТЬ:** нужен телефон с установленной SOVA по adb. Попросить у
пользователя полный `IP:порт` из «Беспроводная отладка».
- [ ] **Шаг 1: Подключить телефон и найти пакет SOVA**
```bash
export PATH="$PATH:/c/Users/nk/AppData/Local/Android/Sdk/platform-tools"
adb connect <IP:порт>
adb shell pm list packages -3 | sort
```
Определить пакет SOVA (как мод VK 6.12 — вероятно `com.vkontakte.android` или
вариант вроде `com.vk.sova`). Записать точное значение как `SOVA_PACKAGE`.
- [ ] **Шаг 2: Узнать, какой URL/схему SOVA перехватывает**
Дамп intent-фильтров пакета:
```bash
adb shell dumpsys package <SOVA_PACKAGE> | grep -iA3 "android.intent.action.VIEW" | grep -iE "scheme|host|vk" | head -30
```
Определить рабочий шаблон поиска. Кандидаты (проверить на устройстве в шаге 4):
- `https://vk.com/audio?q=%s`
- `https://vk.com/search?c[q]=%s&c[section]=audio`
Выбрать `SOVA_SEARCH_URL` — тот, что открывает поиск музыки В приложении.
- [ ] **Шаг 3: Добавить запись SOVA в DeeplinkService**
В `DeeplinkService.kt` добавить запись последней в enum (перед `;`), подставив
значения из шагов 1-2:
```kotlin
SOVA("sova", "SOVA", "<SOVA_SEARCH_URL>", packageName = "<SOVA_PACKAGE>");
```
(Запятую после DEEZER поставить, `;` перенести на строку SOVA.)
- [ ] **Шаг 4: Проверить на устройстве, что открывает поиск в SOVA**
```bash
cd C:/radiOLA && ./gradlew :app:assembleSideloadDebug -q 2>&1 | tail -5; echo built
adb install -r app/build/outputs/apk/sideload/debug/app-sideload-debug.apk
```
Вручную: включить станцию с треком → плеер → кнопка SOVA → убедиться, что
открылся поиск трека В SOVA (не главный экран). Если открывается не то — вернуться
к шагу 2, подобрать другой `SOVA_SEARCH_URL`.
- [ ] **Шаг 5: Отфильтровать SOVA из store-сборки**
SOVA не должна показываться в store. В местах, где строится список сервисов,
исключить SOVA при `!SHOW_DEV_TOOLS`.
В `SettingsScreen.kt` — секция «МУЗЫКАЛЬНЫЕ СЕРВИСЫ», заменить
`DeeplinkService.entries.forEachIndexed { index, service ->` на проход по
отфильтрованному списку. Перед циклом добавить:
```kotlin
val services = DeeplinkService.entries.filter {
com.radiola.BuildConfig.SHOW_DEV_TOOLS || it != DeeplinkService.SOVA
}
```
и использовать `services.forEachIndexed { index, service ->` и
`services.size - 1` в условии разделителя.
В `PlayerBottomSheet.kt``servicesSection` использует `enabledServices` (из
настроек). Поскольку в store SOVA нельзя включить (её нет в списке настроек),
дополнительно подстраховаться: там, где формируется `enabledServices` во
`PlayerViewModel` (фильтр `DeeplinkService.entries.filter { it.serviceId in ids }`),
добавить тот же флаг. Найти место:
```bash
grep -rn "DeeplinkService.entries.filter" app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
```
и заменить на:
```kotlin
_enabledServices.value = DeeplinkService.entries.filter {
it.serviceId in ids &&
(com.radiola.BuildConfig.SHOW_DEV_TOOLS || it != DeeplinkService.SOVA)
}
```
- [ ] **Шаг 6: Проверить оба флейвора**
```bash
cd C:/radiOLA && ./gradlew :app:compileStoreDebugKotlin :app:compileSideloadDebugKotlin -q 2>&1 | tail -10; echo done
```
Ожидаемо: без ошибок. (Ручная проверка: в store-сборке SOVA нет в настройках;
в sideload — есть.)
- [ ] **Шаг 7: Коммит**
```bash
cd C:/radiOLA
git add app/src/main/java/com/radiola/domain/model/DeeplinkService.kt app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
git commit -m "feat(deeplink): кнопка поиска в SOVA (только sideload)"
```
---
## ФАЗА 5 — Финальная сборка, проверка, релиз sideload
### Задача 12: Поднять версию и собрать оба release-флейвора
**Files:** Modify `app/build.gradle.kts` (versionCode/Name).
- [ ] **Шаг 1: Поднять версию**
В `app/build.gradle.kts`: `versionCode = 6``7`, `versionName = "1.5"``"1.6"`.
- [ ] **Шаг 2: Чистая сборка обоих release-флейворов**
```bash
cd C:/radiOLA && ./gradlew clean :app:assembleStoreRelease :app:assembleSideloadRelease -q 2>&1 | tail -12; echo done
ls -la app/build/outputs/apk/store/release/ app/build/outputs/apk/sideload/release/
```
Ожидаемо: оба APK собраны и подписаны release-ключом.
- [ ] **Шаг 3: Проверить критерии (разрешения + BuildConfig)**
```bash
AAPT="$ANDROID_HOME/build-tools/34.0.0/aapt"
echo "=== store: НЕТ REQUEST_INSTALL_PACKAGES ==="
"$AAPT" dump permissions app/build/outputs/apk/store/release/app-store-release.apk | grep -i INSTALL_PACKAGES || echo "OK — нет"
echo "=== store BuildConfig ==="
grep -E "ENABLE_SELF_UPDATE|SHOW_DEV_TOOLS" app/build/generated/source/buildConfig/store/release/com/radiola/BuildConfig.java
```
Ожидаемо: store — нет INSTALL_PACKAGES, `ENABLE_SELF_UPDATE=false`, `SHOW_DEV_TOOLS=false`.
- [ ] **Шаг 4: Коммит**
```bash
cd C:/radiOLA
git add app/build.gradle.kts
git commit -m "chore(app): bump версии до 7 / 1.6 (RuStore-релиз)"
git push
```
---
### Задача 13: Выпустить sideload-сборку через авто-обновление
(Чтобы текущие sideload-пользователи получили https-версию. Процесс — см.
память radiola-autoupdate: clean-сборка обязательна.)
- [ ] **Шаг 1: SHA256 sideload-release APK**
```bash
sha256sum app/build/outputs/apk/sideload/release/app-sideload-release.apk
```
Записать sha.
- [ ] **Шаг 2: Залить APK и обновить манифест версии**
```bash
scp app/build/outputs/apk/sideload/release/app-sideload-release.apk ru-server:/opt/radiola/appdist/downloads/radiola-latest.apk
ssh ru-server 'cd /opt/radiola/appdist && sha256sum downloads/radiola-latest.apk'
```
Сверить sha с шагом 1. Затем обновить `/opt/radiola/appdist/app-version.json`:
`version_code:7`, `version_name:"1.6"`, новый `sha256`, `download_url` (уже https),
`notes` про переезд на https / новые возможности.
- [ ] **Шаг 3: Проверить манифест по https**
```bash
curl -s https://api.radiola.nexaweb.su/app-version
```
Ожидаемо: `version_code:7`, sha совпадает с залитым APK.
---
### Задача 14: Действия пользователя в консоли RuStore
(Не код. Сопроводить пользователя; отметить выполнение.)
- [ ] Создать аккаунт разработчика RuStore.
- [ ] Загрузить `app/build/outputs/apk/store/release/app-store-release.apk`.
- [ ] Категория «Музыка и аудио», возрастной рейтинг 12+.
- [ ] Скриншоты, иконка 512×512, описание.
- [ ] Ссылка на политику: `https://api.radiola.nexaweb.su/privacy`.
- [ ] Заметка модератору: пояснить `USE_EXACT_ALARM` (будильник с радио) и
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (фоновое воспроизведение в машине).
---
## Критерии готовности (проверяются по завершении)
1. `:app:assembleStoreRelease` — подписанный APK без `REQUEST_INSTALL_PACKAGES`, флаги false.
2. `:app:assembleSideloadRelease` — апдейтер на месте, SOVA и тестер присутствуют.
3. `https://api.radiola.nexaweb.su/app-version` — валидный TLS, version_code 7.
4. store-сборка работает против https-API (станции, now-playing, авторизация, Shazam).
5. `https://api.radiola.nexaweb.su/privacy` открывается.
6. Секреты (keystore, пароли) не в git (`git status` чист).
7. store: нет секции «Тестирование станций» и кнопки SOVA; sideload: есть.
8. Тумблера «Запись эфира» нет; запись эфира работает (кнопка плеера + вкладка).

View File

@@ -0,0 +1,244 @@
# Подготовка radiOLA к публикации в RuStore — дизайн
Дата: 2026-06-08
Статус: согласован (дизайн), ждёт ревью спека → план реализации.
## Цель
Подготовить Android-приложение radiOLA (Kotlin/Compose, `applicationId=com.radiola`)
к публикации в **RuStore**, не сломав текущий сайдлоад-канал. Привести проект в
соответствие требованиям магазина: убрать самостоятельную установку APK из
store-сборки, добавить релизную подпись, перевести API на HTTPS, оформить политику
конфиденциальности.
## Принятые решения
1. **Два product flavor** (`store` для RuStore, `sideload` — текущий канал с авто-апдейтером).
2. **Новый release-keystore**, генерирует пользователь (пароли у него, в git не уходят).
3. **HTTPS для API** через существующий хостовый Caddy на сервере `121.127.37.212`.
4. Домен API: **`api.radiola.nexaweb.su`** (A-запись → `121.127.37.212`).
5. **Политику конфиденциальности** пишем сами, хостим статикой на API-домене.
6. Подаём **APK** (не AAB); `isMinifyEnabled=false` (не включаем proguard сейчас).
---
## A. Структура сборки — product flavors
`app/build.gradle.kts`:
```kotlin
flavorDimensions += "distribution"
productFlavors {
create("store") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "false")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "false")
}
create("sideload") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "true")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "true")
}
}
```
- Один и тот же `applicationId=com.radiola` в обоих флейворах (разные каналы, не
ставятся одновременно на одно устройство — это норм).
- Сборки: `:app:assembleStoreRelease`, `:app:assembleSideloadDebug` и т.д.
### Изоляция апдейтера от store-сборки
- Разрешение `REQUEST_INSTALL_PACKAGES` переносится из главного манифеста в
**`app/src/sideload/AndroidManifest.xml`** (manifest merger добавит его только в
sideload). В `src/store/` этого разрешения нет.
- Вызов проверки обновления в `MainActivity` оборачивается:
`if (BuildConfig.ENABLE_SELF_UPDATE) { updateManager.checkUpdate(...) }`.
- Класс `com.radiola.update.UpdateManager` и `ui/update/*` остаются в `main`
(компилируются в обе сборки), но в store никогда не вызываются и разрешений не
просят. (Дешевле и безопаснее, чем дробить sourceSet'ы и ломать компиляцию
MainActivity.)
- `FileProvider` в манифесте можно оставить в `main` (безвреден без апдейтера) —
он используется только установкой APK, которая в store не запускается.
## B. Релизная подпись
`app/build.gradle.kts`:
```kotlin
// до android { }
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = java.util.Properties().apply {
if (keystorePropsFile.exists()) load(keystorePropsFile.inputStream())
}
// внутри android { }
signingConfigs {
create("release") {
if (keystorePropsFile.exists()) {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
// ... existing proguard config, isMinifyEnabled=false
}
}
```
- `keystore.properties` и `*.jks` добавить в `.gitignore`.
- Команда генерации (выполняет пользователь, пароли свои):
```
keytool -genkeypair -v -keystore radiola-release.jks \
-alias radiola -keyalg RSA -keysize 2048 -validity 10000
```
- `keystore.properties` (локально, не в git):
```
storeFile=../radiola-release.jks
storePassword=...
keyAlias=radiola
keyPassword=...
```
- ⚠️ Keystore — навсегда: его потеря = невозможность выпускать обновления. Бэкап.
## C. HTTPS для API
### DNS
A-запись `api.radiola.nexaweb.su → 121.127.37.212` (добавляет пользователь в
DNS-зоне nexaweb.su). Должна резолвиться до выпуска сертификата Caddy.
### Сервер (Caddy уже на хосте, слушает 80/443)
Добавить site-блок в Caddyfile:
```
api.radiola.nexaweb.su {
reverse_proxy localhost:3000
}
```
- Caddy сам получит TLS-сертификат Let's Encrypt.
- `/downloads/*` (APK авто-обновления) и `/covers/*` поедут по https автоматически.
- Маршрут `/privacy` (см. F) тоже за этим прокси.
- Точное расположение Caddyfile уточнить на сервере при реализации; `caddy reload`.
### Приложение
- `di/AppModule.kt`: `baseUrl("https://api.radiola.nexaweb.su/")` для Retrofit
`@Named("radiola")`. (Сейчас `http://121.127.37.212:3000/`.)
- `UpdateManager` BASE_URL (сайдлоад) → https-домен.
- `usesCleartextTraffic=true` **остаётся** в манифесте — нужно для http-потоков
радио (icecast). API/обложки/APK при этом уже по https.
### Сервер env (docker-compose / .env)
- `PUBLIC_BASE_URL=https://api.radiola.nexaweb.su` (абсолютные ссылки на обложки).
- В `app-version.json`: `download_url` → `https://api.radiola.nexaweb.su/downloads/radiola-latest.apk`.
## D. Чистка манифеста под модерацию
- `REQUEST_INSTALL_PACKAGES` → только `src/sideload/` (см. A).
- `SCHEDULE_EXACT_ALARM` / `USE_EXACT_ALARM` — оставляем (фича будильника с радио).
В заметке модератору пояснить назначение.
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — оставляем (фоновое воспроизведение в
машине/при выключенном экране). Потенциальный флаг — пояснить; готовы убрать,
если модерация потребует.
- Остальные (INTERNET, FOREGROUND_SERVICE_MEDIA_PLAYBACK, POST_NOTIFICATIONS,
WAKE_LOCK, RECEIVE_BOOT_COMPLETED, WRITE_EXTERNAL_STORAGE maxSdk=28) — стандартны.
## E. Политика конфиденциальности
Текст на русском, покрывает:
- какие данные собираем: email (magic-link авторизация), история прослушиваний и
распознанных треков, технические логи ошибок (GlitchTip);
- сторонние сервисы: shazam-api.com (фрагмент аудио для распознавания), Discogs
(обогащение обложек), радиопотоки третьих лиц;
- хранение и удаление (запрос на удаление аккаунта/данных);
- контакты оператора.
Хостинг: статическая страница `https://api.radiola.nexaweb.su/privacy` (через Caddy
или статический роут Nest). URL → в карточку RuStore.
## F. Карточка RuStore (действия пользователя в консоли)
- Аккаунт разработчика RuStore (создаёт пользователь).
- Загрузка `store`-release APK.
- Скриншоты, иконка 512, описание.
- Категория: «Музыка и аудио».
- Возрастной рейтинг: **12+** (возможна ненормативная лирика на потоках).
- Ссылка на политику конфиденциальности.
- Заметка модератору: пояснения по exact-alarm и battery-optimization.
## G. Чистка экрана настроек (`ui/settings/SettingsScreen.kt`)
### G1. Убрать тумблер «Запись эфира» (обе сборки)
Осиротевшая настройка: флаг `RECORDING_ENABLED` сохраняется, но НИГДЕ не читается
(запись эфира работает через кнопку в плеере и вкладку «Запись» независимо).
Удалить:
- секцию «Запись эфира» в `SettingsScreen.kt` (item с `isRecordingEnabled`/`setRecordingEnabled`);
- `isRecordingEnabled`/`setRecordingEnabled` в `SettingsViewModel`;
- `isRecordingEnabled()`/`setRecordingEnabled()` в `SettingsRepository` (интерфейс) и
`SettingsRepositoryImpl` + ключ `RECORDING_ENABLED` в DataStore.
- Сама фича записи (кнопка плеера, `RecordingRepository`, вкладка) НЕ трогается.
### G2. Скрыть «Тестирование станций» в store-сборке
Диагностический dev-инструмент (прогон всех станций: OK/без метаданных/оффлайн/
ошибки + отчёт с HTTP/ICY). В store не нужен. Обернуть секцию «ТЕСТИРОВАНИЕ
СТАНЦИЙ» (и связанный диалог отчёта) в `if (BuildConfig.SHOW_DEV_TOOLS) { ... }`.
В sideload остаётся как сейчас. Код тестирования (`SettingsViewModel.startTesting`,
`StationTestStatus`) остаётся в `main`, просто не показывается в store.
## H. Кнопка дип-линк-поиска в SOVA V RE (только sideload)
SOVA V RE — неофициальный мод клиента ВК (на базе VK 6.12), которым пользователь
пользуется лично. **В RuStore-сборку кнопку НЕ включаем** (риск отклонения за
продвижение пиратского мода) — показываем только в sideload-флейворе (тем же
flavor-механизмом, что и dev-tools/тестер: фильтруем список сервисов).
### Архитектура (расширение дип-линков)
Сейчас `DeeplinkService` = `(serviceId, displayName, searchUrlTemplate)`,
`DeeplinkNavigator.openSearch` строит web-URL и открывает через системный chooser.
Для стороннего приложения нужно открывать **напрямую в его пакете**:
- Добавить в `DeeplinkService` опциональное поле `packageName: String? = null`.
- `DeeplinkNavigator`: если `packageName != null` и приложение установлено —
`intent.setPackage(packageName)` (открыть прямо в нём); если не установлено —
фолбэк (chooser/web ВК) или Toast «Установите SOVA». Для остальных сервисов
поведение не меняется (`packageName == null`).
- Добавить запись `SOVA("sova", "SOVA", <url>, packageName=<пакет SOVA>)`.
### Что уточнить на этапе реализации (нужен телефон по adb)
- **Точный пакет** SOVA (`pm list packages -3` на телефоне). Как мод VK 6.12 —
вероятно `com.vkontakte.android` или вариант.
- **Какой URL/схему** SOVA перехватывает как поиск музыки (мод VK обычно ловит
`https://vk.com/...` и `vk://`). Проверить на устройстве, что кнопка реально
открывает поиск в SOVA, а не главный экран. Шаблон поиска подобрать по факту.
### Гейтинг по флейвору
В store-сборке `SOVA` отфильтровывается из списка сервисов (и в настройках, и в
ряду кнопок плеера). В sideload — присутствует. Механизм — `BuildConfig` флаг
флейвора (как у dev-tools).
## Вне области (YAGNI / не сейчас)
- AAB-сборка (подаём APK).
- proguard/minify (оставляем выключенным).
- Пер-юзер квота и платная подписка на распознавание (отдельная задача).
- Перевод радиопотоков на https (невозможно — это чужие icecast-серверы).
## Риски
- Агрегатор чужих радиопотоков — теоретически модерация может запросить права;
обычно проходит.
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` могут попросить убрать.
- DNS/сертификат: пока A-запись не резолвится, Caddy не выпустит cert (порядок шагов).
## Критерии готовности
1. `:app:assembleStoreRelease` собирает подписанный APK без `REQUEST_INSTALL_PACKAGES`
(проверить `aapt dump permissions`), `ENABLE_SELF_UPDATE=false`.
2. `:app:assembleSideloadRelease`/`Debug` — апдейтер на месте (текущее поведение).
3. `https://api.radiola.nexaweb.su/app-version` отвечает по валидному TLS.
4. Приложение store-сборки работает против https-API (станции, now-playing, авторизация, Shazam).
5. `https://api.radiola.nexaweb.su/privacy` открывается.
6. Секреты (keystore, пароли) не в git.
7. В store-сборке нет секции «Тестирование станций»; в sideload — есть.
8. Тумблера «Запись эфира» нет ни в одной сборке; запись эфира работает.
## Порядок реализации (для плана)
1. Инфра HTTPS: DNS A-запись → Caddy vhost → проверка TLS (api + downloads + covers).
2. Сервер: `PUBLIC_BASE_URL` https, `app-version.json` download_url https, страница /privacy.
3. Gradle: flavors `store`/`sideload` + `signingConfig release` + `.gitignore`.
4. Манифест: `REQUEST_INSTALL_PACKAGES` → sideload sourceSet.
5. Код: gate `checkUpdate()` за `BuildConfig.ENABLE_SELF_UPDATE`; baseUrl/BASE_URL → https.
Чистка настроек: убрать тумблер «Запись эфира» (G1); тестер станций под
`BuildConfig.SHOW_DEV_TOOLS` (G2).
Кнопка SOVA (H): расширить `DeeplinkService`/`DeeplinkNavigator` (packageName),
добавить SOVA, отфильтровать в store. Пакет/схему уточнить на телефоне.
6. Keystore: пользователь генерирует, кладёт `keystore.properties`.
7. Сборка `storeRelease`, проверка критериев готовности.
8. Текст политики конфиденциальности.
9. Пользователь: RuStore-консоль (аккаунт, загрузка, карточка).

File diff suppressed because it is too large Load Diff