Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/schedule

# Conflicts:
#	feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/data/remote/ScheduleApi.kt
#	feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/domain/model/ScheduleEntity.kt
This commit is contained in:
2026-02-12 18:27:51 +03:00
16 changed files with 196 additions and 96 deletions

View File

@@ -1,5 +1,5 @@
package ru.fincode.tsudesk.core.network
object NetworkConstants {
object Constants {
const val BASE_URL = "https://tulsu.ru/schedule/queries/"
}

View File

@@ -1,24 +1,58 @@
package ru.fincode.tsudesk.core.network
import retrofit2.HttpException
import retrofit2.Response
import ru.fincode.tsudesk.core.network.model.NetworkError
import ru.fincode.tsudesk.core.network.model.NetworkMeta
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.model.NetworkResult.Success
import java.io.IOException
import java.net.SocketTimeoutException
suspend inline fun <T> safeApiCall(
crossinline block: suspend () -> T
suspend inline fun <T> apiCall(
crossinline block: suspend () -> Response<T>
): NetworkResult<T> {
val startedAt = System.currentTimeMillis()
return try {
NetworkResult.Success(block())
val response = block()
val finishedAt = System.currentTimeMillis()
val raw = response.raw()
val meta = NetworkMeta(
startedAtMillis = startedAt,
finishedAtMillis = finishedAt,
sentAtMillis = raw.sentRequestAtMillis,
receivedAtMillis = raw.receivedResponseAtMillis
)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Success(data = body, meta = meta)
} else {
NetworkResult.Error(
NetworkError.Unknown(
throwable = NullPointerException("Response body is null"),
meta = meta
)
)
}
} else {
NetworkResult.Error(NetworkError.Http(code = response.code(), meta = meta))
}
} catch (t: Throwable) {
NetworkResult.Error(t.toNetworkError())
val meta = NetworkMeta(
startedAtMillis = startedAt,
finishedAtMillis = System.currentTimeMillis()
)
NetworkResult.Error(t.toNetworkError(meta))
}
}
fun Throwable.toNetworkError(): NetworkError = when (this) {
is SocketTimeoutException -> NetworkError.Timeout
is IOException -> NetworkError.NoInternet
is HttpException -> NetworkError.Http(code())
else -> NetworkError.Unknown(this)
fun Throwable.toNetworkError(meta: NetworkMeta): NetworkError = when (this) {
is SocketTimeoutException -> NetworkError.Timeout(meta)
is IOException -> NetworkError.NoInternet(meta)
is HttpException -> NetworkError.Http(code(), meta)
else -> NetworkError.Unknown(this, meta)
}

View File

@@ -1,40 +1,30 @@
package ru.fincode.tsudesk.core.network
package ru.fincode.tsudesk.core.network.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import ru.fincode.tsudesk.core.network.interceptor.DebugInterceptor
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import okhttp3.logging.HttpLoggingInterceptor
import ru.fincode.tsudesk.core.network.interceptor.DebugInterceptor
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
object OkHttpModule {
private const val TIMEOUT = 30L
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
fun provideOkHttpClient(
): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(TIMEOUT, TimeUnit.SECONDS)
.readTimeout(TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(TIMEOUT, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(DebugInterceptor())
.build()
@Provides
fun provideRetrofit(
provider: RetrofitProvider,
client: OkHttpClient
): Retrofit = provider.process(NetworkConstants.BASE_URL, client)
}
}
}

View File

@@ -0,0 +1,24 @@
package ru.fincode.tsudesk.core.network.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import ru.fincode.tsudesk.core.network.Constants
import ru.fincode.tsudesk.core.network.RetrofitProvider
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object RetrofitModule {
@Provides
@Singleton
fun provideRetrofit(
provider: RetrofitProvider,
client: OkHttpClient
): Retrofit =
provider.process(Constants.BASE_URL, client)
}

View File

@@ -1,8 +1,17 @@
package ru.fincode.tsudesk.core.network.model
sealed class NetworkError {
object NoInternet : NetworkError()
object Timeout : NetworkError()
data class Http(val code: Int) : NetworkError()
data class Unknown(val throwable: Throwable) : NetworkError()
}
sealed class NetworkError(open val meta: NetworkMeta) {
data class NoInternet(override val meta: NetworkMeta) : NetworkError(meta)
data class Timeout(override val meta: NetworkMeta) : NetworkError(meta)
data class Http(
val code: Int,
override val meta: NetworkMeta
) : NetworkError(meta)
data class Unknown(
val throwable: Throwable,
override val meta: NetworkMeta
) : NetworkError(meta)
}

View File

@@ -0,0 +1,8 @@
package ru.fincode.tsudesk.core.network.model
data class NetworkMeta(
val startedAtMillis: Long,
val finishedAtMillis: Long,
val sentAtMillis: Long? = null,
val receivedAtMillis: Long? = null
)

View File

@@ -1,6 +1,6 @@
package ru.fincode.tsudesk.core.network.model
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Success<T>(val data: T, val meta: NetworkMeta) : NetworkResult<T>()
data class Error(val error: NetworkError) : NetworkResult<Nothing>()
}
}

View File

@@ -1,8 +1,14 @@
package ru.fincode.tsudesk.core.network.model
inline fun <T, R> NetworkResult<T>.map(
transform: (T) -> R
): NetworkResult<R> = when (this) {
is NetworkResult.Success -> NetworkResult.Success(transform(data))
is NetworkResult.Error -> this
}
transform: (T, NetworkMeta) -> R
): NetworkResult<R> =
when (val source = this) {
is NetworkResult.Success -> NetworkResult.Success(
data = transform(source.data, source.meta),
meta = source.meta
)
is NetworkResult.Error -> NetworkResult.Error(source.error)
}

View File

@@ -1,19 +1,9 @@
package ru.fincode.tsudesk.feature.schedule.data.datasource
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.safeApiCall
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleApi
import ru.fincode.tsudesk.feature.schedule.data.remote.model.LessonDto.ScheduleSearchField.GROUP_P
import ru.fincode.tsudesk.feature.schedule.data.remote.model.LessonDto.ScheduleSearchField.PREP
import ru.fincode.tsudesk.feature.schedule.data.remote.model.ScheduleDto
import javax.inject.Inject
class ScheduleRemoteDataSource @Inject constructor(
private val api: ScheduleApi
) {
suspend fun loadScheduleByGroup(number: String): NetworkResult<ScheduleDto> =
safeApiCall { api.getSchedule(GROUP_P, number) }
suspend fun loadScheduleByTeacher(name: String): NetworkResult<ScheduleDto> =
safeApiCall { api.getSchedule(PREP, name) }
interface ScheduleRemoteDataSource {
suspend fun loadScheduleByGroup(number: String): NetworkResult<ScheduleDto>
suspend fun loadScheduleByTeacher(name: String): NetworkResult<ScheduleDto>
}

View File

@@ -1,21 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.data.datasource
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.model.map
import ru.fincode.tsudesk.feature.schedule.data.mapper.ScheduleDtoToDomainMapper
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.domain.repository.ScheduleRepository
import javax.inject.Inject
class ScheduleRemoteDataSourceImpl @Inject constructor(
private val remote: ScheduleRemoteDataSource,
private val mapper: ScheduleDtoToDomainMapper
) : ScheduleRepository {
override suspend fun loadScheduleByGroup(number: String): NetworkResult<ScheduleEntity> =
remote.loadScheduleByGroup(number).map(mapper::invoke)
override suspend fun loadScheduleByTeacher(name: String): NetworkResult<ScheduleEntity> =
remote.loadScheduleByTeacher(name).map(mapper::invoke)
}

View File

@@ -1,5 +1,6 @@
package ru.fincode.tsudesk.feature.schedule.data.mapper
import ru.fincode.tsudesk.core.network.model.NetworkMeta
import ru.fincode.tsudesk.feature.schedule.data.remote.model.LessonDto
import ru.fincode.tsudesk.feature.schedule.data.remote.model.ScheduleDto
import ru.fincode.tsudesk.feature.schedule.domain.model.LessonEntity
@@ -8,8 +9,11 @@ import javax.inject.Inject
class ScheduleDtoToDomainMapper @Inject constructor() {
operator fun invoke(dto: ScheduleDto): ScheduleEntity =
ScheduleEntity(lessons = dto.map(::mapLesson))
operator fun invoke(dto: ScheduleDto, meta: NetworkMeta): ScheduleEntity =
ScheduleEntity(
lessons = dto.map(::mapLesson),
timestamp = meta.receivedAtMillis ?: meta.finishedAtMillis
)
private fun mapLesson(item: LessonDto): LessonEntity =
LessonEntity(
@@ -22,4 +26,4 @@ class ScheduleDtoToDomainMapper @Inject constructor() {
groupId = item.groups.firstOrNull()?.groupCode.orEmpty(),
type = item.type.trim()
)
}
}

View File

@@ -1,27 +1,18 @@
package ru.fincode.tsudesk.feature.schedule.data.remote
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
import ru.fincode.tsudesk.feature.schedule.data.remote.model.LessonDto
import ru.fincode.tsudesk.feature.schedule.data.remote.model.LessonDto.*
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleApiContract.Path.GET_SCHEDULE_METHOD
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleApiContract.Query.SEARCH_FIELD
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleApiContract.Query.SEARCH_VALUE
import ru.fincode.tsudesk.feature.schedule.data.remote.model.ScheduleDto
interface ScheduleApi {
/**
* Расписание по номеру группы.
* Пример: search_field=GROUP_P&search_value=220631
* https://tulsu.ru/schedule/queries/GetSchedule.php?search_field=GROUP_P&search_value=220631
*
*
* Расписание по ФИО преподавателя (строкой).
* Пример: search_field=PREP&search_value=Набродова Ирина Николаевна
*
* Важно: Retrofit сам URL-энкодит параметр search_value
* https://tulsu.ru/schedule/queries/GetDates.php?search_value=%D0%9D%D0%B0%D0%B1%D1%80%D0%BE%D0%B4%D0%BE%D0%B2%D0%B0%20%D0%98%D1%80%D0%B8%D0%BD%D0%B0%20%D0%9D%D0%B8%D0%BA%D0%BE%D0%BB%D0%B0%D0%B5%D0%B2%D0%BD%D0%B0
*/
@GET("GetSchedule.php")
@GET(GET_SCHEDULE_METHOD)
suspend fun getSchedule(
@Query("search_field") searchField: String, @Query("search_value") searchValue: String
): ScheduleDto
@Query(SEARCH_FIELD) searchField: String,
@Query(SEARCH_VALUE) searchValue: String
): Response<ScheduleDto>
}

View File

@@ -0,0 +1,16 @@
package ru.fincode.tsudesk.feature.schedule.data.remote
object ScheduleApiContract {
object Path {
const val GET_SCHEDULE_METHOD = "GetSchedule.php"
}
object Query {
const val SEARCH_FIELD = "search_field"
const val SEARCH_VALUE = "search_value"
const val FIELD_GROUP = "GROUP_P"
const val FIELD_TEACHER = "PREP"
}
}

View File

@@ -0,0 +1,28 @@
package ru.fincode.tsudesk.feature.schedule.data.remote
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.apiCall
import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleRemoteDataSource
import ru.fincode.tsudesk.feature.schedule.data.remote.model.ScheduleDto
import javax.inject.Inject
class ScheduleRemoteDataSourceImpl @Inject constructor(
private val api: ScheduleApi
) : ScheduleRemoteDataSource {
override suspend fun loadScheduleByGroup(number: String): NetworkResult<ScheduleDto> =
apiCall {
api.getSchedule(
searchField = ScheduleApiContract.Query.FIELD_GROUP,
searchValue = number
)
}
override suspend fun loadScheduleByTeacher(name: String): NetworkResult<ScheduleDto> =
apiCall {
api.getSchedule(
searchField = ScheduleApiContract.Query.FIELD_TEACHER,
searchValue = name
)
}
}

View File

@@ -0,0 +1,20 @@
package ru.fincode.tsudesk.feature.schedule.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleRemoteDataSource
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleRemoteDataSourceImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ScheduleRemoteDataSourceModule {
@Binds
@Singleton
abstract fun bindScheduleRemoteDataSource(
impl: ScheduleRemoteDataSourceImpl
): ScheduleRemoteDataSource
}

View File

@@ -1,7 +1,8 @@
package ru.fincode.tsudesk.feature.schedule.domain.model
data class ScheduleEntity(
val lessons: List<LessonEntity>
val lessons: List<LessonEntity>,
val timestamp: Long
)