Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ dependencies {
implementation(project(":data"))
implementation(project(":core:designsystem"))

implementation(project(":presentation:ai_conversation"))
implementation(project(":presentation:news"))
implementation(project(":presentation:notice"))
implementation(project(":presentation:learning"))
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/com/saegil/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ fun MainScreen() {
Screen.LogList.route,
Screen.Log.route,
) || listOf(
Screen.Learning.route,
// Screen.Learning.route,
Screen.Log.route,
Screen.LogList.route,
).any { prefix -> currentRoute?.startsWith("$prefix/") == true }
Screen.AiConversation.route,
).any { prefix -> currentRoute?.startsWith("$prefix/") == true }

Scaffold(
topBar = {
Expand Down
31 changes: 29 additions & 2 deletions app/src/main/java/com/saegil/android/navigation/NavGraph.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.saegil.android.navigation

import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
Expand All @@ -18,6 +19,10 @@ import com.saegil.notice.notice.NoticeScreen
import com.saegil.onboarding.OnboardingScreen
import com.saegil.splash.SplashScreen
import androidx.core.net.toUri
import com.saegil.ai_conversation.SaegilCharacter
import com.saegil.ai_conversation.aiconversation.AiConversationScreen
import com.saegil.ai_conversation.aiconversationlist.AiConversationListScreen
import com.saegil.learning.learning.components.CharacterEmotion
import com.saegil.news.news.NewsScreen
import com.saegil.news.newsquiz.NewsQuizScreen

Expand All @@ -27,6 +32,28 @@ fun NavGraph(navController: NavHostController, modifier: Modifier) {
val context = LocalContext.current

NavHost(navController = navController, startDestination = Screen.Splash.route) {

composable(Screen.AiConversation.route) {
AiConversationListScreen(
modifier = modifier,
onCharacterClick = { character ->
navController.navigate("${Screen.AiConversation.route}/$character")
Log.d("character", character)
}
)
}
composable(
route = "${Screen.AiConversation.route}/{characterString}",
arguments = listOf(navArgument("characterString") { type = NavType.StringType })
) { backStackEntry ->
val characterString = backStackEntry.arguments?.getString("characterString")
val character = runCatching { SaegilCharacter.valueOf(characterString ?: "") }.getOrNull()

AiConversationScreen(character = character,
navigateToAiConversationList = { navController.popBackStack() }
)
}

composable(Screen.Learning.route) {
LearningListScreen(
modifier = modifier,
Expand Down Expand Up @@ -114,7 +141,7 @@ fun NavGraph(navController: NavHostController, modifier: Modifier) {
composable(Screen.Onboarding.route) {
OnboardingScreen(
navigateToMain = {
navController.navigate(Screen.Learning.route) {
navController.navigate(Screen.AiConversation.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
}
Expand All @@ -128,7 +155,7 @@ fun NavGraph(navController: NavHostController, modifier: Modifier) {
}
},
navigateToMain = {
navController.navigate(Screen.Learning.route) {
navController.navigate(Screen.AiConversation.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
},
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/com/saegil/android/navigation/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import androidx.annotation.DrawableRes
import com.saegil.android.R

sealed class Screen(val route: String, @DrawableRes val icon: Int?, val label: String) {
object AiConversation : Screen("ai_conversation", R.drawable.ic_pencil, "AI 전화 회화")

object Learning : Screen("learning", R.drawable.ic_pencil, "학습")
object Announcement : Screen("announcement", R.drawable.ic_announcement, "공지사항")
object Map : Screen("map", R.drawable.ic_location, "지도")
Expand All @@ -16,6 +18,6 @@ sealed class Screen(val route: String, @DrawableRes val icon: Int?, val label: S
data object Quiz : Screen("quiz", null, "퀴즈")

companion object {
val items = listOf(Learning, Announcement, News, MyPage)
val items = listOf(AiConversation, Announcement, News, MyPage)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
buildConfigField("String", "BASE_URL", "\"${properties.getProperty("base_url")}\"")
buildConfigField("String", "OPEN_AI_API_KEY", "\"${properties.getProperty("openai_api_key")}\"")
}

buildTypes {
Expand Down Expand Up @@ -105,4 +106,10 @@ dependencies {
implementation(libs.androidx.datastore.preferences)

implementation(project(":domain"))//클린아키텍처 도메인 의존

implementation("io.ktor:ktor-client-websockets:2.3.5")
implementation("io.ktor:ktor-client-content-negotiation:2.3.5")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")


}
9 changes: 9 additions & 0 deletions data/src/main/java/com/saegil/data/di/DataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.saegil.data.remote.MapService
import com.saegil.data.remote.NewsService
import com.saegil.data.remote.OAuthService
import com.saegil.data.remote.QuizService
import com.saegil.data.remote.RealTimeService
import com.saegil.data.remote.ScenarioService
import com.saegil.data.remote.SimulationLogService
import com.saegil.data.remote.TextToSpeechService
Expand All @@ -21,6 +22,7 @@ import com.saegil.data.repository.MapRepositoryImpl
import com.saegil.data.repository.NewsRepositoryImpl
import com.saegil.data.repository.OAuthRepositoryImpl
import com.saegil.data.repository.QuizRepositoryImpl
import com.saegil.data.repository.RealTimeRepositoryImpl
import com.saegil.data.repository.ScenarioRepositoryImpl
import com.saegil.data.repository.SimulationLogRepositoryImpl
import com.saegil.data.repository.TextToSpeechRepositoryImpl
Expand All @@ -32,6 +34,7 @@ import com.saegil.domain.repository.MapRepository
import com.saegil.domain.repository.NewsRepository
import com.saegil.domain.repository.OAuthRepository
import com.saegil.domain.repository.QuizRepository
import com.saegil.domain.repository.RealTimeRepository
import com.saegil.domain.repository.ScenarioRepository
import com.saegil.domain.repository.SimulationLogRepository
import com.saegil.domain.repository.TextToSpeechRepository
Expand Down Expand Up @@ -110,6 +113,12 @@ object DataModule {
return UserInfoRepositoryImpl(userInfoService)
}

@Provides
@Singleton
fun provideRealTimeRepository(realTimeService: RealTimeService): RealTimeRepository {
return RealTimeRepositoryImpl(realTimeService)
}

@Provides
@Singleton
fun provideUserPreferenceRepository(interestService: InterestService): UserTopicRepository {
Expand Down
14 changes: 13 additions & 1 deletion data/src/main/java/com/saegil/data/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.saegil.data.remote.AssistantServiceImpl
import com.saegil.data.remote.FeedService
import com.saegil.data.remote.FeedServiceImpl
import com.saegil.data.remote.HttpRoutes.ASSISTANT
import com.saegil.data.remote.HttpRoutes.GET_REALTIME_TOKEN
import com.saegil.data.remote.HttpRoutes.NEWS
import com.saegil.data.remote.HttpRoutes.NEWS_INTERESTS
import com.saegil.data.remote.HttpRoutes.OAUTH_LOGOUT
Expand All @@ -23,6 +24,8 @@ import com.saegil.data.remote.NewsService
import com.saegil.data.remote.NewsServiceImpl
import com.saegil.data.remote.OAuthService
import com.saegil.data.remote.OAuthServiceImpl
import com.saegil.data.remote.RealTimeService
import com.saegil.data.remote.RealTimeServiceImpl
import com.saegil.data.remote.QuizService
import com.saegil.data.remote.QuizServiceImpl
import com.saegil.data.remote.ScenarioService
Expand All @@ -39,6 +42,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
Expand All @@ -60,7 +64,7 @@ object NetworkModule {
fun provideHttpClient(
tokenDataSource: TokenDataSource
): HttpClient {
return HttpClient(Android) {
return HttpClient(CIO) {
install(Logging) {
level = LogLevel.ALL
logger = Logger.SIMPLE
Expand All @@ -83,11 +87,13 @@ object NetworkModule {
TTS,
USER,
ASSISTANT,
GET_REALTIME_TOKEN,
NEWS_INTERESTS,
NEWS
).any { it in path }
}
}
install(io.ktor.client.plugins.websocket.WebSockets)
}
}

Expand Down Expand Up @@ -158,4 +164,10 @@ object NetworkModule {
return QuizServiceImpl(client)
}

@Provides
@Singleton
fun provideRealTimeService(client: HttpClient): RealTimeService {
return RealTimeServiceImpl(client)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.saegil.data.model

import kotlinx.serialization.Serializable

@Serializable
data class RealtimeResponse(
val id: String,
val `object`: String,
val model: String,
val modalities: List<String>,
val instructions: String,
val voice: String,
val input_audio_format: String,
val output_audio_format: String,
val input_audio_transcription: InputAudioTranscription,
val turn_detection: String, // null 허용

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The comment indicates that turn_detection can be null, but its type is a non-nullable String. This will cause a JsonDecodingException if the API returns null for this field. To prevent a crash, the type should be made nullable.

    val turn_detection: String?

val tools: List<String> = emptyList(),
val tool_choice: String,
val temperature: Double,
val speed: Double,
val tracing: String, // "auto"
val max_response_output_tokens: Int,
val client_secret: ClientSecret
){
fun toDomain(): String {
return client_secret.value
}
}

@Serializable
data class InputAudioTranscription(
val model: String
)

//@Serializable
//data class TurnDetection(
// // 현재 예시에선 null이므로 생략 가능. 나중에 구조 생기면 필드 추가
//)
//
//@Serializable
//data class Tool(
// // 현재 예시에선 빈 객체 리스트, 추후 구조 생기면 필드 추가
//)
Comment on lines +35 to +43

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This commented-out code should be removed to improve code clarity and maintainability.


@Serializable
data class ClientSecret(
val value: String,
val expires_at: Long
)
5 changes: 5 additions & 0 deletions data/src/main/java/com/saegil/data/remote/AssistantService.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.saegil.data.remote

import com.saegil.data.model.RealtimeResponse
import com.saegil.data.model.UploadAudioDto
import java.io.File

interface AssistantService {

suspend fun getAssistant(file: File, threadId: String?, scenarioId: Int): UploadAudioDto

suspend fun realtimeAssistant()

suspend fun getRealtimeToken(): String?

}
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
package com.saegil.data.remote

import android.util.Log
import com.saegil.data.model.OrganizationDto
import com.saegil.data.model.RealtimeResponse
import com.saegil.data.model.UploadAudioDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.accept
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.URLBuilder
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import javax.inject.Inject

class AssistantServiceImpl @Inject constructor(
private val client: HttpClient
) : AssistantService {

override suspend fun getAssistant(file: File, threadId: String?, scenarioId: Int): UploadAudioDto {
override suspend fun getAssistant(
file: File,
threadId: String?,
scenarioId: Int
): UploadAudioDto {
val response = client.submitFormWithBinaryData(
url = HttpRoutes.ASSISTANT,
formData = formData {
Expand All @@ -31,4 +48,32 @@ class AssistantServiceImpl @Inject constructor(
}
return response.body()
}

override suspend fun getRealtimeToken(): String? {

val urlBuilder = URLBuilder(HttpRoutes.GET_REALTIME_TOKEN)

val response = client.get(urlBuilder.build()) {
headers {
accept(ContentType.Application.Json)
}
}
val responseBody = response.bodyAsText()
val json = Json.parseToJsonElement(responseBody)

// JSON 구조에서 client_secret.value 접근
val value = json
.jsonObject["client_secret"]
?.jsonObject
?.get("value")
?.jsonPrimitive
?.contentOrNull

Log.d("value", value.toString())
return value.toString()
Comment on lines +61 to +73

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Manual JSON parsing is brittle and can lead to runtime errors if the API response structure changes. It's better to use Ktor's content negotiation feature to deserialize the response into the RealtimeResponse data class automatically. This approach is more type-safe and maintainable.

Additionally, the current implementation return value.toString() is unsafe. If value is null, it returns the string "null", which is a bug. Using response.body<T>() and returning the desired property directly will correctly propagate null values.

Suggested change
val responseBody = response.bodyAsText()
val json = Json.parseToJsonElement(responseBody)
// JSON 구조에서 client_secret.value 접근
val value = json
.jsonObject["client_secret"]
?.jsonObject
?.get("value")
?.jsonPrimitive
?.contentOrNull
Log.d("value", value.toString())
return value.toString()
return try {
response.body<RealtimeResponse>().toDomain()
} catch (e: Exception) {
Log.e("AssistantService", "Failed to get or parse real-time token", e)
null
}

}

override suspend fun realtimeAssistant() {
TODO("Not yet implemented")
}
}
2 changes: 2 additions & 0 deletions data/src/main/java/com/saegil/data/remote/HttpRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ object HttpRoutes {

const val ASSISTANT = "$BASE_URL/api/v2/llm/assistant/upload" // 음성 파일로부터 Assistant 응답 가져오기

const val GET_REALTIME_TOKEN = "$BASE_URL/api/realtime/token"

const val TTS = "$BASE_URL/api/v1/llm/tts"

const val SIMULATION_LOG = "$BASE_URL/api/v1/simulations"
Expand Down
Loading