fix schedule request. Add Moshi and network logger

This commit is contained in:
Shcherbatykh Oleg
2026-02-12 14:55:50 +03:00
parent 04b8164eba
commit 5dac9438fd
27 changed files with 303 additions and 156 deletions

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class App : Application()

View File

@@ -1,21 +1,33 @@
package ru.fincode.tsudesk
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import ru.fincode.tsudesk.feature.schedule.domain.usecase.GetScheduleUseCase
import ru.fincode.tsudesk.feature.schedule.domain.usecase.GetScheduleUseCase.ScheduleType
import javax.inject.Inject
import ru.fincode.tsudesk.core.network.NetworkConstants
import ru.fincode.tsudesk.core.network.RetrofitProvider
import ru.fincode.tsudesk.core.network.NetworkModule
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleApi
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var getScheduleUseCase: GetScheduleUseCase
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
lifecycleScope.launch {
try {
val result = getScheduleUseCase(
ScheduleType.Group("220631")
)
Log.d("TSUDesk", result.isSuccess.toString())
} catch (e: Exception) {
Log.e("TSUDesk", "Error loading schedule", e)
}
}
}
}
}

View File

@@ -42,5 +42,12 @@ dependencies {
api(libs.retrofit)
api(libs.okhttp)
implementation(libs.retrofit.simplexml)
implementation(libs.okhttp.logging)
implementation(libs.converter.gson)
api(libs.moshi)
api(libs.moshiKotlin)
api(libs.retrofitMoshi)
api(libs.retrofit.simplexml)
}

View File

@@ -0,0 +1,18 @@
package ru.fincode.tsudesk.core.network
import okhttp3.Interceptor
import okhttp3.Response
import android.util.Log
class DebugInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
Log.d("NETWORK_DEBUG", "URL: ${request.url}")
Log.d("NETWORK_DEBUG", "Method: ${request.method}")
Log.d("NETWORK_DEBUG", "Headers: ${request.headers}")
return chain.proceed(request)
}
}

View File

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

View File

@@ -8,12 +8,16 @@ import okhttp3.OkHttpClient
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import okhttp3.logging.HttpLoggingInterceptor
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val TIMEOUT_SEC = 30L
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
@Provides
@Singleton
@@ -23,6 +27,7 @@ object NetworkModule {
.readTimeout(TIMEOUT_SEC, TimeUnit.SECONDS)
.writeTimeout(TIMEOUT_SEC, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(DebugInterceptor())
.build()
@Provides

View File

@@ -1,14 +1,24 @@
package ru.fincode.tsudesk.core.network
import com.squareup.moshi.Moshi
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.simplexml.SimpleXmlConverterFactory
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Inject
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import javax.inject.Singleton
@Singleton
class RetrofitProvider @Inject constructor() {
private val moshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
class RetrofitProvider {
fun process(baseUrl: String, client: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(SimpleXmlConverterFactory.create())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}

View File

@@ -2,7 +2,7 @@ package ru.fincode.tsudesk.feature.schedule.data
import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleRemoteDataSource
import ru.fincode.tsudesk.feature.schedule.data.mapper.ScheduleDtoToDomainMapper
import ru.fincode.tsudesk.feature.schedule.domain.model.Schedule
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.domain.repository.ScheduleRepository
import javax.inject.Inject
@@ -10,12 +10,11 @@ class ScheduleRepositoryImpl @Inject constructor(
private val remote: ScheduleRemoteDataSource, private val mapper: ScheduleDtoToDomainMapper
) : ScheduleRepository {
override suspend fun loadScheduleByGroup(groupNumber: String): Schedule {
return mapper.map(remote.loadScheduleByGroup(groupNumber))
}
override suspend fun loadScheduleByTeacher(teacherName: String): Schedule {
return mapper.map(remote.loadScheduleByTeacher(teacherName))
}
override suspend fun loadScheduleByGroup(
groupNumber: String
): Result<ScheduleEntity> = remote.loadScheduleByGroup(groupNumber).map(mapper::invoke)
override suspend fun loadScheduleByTeacher(
name: String
): Result<ScheduleEntity> = remote.loadScheduleByTeacher(name).map(mapper::invoke)
}

View File

@@ -1,10 +1,10 @@
package ru.fincode.tsudesk.feature.schedule.data.datasource
import ru.fincode.tsudesk.feature.schedule.data.local.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleDto
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
interface ScheduleLocalDataSource {
suspend fun getScheduleByGroup(groupNumber: String): ScheduleEntity?
suspend fun getScheduleByTeacherName(teacherName: String): ScheduleEntity?
suspend fun getScheduleByTeacher(teacherName: String): ScheduleEntity?
suspend fun saveSchedule(entity: ScheduleEntity)
}

View File

@@ -1,20 +1,17 @@
package ru.fincode.tsudesk.feature.schedule.data.datasource
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleApi
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleDto
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleXmlParser
import java.io.IOException
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(
private val api: ScheduleApi, private val xmlParser: ScheduleXmlParser
class ScheduleRemoteDataSource @Inject constructor(
private val api: ScheduleApi
) {
suspend fun loadScheduleByGroup(groupNumber: String): ScheduleDto {
val response = api.getScheduleByGroup(groupNumber)
return xmlParser.parse(response.body() ?: throw IOException("Response body is null"))
}
suspend fun loadScheduleByGroup(groupNumber: String): Result<ScheduleDto> =
runCatching { api.getSchedule(GROUP_P, groupNumber) }
suspend fun loadScheduleByTeacher(name: String): ScheduleDto {
val response = api.getScheduleByTeacherName(name)
return xmlParser.parse(response.body() ?: throw IOException("Response body is null"))
}
suspend fun loadScheduleByTeacher(name: String): Result<ScheduleDto> =
runCatching { api.getSchedule(PREP, name) }
}

View File

@@ -1,20 +1,22 @@
package ru.fincode.tsudesk.feature.schedule.data.datasource
import ru.fincode.tsudesk.feature.schedule.data.mapper.ScheduleDtoToDomainMapper
import ru.fincode.tsudesk.feature.schedule.domain.model.Schedule
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.domain.repository.ScheduleRepository
import javax.inject.Inject
class ScheduleRepositoryImpl @Inject constructor(
class ScheduleRemoteDataSourceImpl @Inject constructor(
private val remote: ScheduleRemoteDataSource,
private val mapper: ScheduleDtoToDomainMapper
) : ScheduleRepository {
override suspend fun loadScheduleByGroup(groupNumber: String): Schedule {
return mapper.map(remote.loadScheduleByGroup(groupNumber));
}
override suspend fun loadScheduleByGroup(groupNumber: String): Result<ScheduleEntity> =
remote
.loadScheduleByGroup(groupNumber) // Result<ScheduleDto>
.map(mapper::invoke) // Result<ScheduleEntity>
override suspend fun loadScheduleByTeacher(teacherName: String): Schedule {
return mapper.map(remote.loadScheduleByTeacher(teacherName));
}
override suspend fun loadScheduleByTeacher(name: String): Result<ScheduleEntity> =
remote
.loadScheduleByTeacher(name) // Result<ScheduleDto>
.map(mapper::invoke) // Result<ScheduleEntity>
}

View File

@@ -1,4 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.data.local;
public class ScheduleEntity {
}

View File

@@ -1,23 +1,25 @@
package ru.fincode.tsudesk.feature.schedule.data.mapper
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleDto
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.Lesson
import ru.fincode.tsudesk.feature.schedule.domain.model.Schedule
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import javax.inject.Inject
class ScheduleDtoToDomainMapper {
class ScheduleDtoToDomainMapper @Inject constructor() {
fun map(dto: ScheduleDto): Schedule =
Schedule(
lessons = dto.lessons.map { l ->
Lesson(
dayOfWeek = l.dayOfWeek,
dayName = l.dayName,
weekType = l.weekType,
time = l.time,
room = l.room,
subjectName = l.subjectName,
teacherName = l.teacherName
)
}
operator fun invoke(dto: ScheduleDto): ScheduleEntity =
ScheduleEntity(lessons = dto.map(::mapLesson))
private fun mapLesson(item: LessonDto): Lesson =
Lesson(
date = item.date.trim(),
time = item.time.trim(),
subject = item.discipline.trim(),
typeName = item.typeName.trim(),
room = item.room.trim(),
teacher = item.teacher.trim(),
groupId = item.groups.firstOrNull()?.groupCode.orEmpty(),
type = item.type.trim()
)
}

View File

@@ -1,18 +1,28 @@
package ru.fincode.tsudesk.feature.schedule.data.remote
import okhttp3.ResponseBody
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.model.ScheduleDto
interface ScheduleApi {
@GET("schedule")
suspend fun getScheduleByGroup(
@Query("group") groupNumber: String
): Response<ResponseBody>
@GET("schedule")
suspend fun getScheduleByTeacherName(
@Query("fio") teacherName: String
): Response<ResponseBody>
/**
* Расписание по номеру группы.
* Пример: 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")
suspend fun getSchedule(
@Query("search_field") searchField: String,
@Query("search_value") searchValue: String
): ScheduleDto
}

View File

@@ -1,15 +1,38 @@
package ru.fincode.tsudesk.feature.schedule.data.remote
package ru.fincode.tsudesk.feature.schedule.data.remote.model
data class ScheduleDto(
val lessons: List<LessonDto>
)
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
typealias ScheduleDto = List<LessonDto>
@JsonClass(generateAdapter = false) // reflection-адаптер через moshi-kotlin
data class LessonDto(
val dayOfWeek: String,
val dayName: String,
val time: String,
val room: String,
val subjectName: String,
val teacherName: String,
val weekType: Int
)
@Json(name = "DATE_Z")
val date: String, // "12.01.2026"
@Json(name = "TIME_Z")
val time: String, // "11:00 - 13:55"
@Json(name = "DISCIP")
val discipline: String, // предмет
@Json(name = "KOW")
val typeName: String, // "Лекции", "Лабораторные занятия", "Э", "зч", "КР", "ДЗ" и т.п.
@Json(name = "AUD")
val room: String, // можно назвать audience, но ключ в JSON "AUD"
@Json(name = "PREP")
val teacher: String,
@Json(name = "GROUPS")
val groups: List<GroupDto>,
@Json(name = "CLASS")
val type: String // "lecture" / "lab" / "practice" / "default"
) {
data class GroupDto(
@Json(name = "GROUP_P")
val groupCode: String
)
object ScheduleSearchField {
const val GROUP_P = "GROUP_P"
const val PREP = "PREP"
}
}

View File

@@ -0,0 +1,23 @@
package ru.fincode.tsudesk.feature.schedule.data.remote
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import ru.fincode.tsudesk.feature.schedule.data.remote.model.LessonDto
class ScheduleJsonParser {
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val type = Types.newParameterizedType(
List::class.java,
LessonDto::class.java
)
private val adapter = moshi.adapter<List<LessonDto>>(type)
fun parse(json: String): List<LessonDto> =
adapter.fromJson(json)
?: throw IllegalStateException("Schedule JSON is null/invalid")
}

View File

@@ -1,15 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.data.remote
import okhttp3.ResponseBody
interface ScheduleXmlParser {
fun parse(body: ResponseBody): ScheduleDto
}
class ScheduleXmlParserImpl : ScheduleXmlParser {
override fun parse(body: ResponseBody): ScheduleDto {
val xml = body.string()
return ScheduleDto(lessons = emptyList())
}
}

View File

@@ -0,0 +1,19 @@
package ru.fincode.tsudesk.feature.schedule.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import ru.fincode.tsudesk.feature.schedule.data.remote.ScheduleApi
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ScheduleNetworkModule {
@Provides
@Singleton
fun provideScheduleApi(retrofit: Retrofit): ScheduleApi =
retrofit.create(ScheduleApi::class.java)
}

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.ScheduleRepositoryImpl
import ru.fincode.tsudesk.feature.schedule.domain.repository.ScheduleRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ScheduleRepositoryModule {
@Binds
@Singleton
abstract fun bindScheduleRepository(
impl: ScheduleRepositoryImpl
): ScheduleRepository
}

View File

@@ -1,17 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.domain.model
data class Schedule(
val lessons: List<Lesson>
)
data class Lesson(
val dayOfWeek: String,
val dayName: String,
val time: String,
val room: String,
val subjectName: String,
val teacherName: String,
val weekType: Int
)

View File

@@ -0,0 +1,19 @@
package ru.fincode.tsudesk.feature.schedule.domain.model
data class ScheduleEntity(
val lessons: List<Lesson>
)
data class Lesson(
val date: String, // "12.01.2026"
val time: String, // "11:00 - 13:55"
val subject: String, // discipline
val typeName: String, // "Лекции", "Лабораторные занятия" и т.д.
val room: String,
val teacher: String,
val groupId: String,
val type: String // "lecture" / "lab" / "practice" / "default"
)

View File

@@ -1,8 +1,8 @@
package ru.fincode.tsudesk.feature.schedule.domain.repository
import ru.fincode.tsudesk.feature.schedule.domain.model.Schedule
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
interface ScheduleRepository {
suspend fun loadScheduleByGroup(groupNumber: String): Schedule
suspend fun loadScheduleByTeacher(teacherName: String): Schedule
suspend fun loadScheduleByGroup(groupNumber: String): Result<ScheduleEntity>
suspend fun loadScheduleByTeacher(name: String): Result<ScheduleEntity>
}

View File

@@ -1,24 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.domain.repository
import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleLocalDataSource
import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleRemoteDataSource
import ru.fincode.tsudesk.feature.schedule.data.mapper.ScheduleDtoToDomainMapper
import ru.fincode.tsudesk.feature.schedule.domain.model.Schedule
import javax.inject.Inject
class ScheduleRepositoryImpl @Inject constructor(
private val remoteDataSource: ScheduleRemoteDataSource,
private val localDataSource: ScheduleLocalDataSource,
private val mapper: ScheduleDtoToDomainMapper
) : ScheduleRepository {
override suspend fun loadScheduleByGroup(groupNumber: String): Schedule {
val dto = remoteDataSource.loadScheduleByGroup(groupNumber)
return mapper.map(dto)
}
override suspend fun loadScheduleByTeacher(teacherName: String): Schedule {
val dto = remoteDataSource.loadScheduleByTeacher(teacherName)
return mapper.map(dto)
}
}

View File

@@ -1,4 +1,20 @@
package ru.fincode.tsudesk.feature.schedule.domain.usecase
class GetScheduleUseCase {
}
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.domain.repository.ScheduleRepository
import javax.inject.Inject
class GetScheduleUseCase @Inject constructor(
private val repository: ScheduleRepository
) {
sealed interface ScheduleType {
data class Group(val groupNumber: String) : ScheduleType
data class Teacher(val teacherName: String) : ScheduleType
}
suspend operator fun invoke(type: ScheduleType): Result<ScheduleEntity> =
when (type) {
is ScheduleType.Group -> repository.loadScheduleByGroup(type.groupNumber)
is ScheduleType.Teacher -> repository.loadScheduleByTeacher(type.teacherName)
}
}

View File

@@ -6,7 +6,7 @@ versionName = "1.0"
versionCode = "1"
agp = "8.12.0"
kotlin = "2.0.21"
kotlin = "1.9.24"
jvmTarget = "17"
coreKtx = "1.10.1"
@@ -19,6 +19,10 @@ hilt = "2.50"
retrofit = "2.11.0"
okhttp = "4.12.0"
moshi = "1.15.1"
lifecycle = "2.7.0"
coroutines = "1.8.1"
[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
@@ -34,6 +38,18 @@ hiltandroid = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
hiltcompiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
moshi = { group="com.squareup.moshi", name="moshi", version.ref="moshi" }
moshiKotlin = { group="com.squareup.moshi", name="moshi-kotlin", version.ref="moshi" }
retrofitMoshi = { group="com.squareup.retrofit2", name="converter-moshi", version.ref="retrofit" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

View File

@@ -13,12 +13,13 @@ pluginManagement {
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories(fun RepositoryHandler.() {
repositories {
google()
mavenCentral()
})
}
}
rootProject.name = "TSUDesk"
include(":app")