Compare commits
22 Commits
bdeb57c2ad
...
feat/boots
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a380d34b | ||
|
|
75d256eda5 | ||
| 1771a5b975 | |||
|
|
91777fc459 | ||
|
|
7a00f53b20 | ||
|
|
3c7ae1eb4c | ||
|
|
d31e5d1119 | ||
|
|
ab09d92b0d | ||
|
|
c75ff8cb9a | ||
|
|
9e729512e9 | ||
|
|
92a7c614c1 | ||
|
|
cbd6451ee0 | ||
|
|
6a21a84b86 | ||
|
|
8dc0d46c40 | ||
|
|
34bd6ab02e | ||
|
|
86b39f9fea | ||
|
|
dbc99bcb10 | ||
|
|
6159cc13cc | ||
|
|
56d96382fa | ||
|
|
4391f3ec33 | ||
|
|
bb40d26621 | ||
|
|
9828bdf8d1 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -26,6 +26,16 @@ app/build/
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# Релизная подпись (секреты — никогда в git)
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Логотип: промежуточные генерации (тяжёлые), ключ — вне репо
|
||||
design/logos/gen/
|
||||
design/logos/ref_*.png
|
||||
|
||||
# Скрэтч-папка (картинки, HTML-эксперименты, мокапы RuStore) — не версионируем
|
||||
tempfiles/
|
||||
# ...кроме дизайн-файла Pencil — он остаётся под версией
|
||||
!tempfiles/radiOLA.pen
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -7,6 +9,12 @@ plugins {
|
||||
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 {
|
||||
namespace = "com.radiola"
|
||||
compileSdk = 34
|
||||
@@ -15,8 +23,8 @@ android {
|
||||
applicationId = "com.radiola"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 5
|
||||
versionName = "1.4"
|
||||
versionCode = 9
|
||||
versionName = "1.8"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
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 {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Авто-обновление: установка скачанного APK -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
|
||||
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
@@ -109,6 +109,19 @@ class MainActivity : ComponentActivity() {
|
||||
val isRecording by playerViewModel.isRecording.collectAsState()
|
||||
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 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) }
|
||||
val updateScope = rememberCoroutineScope()
|
||||
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
|
||||
}
|
||||
@@ -355,10 +370,11 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun maybeRequestBatteryExemption() {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
|
||||
if (pm.isIgnoringBatteryOptimizations(packageName)) return
|
||||
// Спрашиваем один раз на установку, чтобы не надоедать.
|
||||
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
|
||||
if (prefs.getBoolean("battery_opt_asked", false)) return
|
||||
// Спрашиваем один раз на установку, чтобы не надоедать.
|
||||
if (!pm.isIgnoringBatteryOptimizations(packageName) &&
|
||||
!prefs.getBoolean("battery_opt_asked", false)
|
||||
) {
|
||||
prefs.edit().putBoolean("battery_opt_asked", true).apply()
|
||||
runCatching {
|
||||
startActivity(
|
||||
@@ -369,4 +385,49 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ class SettingsRepositoryImpl @Inject constructor(
|
||||
private val SLEEP_TIMER = intPreferencesKey("sleep_timer_minutes")
|
||||
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
|
||||
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
|
||||
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
|
||||
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
|
||||
private val COUNTRY_CODE = stringPreferencesKey("country_code")
|
||||
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 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 suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } }
|
||||
|
||||
|
||||
@@ -15,6 +15,32 @@ object DeeplinkNavigator {
|
||||
val url = service.buildSearchUrl(track.artist, track.song)
|
||||
Log.d("DeeplinkNavigator", "url=$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 {
|
||||
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -123,7 +123,7 @@ object AppModule {
|
||||
@Named("radiolaClient") okHttpClient: OkHttpClient,
|
||||
json: Json
|
||||
): Retrofit = Retrofit.Builder()
|
||||
.baseUrl("http://121.127.37.212:3000/")
|
||||
.baseUrl("https://api.radiola.nexaweb.su/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.radiola.domain.model
|
||||
enum class DeeplinkService(
|
||||
val serviceId: String,
|
||||
val displayName: String,
|
||||
val searchUrlTemplate: String
|
||||
val searchUrlTemplate: String,
|
||||
// Пакет стороннего приложения: если задан — открываем поиск прямо в нём
|
||||
// (setPackage), иначе через системный выбор «Открыть в...».
|
||||
val packageName: String? = null
|
||||
) {
|
||||
YANDEX("yandex", "Яндекс Музыка", "https://music.yandex.ru/search?text=%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"),
|
||||
YOUTUBE_MUSIC("youtube", "YouTube Music", "https://music.youtube.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 {
|
||||
val query = java.net.URLEncoder.encode("$artist $song", "UTF-8")
|
||||
|
||||
@@ -12,8 +12,6 @@ interface SettingsRepository {
|
||||
suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>)
|
||||
fun getEqualizerPreset(): Flow<String>
|
||||
suspend fun setEqualizerPreset(preset: String)
|
||||
fun isRecordingEnabled(): Flow<Boolean>
|
||||
suspend fun setRecordingEnabled(enabled: Boolean)
|
||||
// Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции).
|
||||
fun getPreferredBitrate(): Flow<Int>
|
||||
suspend fun setPreferredBitrate(bitrate: Int)
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
@@ -90,6 +92,31 @@ class PlayerController @Inject constructor(
|
||||
private var retryCount = 0
|
||||
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 {
|
||||
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
|
||||
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
|
||||
@@ -212,7 +239,10 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
private fun scheduleReconnect() {
|
||||
reconnectJob?.cancel()
|
||||
if (retryCount >= 10) return
|
||||
// Пока пользователь хочет играть — пробуем переподключаться бесконечно
|
||||
// (с бэк-оффом до 15с). Раньше сдавались навсегда после 10 попыток (~100с) →
|
||||
// длинный туннель/смена сети глушили радио до ручного перезапуска.
|
||||
if (!intendedToPlay) return
|
||||
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
|
||||
retryCount++
|
||||
reconnectJob = timerScope.launch {
|
||||
@@ -244,6 +274,7 @@ class PlayerController @Inject constructor(
|
||||
|
||||
init {
|
||||
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
|
||||
runCatching { connectivityManager?.registerDefaultNetworkCallback(networkCallback) }
|
||||
}
|
||||
|
||||
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
|
||||
@@ -251,6 +282,7 @@ class PlayerController @Inject constructor(
|
||||
// Новая станция — сбрасываем переподключение предыдущего потока.
|
||||
reconnectJob?.cancel()
|
||||
retryCount = 0
|
||||
intendedToPlay = true
|
||||
_currentStationId.value = stationId
|
||||
_icyTitle.value = null
|
||||
val mediaItem = MediaItem.Builder()
|
||||
@@ -412,17 +444,19 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
// Пауза пользователем — отменяем отложенное переподключение, иначе оно
|
||||
// позже само возобновит воспроизведение.
|
||||
// Пауза пользователем — больше не хотим играть, отменяем переподключение.
|
||||
intendedToPlay = false
|
||||
reconnectJob?.cancel()
|
||||
exoPlayer.pause()
|
||||
}
|
||||
|
||||
fun play() {
|
||||
intendedToPlay = true
|
||||
exoPlayer.play()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
intendedToPlay = false
|
||||
reconnectJob?.cancel()
|
||||
exoPlayer.stop()
|
||||
_currentStationPrefix.value = null
|
||||
@@ -432,6 +466,7 @@ class PlayerController @Inject constructor(
|
||||
fun release() {
|
||||
timerScope.cancel()
|
||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(networkCallback) }
|
||||
sleepSoundPlayer.stop()
|
||||
exoPlayer.release()
|
||||
}
|
||||
|
||||
@@ -108,12 +108,30 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
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 {
|
||||
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 {
|
||||
_currentTrack
|
||||
.filterNotNull()
|
||||
@@ -146,8 +164,20 @@ class PlayerViewModel @Inject constructor(
|
||||
playerController.play(url, station.prefix, station.name, station.id)
|
||||
}
|
||||
viewModelScope.launch { pushHistoryUseCase(station.id) }
|
||||
startNowPlaying(station)
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает опрос now-playing для станции: мгновенный рефреш + цикл раз в 5с
|
||||
* (пока играем) + сбор трека из API (приоритет) и ICY (фолбэк). Вынесено из
|
||||
* play(), чтобы переиспользовать при восстановлении сессии (возврат из фона /
|
||||
* пересоздание ViewModel) — иначе эфир «застывает» на последнем значении.
|
||||
*/
|
||||
private fun startNowPlaying(station: Station) {
|
||||
nowPlayingJob?.cancel()
|
||||
nowPlayingJob = viewModelScope.launch {
|
||||
// Сразу тянем свежий эфир — не ждём первые 5с цикла.
|
||||
launch { nowPlayingRepository.refreshNowPlaying() }
|
||||
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
|
||||
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
|
||||
// каждые 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? {
|
||||
val list = station.qualities
|
||||
|
||||
@@ -50,7 +50,6 @@ fun SettingsScreen(
|
||||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
|
||||
val themePalette by viewModel.themePalette.collectAsState()
|
||||
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
||||
val preferredBitrate by viewModel.preferredBitrate.collectAsState()
|
||||
val isTesting by viewModel.isTesting.collectAsState()
|
||||
val testProgress by viewModel.testProgress.collectAsState()
|
||||
@@ -408,7 +407,11 @@ fun SettingsScreen(
|
||||
.background(colors.surface)
|
||||
.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 trackColor by animateColorAsState(
|
||||
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(
|
||||
color = colors.border,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
@@ -449,51 +452,8 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Запись эфира ---
|
||||
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 {
|
||||
// --- Тестирование станций (dev-инструмент, только в sideload) ---
|
||||
if (com.radiola.BuildConfig.SHOW_DEV_TOOLS) item {
|
||||
SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Column(
|
||||
|
||||
@@ -38,9 +38,6 @@ class SettingsViewModel @Inject constructor(
|
||||
val themePalette: StateFlow<String> = settingsRepository.getThemePalette()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest")
|
||||
|
||||
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
// Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
|
||||
val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
@@ -91,10 +88,6 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { settingsRepository.setThemePalette(id) }
|
||||
}
|
||||
|
||||
fun setRecordingEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun startTesting() {
|
||||
viewModelScope.launch {
|
||||
_isTesting.value = true
|
||||
|
||||
@@ -32,7 +32,7 @@ data class VersionInfo(
|
||||
*/
|
||||
object UpdateManager {
|
||||
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()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
|
||||
13
app/src/sideload/AndroidManifest.xml
Normal file
13
app/src/sideload/AndroidManifest.xml
Normal 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>
|
||||
2
backend
2
backend
Submodule backend updated: 791156f814...e1bceb8bd1
27
design/logos/icon_01.html
Normal file
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
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
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
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
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
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
83
docs/rustore-listing.md
Normal 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`.
|
||||
877
docs/superpowers/plans/2026-06-08-rustore-release.md
Normal file
877
docs/superpowers/plans/2026-06-08-rustore-release.md
Normal 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. Тумблера «Запись эфира» нет; запись эфира работает (кнопка плеера + вкладка).
|
||||
244
docs/superpowers/specs/2026-06-08-rustore-release-design.md
Normal file
244
docs/superpowers/specs/2026-06-08-rustore-release-design.md
Normal 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
Reference in New Issue
Block a user