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

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