feat: auth screen with auto-redirect, sync favorites/history with backend

This commit is contained in:
nk
2026-06-02 19:12:07 +03:00
parent d4adb1e7be
commit a83672b455
2934 changed files with 97351 additions and 163 deletions

View File

@@ -2,16 +2,55 @@ package com.radiola.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.radiola.data.local.dao.RecordingDao
import com.radiola.data.local.dao.StationDao
import com.radiola.data.local.dao.TagDao
import com.radiola.data.local.dao.TrackHistoryDao
import com.radiola.data.local.entity.RecordingEntity
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity
import com.radiola.data.local.entity.TrackHistoryEntity
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS tags (name TEXT PRIMARY KEY NOT NULL)")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE stations ADD COLUMN source TEXT NOT NULL DEFAULT 'record'")
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY NOT NULL,
stationName TEXT NOT NULL,
stationId INTEGER NOT NULL,
filePath TEXT NOT NULL,
startTime INTEGER NOT NULL,
endTime INTEGER,
trackName TEXT,
duration INTEGER
)
""".trimIndent()
)
}
}
@Database(
entities = [StationEntity::class, TrackHistoryEntity::class],
version = 1
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
version = 4
)
abstract class AppDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao
abstract fun trackHistoryDao(): TrackHistoryDao
abstract fun tagDao(): TagDao
abstract fun recordingDao(): RecordingDao
}

View File

@@ -0,0 +1,74 @@
package com.radiola.data.local
import android.content.Context
import com.radiola.data.local.dto.LocalGroupDto
import com.radiola.data.local.dto.LocalStationsResponse
import com.radiola.domain.model.Station
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import java.io.BufferedReader
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocalStationDataSource @Inject constructor(
@ApplicationContext private val context: Context,
private val json: Json
) {
private var cachedResponse: LocalStationsResponse? = null
fun loadStations(): List<Station> {
android.util.Log.d("LocalStationDS", "loadStations() called")
val response = getResponse()
android.util.Log.d("LocalStationDS", "Parsed: ${response.stations.size} stations, ${response.groups.size} groups")
val groupMap = response.groups.associateBy { it.id }
return response.stations
.filter { it.enabled && !it.notWorked && it.stream != null }
.map { dto ->
val group = groupMap[dto.groupId]
val prefix = generatePrefix(dto.name)
Station(
id = dto.id,
name = dto.name,
prefix = prefix,
streamUrl = dto.stream!!,
coverUrl = group?.let { generateCoverUrl(it.name, dto.name) } ?: "",
genre = group?.name ?: "",
tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }),
sortOrder = dto.id,
source = "local"
)
}
}
fun loadGroups(): List<LocalGroupDto> {
return getResponse().groups.filter { it.name.isNotBlank() }
}
fun getGroupNames(): List<String> {
return loadGroups().map { it.name }
}
private fun getResponse(): LocalStationsResponse {
android.util.Log.d("LocalStationDS", "getResponse() called")
cachedResponse?.let { return it }
val text = context.assets.open("stations.json").bufferedReader().use(BufferedReader::readText)
val parsed = json.decodeFromString(LocalStationsResponse.serializer(), text)
cachedResponse = parsed
return parsed
}
private fun generatePrefix(name: String): String {
return name.lowercase()
.replace(Regex("[^a-z0-9а-яё]+"), "_")
.trim('_')
.take(30)
}
private fun generateCoverUrl(groupName: String, stationName: String): String {
// Placeholder: return empty for now; Record API will override with real covers when available
return ""
}
}

View File

@@ -0,0 +1,59 @@
package com.radiola.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_prefs")
@Singleton
class TokenDataStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore = context.dataStore
@Volatile
var currentToken: String? = null
private set
companion object {
private val TOKEN_KEY = stringPreferencesKey("jwt_token")
private val USER_ID_KEY = stringPreferencesKey("user_id")
private val USER_EMAIL_KEY = stringPreferencesKey("user_email")
private val USER_NAME_KEY = stringPreferencesKey("user_name")
}
val token: Flow<String?> = dataStore.data.map { it[TOKEN_KEY] }
val userId: Flow<String?> = dataStore.data.map { it[USER_ID_KEY] }
val userEmail: Flow<String?> = dataStore.data.map { it[USER_EMAIL_KEY] }
val userName: Flow<String?> = dataStore.data.map { it[USER_NAME_KEY] }
val isLoggedIn: Flow<Boolean> = token.map { !it.isNullOrBlank() }
suspend fun saveToken(token: String, userId: String, email: String, name: String?) {
currentToken = token
dataStore.edit { prefs ->
prefs[TOKEN_KEY] = token
prefs[USER_ID_KEY] = userId
prefs[USER_EMAIL_KEY] = email
name?.let { prefs[USER_NAME_KEY] = it }
}
}
suspend fun clear() {
currentToken = null
dataStore.edit { it.clear() }
}
suspend fun preload() {
token.collect { currentToken = it }
}
}

View File

@@ -0,0 +1,26 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.radiola.data.local.entity.RecordingEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RecordingDao {
@Query("SELECT * FROM recordings ORDER BY startTime DESC")
fun getAll(): Flow<List<RecordingEntity>>
@Insert
suspend fun insert(recording: RecordingEntity)
@Query("DELETE FROM recordings WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id")
suspend fun updateEndTime(id: Long, endTime: Long, duration: Long)
@Query("SELECT * FROM recordings WHERE id = :id")
suspend fun getById(id: Long): RecordingEntity?
}

View File

@@ -31,4 +31,7 @@ interface StationDao {
@Query("SELECT isFavorite FROM stations WHERE id = :id")
fun isFavorite(id: Int): Flow<Boolean>
@Query("SELECT id FROM stations WHERE isFavorite = 1")
fun getFavoriteIds(): Flow<List<Int>>
}

View File

@@ -0,0 +1,20 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.radiola.data.local.entity.TagEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface TagDao {
@Query("SELECT * FROM tags ORDER BY name")
fun getAll(): Flow<List<TagEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(tags: List<TagEntity>)
@Query("DELETE FROM tags")
suspend fun clearAll()
}

View File

@@ -0,0 +1,44 @@
package com.radiola.data.local.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LocalStationDto(
@SerialName("id") val id: Int,
@SerialName("groupId") val groupId: Int,
@SerialName("name") val name: String,
@SerialName("bitrate") val bitrate: String? = null,
@SerialName("site") val site: String? = null,
@SerialName("stream") val stream: String? = null,
@SerialName("type") val type: String? = null,
@SerialName("iconText") val iconText: String? = null,
@SerialName("textColor") val textColor: String? = null,
@SerialName("bgColor") val bgColor: String? = null,
@SerialName("enabled") val enabled: Boolean = true,
@SerialName("notWorked") val notWorked: Boolean = false,
@SerialName("isNew") val isNew: Boolean = false
)
@Serializable
data class LocalGroupDto(
@SerialName("id") val id: Int,
@SerialName("name") val name: String,
@SerialName("textColor") val textColor: String? = null,
@SerialName("bgColor") val bgColor: String? = null,
@SerialName("info") val info: String? = null,
@SerialName("stations") val stations: List<Int> = emptyList()
)
@Serializable
data class LocalStationsResponse(
@SerialName("stations") val stations: List<LocalStationDto> = emptyList(),
@SerialName("groups") val groups: List<LocalGroupDto> = emptyList(),
@SerialName("config") val config: LocalConfigDto? = null
)
@Serializable
data class LocalConfigDto(
@SerialName("stationsBeforeGroups") val stationsBeforeGroups: Boolean = false,
@SerialName("version") val version: Int = 0
)

View File

@@ -0,0 +1,16 @@
package com.radiola.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "recordings")
data class RecordingEntity(
@PrimaryKey val id: Long,
val stationName: String,
val stationId: Int,
val filePath: String,
val startTime: Long,
val endTime: Long?,
val trackName: String?,
val duration: Long?
)

View File

@@ -13,5 +13,6 @@ data class StationEntity(
val genre: String,
val tags: String,
val sortOrder: Int,
val source: String = "record",
val isFavorite: Boolean = false
)

View File

@@ -0,0 +1,9 @@
package com.radiola.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey val name: String
)

View File

@@ -8,25 +8,26 @@ import com.radiola.domain.model.Track
object ApiMapper {
fun StationDto.toDomain(): Station {
val cover = iconPng ?: iconSvg ?: ""
val cover = iconFillColored ?: bgImageMobile ?: bgImage ?: ""
val stream = stream128 ?: stream320 ?: streamHls ?: "https://air.radiorecord.ru:805/${prefix}_128"
return Station(
id = id,
name = name,
prefix = prefix,
streamUrl = "https://air.radiorecord.ru:805/${prefix}_128",
streamUrl = stream,
coverUrl = cover,
genre = genre ?: "",
tags = emptyList(),
sortOrder = id
genre = tooltip ?: "",
tags = tags.map { it.name },
sortOrder = sort
)
}
fun NowPlayingItemDto.toDomain(): Track {
return Track(
artist = artist,
song = song,
coverUrl = image600 ?: image100,
stationName = prefix
artist = track.artist,
song = track.song,
coverUrl = track.image600 ?: track.image100,
stationName = ""
)
}
}

View File

@@ -0,0 +1,25 @@
package com.radiola.data.remote
import com.radiola.data.local.TokenDataStore
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
class AuthInterceptor @Inject constructor(
private val tokenDataStore: TokenDataStore
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val token = tokenDataStore.currentToken
return if (token != null) {
chain.proceed(
request.newBuilder()
.header("Authorization", "Bearer $token")
.build()
)
} else {
chain.proceed(request)
}
}
}

View File

@@ -0,0 +1,48 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.AuthResponseDto
import com.radiola.data.remote.dto.BackendStationDto
import com.radiola.data.remote.dto.HistoryResponseDto
import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto
import com.radiola.data.remote.dto.UserSettingsDto
import kotlinx.serialization.json.JsonObject
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PATCH
import retrofit2.http.Path
interface RadiolaApi {
@POST("auth/magic-link")
suspend fun requestMagicLink(@Body dto: MagicLinkRequestDto): JsonObject
@POST("auth/verify")
suspend fun verifyMagicLink(@Body dto: MagicLinkVerifyDto): AuthResponseDto
@GET("users/me")
suspend fun getMe(): JsonObject
@GET("users/me/settings")
suspend fun getSettings(): UserSettingsDto
@PATCH("users/me/settings")
suspend fun updateSettings(@Body dto: UserSettingsDto): UserSettingsDto
@GET("users/me/favorites")
suspend fun getFavorites(): List<BackendStationDto>
@POST("users/me/favorites/{stationId}")
suspend fun addFavorite(@Path("stationId") stationId: String): JsonObject
@DELETE("users/me/favorites/{stationId}")
suspend fun removeFavorite(@Path("stationId") stationId: String): JsonObject
@GET("users/me/history")
suspend fun getHistory(): HistoryResponseDto
@POST("users/me/history/{stationId}")
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
}

View File

@@ -0,0 +1,9 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class AuthResponseDto(
val accessToken: String,
val user: UserDto
)

View File

@@ -0,0 +1,18 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class BackendStationDto(
val id: String,
val stationId: Int,
val name: String,
val prefix: String,
val streamUrl: String,
val coverUrl: String? = null,
val genre: String? = null,
val tags: List<String> = emptyList(),
val sortOrder: Int = 0,
val source: String = "local",
val isOnline: Boolean = true
)

View File

@@ -0,0 +1,8 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class FavoritesResponseDto(
val favorites: List<BackendStationDto> = emptyList()
)

View File

@@ -0,0 +1,11 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class HistoryResponseDto(
val items: List<BackendStationDto> = emptyList(),
val total: Int = 0,
val limit: Int = 50,
val offset: Int = 0
)

View File

@@ -0,0 +1,8 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class MagicLinkRequestDto(
val email: String
)

View File

@@ -0,0 +1,9 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class MagicLinkVerifyDto(
val email: String,
val code: String
)

View File

@@ -4,15 +4,20 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class NowPlayingItemDto(
data class TrackDto(
@SerialName("id") val id: Int,
@SerialName("prefix") val prefix: String,
@SerialName("artist") val artist: String,
@SerialName("song") val song: String,
@SerialName("image600") val image600: String? = null,
@SerialName("image100") val image100: String? = null
)
@Serializable
data class NowPlayingItemDto(
@SerialName("id") val id: Int,
@SerialName("track") val track: TrackDto
)
@Serializable
data class NowPlayingResponse(
@SerialName("result") val result: List<NowPlayingItemDto>

View File

@@ -8,12 +8,30 @@ data class StationDto(
@SerialName("id") val id: Int,
@SerialName("title") val name: String,
@SerialName("prefix") val prefix: String,
@SerialName("genre") val genre: String? = null,
@SerialName("icon_png") val iconPng: String? = null,
@SerialName("icon_svg") val iconSvg: String? = null
@SerialName("tooltip") val tooltip: String? = null,
@SerialName("sort") val sort: Int = 0,
@SerialName("bg_image") val bgImage: String? = null,
@SerialName("bg_image_mobile") val bgImageMobile: String? = null,
@SerialName("icon_fill_colored") val iconFillColored: String? = null,
@SerialName("stream_128") val stream128: String? = null,
@SerialName("stream_320") val stream320: String? = null,
@SerialName("stream_hls") val streamHls: String? = null,
@SerialName("tags") val tags: List<TagDto> = emptyList()
)
@Serializable
data class TagDto(
@SerialName("id") val id: Int,
@SerialName("name") val name: String
)
@Serializable
data class StationsResult(
@SerialName("stations") val stations: List<StationDto> = emptyList(),
@SerialName("tags") val tags: List<TagDto> = emptyList()
)
@Serializable
data class StationsResponse(
@SerialName("result") val result: List<StationDto>
@SerialName("result") val result: StationsResult
)

View File

@@ -0,0 +1,10 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class UserDto(
val id: String,
val email: String,
val name: String? = null
)

View File

@@ -0,0 +1,12 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class UserSettingsDto(
val theme: String? = null,
val language: String? = null,
val autoPlay: Boolean? = null,
val showOffline: Boolean? = null,
val sleepTimerMinutes: Int? = null
)

View File

@@ -0,0 +1,62 @@
package com.radiola.data.repository
import com.radiola.data.local.TokenDataStore
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto
import com.radiola.domain.model.User
import com.radiola.domain.repository.AuthRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(
private val api: RadiolaApi,
private val tokenDataStore: TokenDataStore
) : AuthRepository {
override suspend fun requestMagicLink(email: String): Result<Unit> = try {
api.requestMagicLink(MagicLinkRequestDto(email))
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
override suspend fun verifyMagicLink(email: String, code: String): Result<User> = try {
val response = api.verifyMagicLink(MagicLinkVerifyDto(email, code))
tokenDataStore.saveToken(
token = response.accessToken,
userId = response.user.id,
email = response.user.email,
name = response.user.name
)
Result.success(
User(
id = response.user.id,
email = response.user.email,
name = response.user.name
)
)
} catch (e: Exception) {
Result.failure(e)
}
override fun isLoggedIn(): Flow<Boolean> = tokenDataStore.isLoggedIn
override fun currentUser(): Flow<User?> = combine(
tokenDataStore.userId,
tokenDataStore.userEmail,
tokenDataStore.userName
) { id, email, name ->
if (id != null && email != null) {
User(id = id, email = email, name = name)
} else null
}
override suspend fun logout() {
tokenDataStore.clear()
}
}

View File

@@ -18,10 +18,18 @@ class FavoritesRepositoryImpl @Inject constructor(
}
}
override fun getFavoriteIds(): Flow<Set<Int>> {
return db.stationDao().getFavoriteIds().map { it.toSet() }
}
override suspend fun addFavorite(station: Station) {
db.stationDao().setFavorite(station.id, true)
}
override suspend fun addFavorite(stationId: Int) {
db.stationDao().setFavorite(stationId, true)
}
override suspend fun removeFavorite(stationId: Int) {
db.stationDao().setFavorite(stationId, false)
}
@@ -42,6 +50,7 @@ class FavoritesRepositoryImpl @Inject constructor(
coverUrl = coverUrl,
genre = genre,
tags = tags.split(",").filter { it.isNotBlank() },
sortOrder = sortOrder
sortOrder = sortOrder,
source = source
)
}

View File

@@ -13,18 +13,18 @@ class NowPlayingRepositoryImpl @Inject constructor(
private val api: RecordApi
) : NowPlayingRepository {
private val _nowPlaying = MutableStateFlow<Map<String, Track>>(emptyMap())
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
override fun getNowPlaying(stationPrefix: String): Flow<Track?> {
return _nowPlaying.map { it[stationPrefix] }
override fun getNowPlaying(stationId: Int): Flow<Track?> {
return _nowPlaying.map { it[stationId] }
}
override fun getAllNowPlaying(): Flow<Map<String, Track>> = _nowPlaying
override fun getAllNowPlaying(): Flow<Map<Int, Track>> = _nowPlaying
override suspend fun refreshNowPlaying(): Result<Unit> {
return try {
val response = api.getNowPlaying()
val map = response.result.associate { it.prefix to it.toDomain() }
val map = response.result.associate { it.id to it.toDomain() }
_nowPlaying.value = map
Result.success(Unit)
} catch (e: Exception) {

View File

@@ -0,0 +1,163 @@
package com.radiola.data.repository
import android.content.Context
import android.content.Intent
import android.os.Environment
import android.util.Log
import androidx.core.content.ContextCompat
import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.RecordingEntity
import com.radiola.domain.model.Recording
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.repository.RecordingRepository
import com.radiola.service.RecordingService
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import javax.inject.Inject
class RecordingRepositoryImpl @Inject constructor(
private val db: AppDatabase,
private val okHttpClient: OkHttpClient,
@ApplicationContext private val context: Context
) : RecordingRepository {
private val _isRecording = kotlinx.coroutines.flow.MutableStateFlow(false)
override val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
private var recordingJob: Job? = null
private var currentCall: okhttp3.Call? = null
private var currentRecordingId: Long? = null
override fun getRecordings(): Flow<List<Recording>> {
return db.recordingDao().getAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun startRecording(station: Station, track: Track?) {
if (_isRecording.value) return
val id = System.currentTimeMillis()
currentRecordingId = id
val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings")
dir.mkdirs()
val ext = when {
station.streamUrl.contains(".aac", ignoreCase = true) -> "aac"
station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3"
else -> "audio"
}
val safeName = station.name.replace(Regex("[^a-zA-Z0-9а-яА-ЯёЁ]"), "_").take(30)
val file = File(dir, "${safeName}_${id}.$ext")
val entity = RecordingEntity(
id = id,
stationName = station.name,
stationId = station.id,
filePath = file.absolutePath,
startTime = id,
endTime = null,
trackName = track?.let { "${it.artist} - ${it.song}" },
duration = null
)
db.recordingDao().insert(entity)
_isRecording.value = true
// Start foreground service to keep process alive during recording
val serviceIntent = Intent(context, RecordingService::class.java).apply {
putExtra(RecordingService.EXTRA_STATION_NAME, station.name)
}
ContextCompat.startForegroundService(context, serviceIntent)
recordingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
var output: FileOutputStream? = null
try {
val request = Request.Builder().url(station.streamUrl).build()
val call = okHttpClient.newCall(request)
currentCall = call
val response = call.execute()
if (!response.isSuccessful) {
Log.e("RecordingRepo", "HTTP error: ${response.code}")
return@launch
}
output = FileOutputStream(file)
val input = response.body?.byteStream()
if (input == null) {
Log.e("RecordingRepo", "Empty response body")
return@launch
}
val buffer = ByteArray(8192)
var bytesRead: Int
while (isActive) {
bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
}
input.close()
} catch (e: IOException) {
if (e.message?.contains("Canceled") == true) {
Log.d("RecordingRepo", "Recording cancelled normally")
} else {
Log.e("RecordingRepo", "Recording error", e)
}
} finally {
try { output?.close() } catch (_: Exception) {}
}
}
}
override suspend fun stopRecording() {
currentCall?.cancel()
currentCall = null
recordingJob?.cancelAndJoin()
recordingJob = null
_isRecording.value = false
// Stop foreground service
context.stopService(Intent(context, RecordingService::class.java))
currentRecordingId?.let { id ->
val endTime = System.currentTimeMillis()
val duration = endTime - id
try {
db.recordingDao().updateEndTime(id, endTime, duration)
} catch (e: Exception) {
Log.e("RecordingRepo", "Failed to update recording end time", e)
}
}
currentRecordingId = null
}
override suspend fun deleteRecording(id: Long) {
val entity = db.recordingDao().getById(id)
entity?.let {
try { File(it.filePath).delete() } catch (_: Exception) {}
}
db.recordingDao().deleteById(id)
}
private fun RecordingEntity.toDomain(): Recording = Recording(
id = id,
stationName = stationName,
stationId = stationId,
filePath = filePath,
startTime = startTime,
endTime = endTime,
trackName = trackName,
duration = duration
)
}

View File

@@ -32,20 +32,21 @@ class SettingsRepositoryImpl @Inject constructor(
}
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
override suspend fun setLastStationId(id: Int) = dataStore.edit { it[LAST_STATION_ID] = id }
override suspend fun setLastStationId(id: Int) { dataStore.edit { it[LAST_STATION_ID] = id } }
override fun getSleepTimerMinutes(): Flow<Int> = dataStore.data.map { it[SLEEP_TIMER] ?: 30 }
override suspend fun setSleepTimerMinutes(minutes: Int) = dataStore.edit { it[SLEEP_TIMER] = minutes }
override suspend fun setSleepTimerMinutes(minutes: Int) { dataStore.edit { it[SLEEP_TIMER] = minutes } }
override fun getEnabledDeeplinkServices(): Flow<Set<String>> = dataStore.data.map {
it[ENABLED_SERVICES] ?: setOf("yandex", "vk", "spotify", "apple", "youtube")
}
override suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>) =
override suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>) {
dataStore.edit { it[ENABLED_SERVICES] = serviceIds }
}
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 suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
}

View File

@@ -1,20 +1,27 @@
package com.radiola.data.repository
import com.radiola.data.local.LocalStationDataSource
import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.ApiMapper.toDomain
import com.radiola.domain.model.Station
import com.radiola.domain.repository.StationRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class StationRepositoryImpl @Inject constructor(
private val api: RecordApi,
private val db: AppDatabase
private val db: AppDatabase,
private val localDataSource: LocalStationDataSource
) : StationRepository {
private val _tags = MutableStateFlow<List<String>>(emptyList())
override fun getStations(): Flow<List<Station>> {
return db.stationDao().getAll().map { entities ->
entities.map { it.toDomain() }
@@ -22,29 +29,73 @@ class StationRepositoryImpl @Inject constructor(
}
override suspend fun refreshStations(): Result<Unit> {
android.util.Log.d("StationRepo", "refreshStations() called")
return try {
val response = api.getStations()
val entities = response.result.mapIndexed { index, dto ->
val domain = dto.toDomain()
// 1. Load local stations from assets
val localStations = localDataSource.loadStations()
android.util.Log.d("StationRepo", "Loaded ${localStations.size} local stations")
val localGroups = localDataSource.loadGroups()
// 2. Try to enrich with Record API data (covers, streams, tags)
val apiResponse = try { api.getStations() } catch (e: Exception) { null }
val apiStations = apiResponse?.result?.stations ?: emptyList()
val apiTags = apiResponse?.result?.tags?.map { it.name } ?: emptyList()
// 3. Merge: local stations enriched with API data where IDs match
val merged = localStations.map { local ->
val apiStation = apiStations.find { it.id == local.id }
if (apiStation != null) {
val domain = apiStation.toDomain()
local.copy(
coverUrl = domain.coverUrl,
streamUrl = domain.streamUrl,
genre = local.genre, // keep group name for filtering
tags = (local.tags + domain.tags).distinct(), // merge tags
prefix = domain.prefix,
source = "record"
)
} else {
local
}
}
// 4. Save to DB
android.util.Log.d("StationRepo", "Saving ${merged.size} merged stations to DB")
val entities = merged.mapIndexed { index, station ->
StationEntity(
id = domain.id,
name = domain.name,
prefix = domain.prefix,
streamUrl = domain.streamUrl,
coverUrl = domain.coverUrl,
genre = domain.genre,
tags = domain.tags.joinToString(","),
id = station.id,
name = station.name,
prefix = station.prefix,
streamUrl = station.streamUrl,
coverUrl = station.coverUrl,
genre = station.genre,
tags = station.tags.joinToString(","),
sortOrder = index,
source = station.source,
isFavorite = false
)
}
db.stationDao().insertAll(entities)
android.util.Log.d("StationRepo", "Inserted ${entities.size} stations into DB")
// 5. Update tags: group names + API tags
val groupNames = localGroups.map { it.name }.filter { it.isNotBlank() }
val allTags = (groupNames + apiTags).distinct().sorted()
db.tagDao().clearAll()
db.tagDao().insertAll(allTags.map { TagEntity(it) })
_tags.value = allTags
Result.success(Unit)
} catch (e: Exception) {
android.util.Log.e("StationRepo", "refreshStations() failed", e)
Result.failure(e)
}
}
override fun getTags(): Flow<List<String>> {
return _tags.asStateFlow()
}
override fun getStationById(id: Int): Flow<Station?> {
return db.stationDao().getById(id).map { it?.toDomain() }
}
@@ -57,6 +108,7 @@ class StationRepositoryImpl @Inject constructor(
coverUrl = coverUrl,
genre = genre,
tags = tags.split(",").filter { it.isNotBlank() },
sortOrder = sortOrder
sortOrder = sortOrder,
source = source
)
}

View File

@@ -0,0 +1,116 @@
package com.radiola.data.repository
import com.radiola.domain.model.Station
import com.radiola.domain.model.StationTestResult
import com.radiola.domain.model.StationTestStatus
import com.radiola.domain.model.Track
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.Buffer
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StationTester @Inject constructor(
private val okHttpClient: OkHttpClient
) {
suspend fun test(station: Station, nowPlaying: Track?): StationTestResult {
val testClient = okHttpClient.newBuilder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build()
val request = Request.Builder()
.url(station.streamUrl)
.header("Icy-Metadata", "1")
.build()
return try {
testClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return StationTestResult(
stationId = station.id,
stationName = station.name,
streamUrl = station.streamUrl,
status = StationTestStatus.OFFLINE,
httpCode = response.code,
errorMessage = "HTTP ${response.code}"
)
}
val contentType = response.header("Content-Type")
val hasAudio = contentType?.startsWith("audio") == true
val metaint = response.header("icy-metaint")?.toIntOrNull()
val hasIcy = metaint != null
val icyTitle = if (hasIcy && metaint != null) {
readIcyTitle(response, metaint)
} else null
StationTestResult(
stationId = station.id,
stationName = station.name,
streamUrl = station.streamUrl,
status = when {
icyTitle != null || nowPlaying != null -> StationTestStatus.OK
hasAudio -> StationTestStatus.OK_NO_META
else -> StationTestStatus.OK_NO_META
},
httpCode = response.code,
contentType = contentType,
hasIcyMetadata = hasIcy,
icyTitle = icyTitle,
hasNowPlaying = nowPlaying != null,
nowPlayingTrack = nowPlaying?.let { "${it.artist} - ${it.song}" },
errorMessage = null
)
}
} catch (e: IOException) {
StationTestResult(
stationId = station.id,
stationName = station.name,
streamUrl = station.streamUrl,
status = StationTestStatus.OFFLINE,
errorMessage = e.message ?: "Network error"
)
} catch (e: Exception) {
StationTestResult(
stationId = station.id,
stationName = station.name,
streamUrl = station.streamUrl,
status = StationTestStatus.ERROR,
errorMessage = e.message ?: "Unknown error"
)
}
}
private fun readIcyTitle(response: okhttp3.Response, metaint: Int): String? {
return try {
val source = response.body?.source() ?: return null
val buffer = Buffer()
var skipped = 0L
while (skipped < metaint) {
val toSkip = (metaint - skipped).coerceAtMost(8192)
val actual = source.read(buffer, toSkip)
if (actual == -1L) return null
skipped += actual
}
buffer.clear()
val metaLengthByte = source.readByte().toInt() and 0xFF
if (metaLengthByte == 0) return null
val metaBytes = source.readByteArray((metaLengthByte * 16).toLong())
val metadata = String(metaBytes, Charsets.UTF_8).trim('\u0000')
val regex = Regex("StreamTitle='([^']*)'")
regex.find(metadata)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,66 @@
package com.radiola.data.repository
import com.radiola.data.local.TokenDataStore
import com.radiola.data.remote.RadiolaApi
import com.radiola.domain.repository.SyncRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncRepositoryImpl @Inject constructor(
private val api: RadiolaApi,
private val tokenDataStore: TokenDataStore
) : SyncRepository {
private fun isLoggedIn(): Boolean = tokenDataStore.currentToken != null
override suspend fun pushFavorite(stationId: Int): Result<Unit> {
if (!isLoggedIn()) return Result.success(Unit)
return try {
api.addFavorite(stationId.toString())
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun removeFavorite(stationId: Int): Result<Unit> {
if (!isLoggedIn()) return Result.success(Unit)
return try {
api.removeFavorite(stationId.toString())
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun pushHistory(stationId: Int): Result<Unit> {
if (!isLoggedIn()) return Result.success(Unit)
return try {
api.addHistory(stationId.toString())
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun fetchRemoteFavorites(): Result<List<Int>> {
if (!isLoggedIn()) return Result.success(emptyList())
return try {
val stations = api.getFavorites()
Result.success(stations.map { it.stationId })
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun fetchRemoteHistory(): Result<List<Int>> {
if (!isLoggedIn()) return Result.success(emptyList())
return try {
val response = api.getHistory()
Result.success(response.items.map { it.stationId })
} catch (e: Exception) {
Result.failure(e)
}
}
}