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
15 changes: 15 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.util.Properties

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
Expand All @@ -21,6 +23,12 @@ android {
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

val properties = Properties()
properties.load(project.rootProject.file("local.properties").inputStream())

buildConfigField("String", "SUPABASE_URL", "\"${properties.getProperty("SUPABASE_URL")}\"")
buildConfigField("String", "SUPABASE_ANON_KEY", "\"${properties.getProperty("SUPABASE_ANON_KEY")}\"")
}

buildTypes {
Expand All @@ -38,13 +46,15 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.activity.ktx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
Expand Down Expand Up @@ -75,6 +85,11 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)

// Supabase
implementation(platform(libs.bom))
implementation(libs.postgrest.kt)
implementation(libs.ktor.client.android)

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".GundamApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/example/gundamdexapp/GundamApplication.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.gundamdexapp

import android.app.Application
import com.example.gundamdexapp.data.network.AppContainer

class GundamApplication : Application() {
lateinit var appContainer: AppContainer

override fun onCreate() {
super.onCreate()
appContainer = AppContainer()
}
}
13 changes: 12 additions & 1 deletion app/src/main/java/com/example/gundamdexapp/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import com.example.gundamdexapp.feature.home.GundamdexHomeViewModel
import com.example.gundamdexapp.feature.navigation.GundamdexNavigation
import com.example.gundamdexapp.ui.theme.GundamdexAppTheme
import kotlin.getValue

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

val application = application as GundamApplication
val gundamRepository = application.appContainer.gundamRepository

val gundamdexHomeViewModel by viewModels<GundamdexHomeViewModel> { GundamdexHomeViewModel.Factory }

setContent {
GundamdexAppTheme {
GundamdexNavigation()
GundamdexNavigation(
gundamdexHomeViewModel = gundamdexHomeViewModel,
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.gundamdexapp.data.network

import com.example.gundamdexapp.data.network.repositoryImpl.GundamRepositoryImpl
import com.example.gundamdexapp.domain.repository.GundamRepository

class AppContainer {
val gundamRepository: GundamRepository by lazy {
GundamRepositoryImpl(SupabaseNetwork.gundamApi)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.gundamdexapp.data.network

import com.example.gundamdexapp.BuildConfig
import com.example.gundamdexapp.data.network.api.GundamApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory

object SupabaseNetwork {
// Logcat에서 통신 로그를 보기 위한 설정
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}

private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
Comment on lines +14 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does OkHttp HttpLoggingInterceptor Level.BODY log request headers like Authorization?

💡 Result:

Yes, the OkHttp HttpLoggingInterceptor at Level.BODY will log request headers, including sensitive ones like Authorization, by default [1][2][3]. Because the BODY and HEADERS levels log all request and response headers, they pose a risk of leaking sensitive information such as authentication tokens, cookies, or other private data [2][4][5]. OkHttp explicitly warns that these levels should only be used in controlled or non-production environments to avoid accidental exposure [2][4]. To prevent sensitive headers from being logged, you must explicitly redact them using the redactHeader method [2][4][5]: logging.redactHeader("Authorization"); logging.redactHeader("Cookie"); Once redacted, the interceptor will replace the header's value in the logs with a placeholder (typically "██") rather than printing the actual sensitive data [2][5].

Citations:


릴리스 빌드에서 인증 헤더가 Logcat에 노출될 위험이 있습니다.

HttpLoggingInterceptor.Level.BODY는 요청 및 응답 헤더 전체를 기록합니다. 코드에서 전송하는 apikeyAuthorization 헤더가 로그에 그대로 노출되며, 이는 보안 위험입니다.

해당 로깅 레벨을 디버그 빌드에서만 적용하고 릴리스 빌드에서는 logs 를 끄도록 수정해야 합니다.

🔒 제안 수정
+import com.example.gundamdexapp.BuildConfig
 import okhttp3.logging.HttpLoggingInterceptor
 import okhttp3.OkHttpClient

 object SupabaseNetwork {
     private val loggingInterceptor = HttpLoggingInterceptor().apply {
-        level = HttpLoggingInterceptor.Level.BODY
+        level = if (BuildConfig.DEBUG) {
+            HttpLoggingInterceptor.Level.BODY
+        } else {
+            HttpLoggingInterceptor.Level.NONE
+        }
+        
+        // 디버그 모드에서도 민감 헤더는 암호화하여 노출 방지 (선택적)
+        redactHeader("Authorization")
+        redactHeader("apikey")
     }

     private val okHttpClient = OkHttpClient.Builder()
         .addInterceptor(loggingInterceptor)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
import com.example.gundamdexapp.BuildConfig
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.OkHttpClient
object SupabaseNetwork {
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
// 디버그 모드에서도 민감 헤더는 암호화하여 노출 방지 (선택적)
redactHeader("Authorization")
redactHeader("apikey")
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/example/gundamdexapp/data/network/SupabaseNetwork.kt`
around lines 14 - 20, The current HttpLoggingInterceptor setup in
SupabaseNetwork is logging full headers at BODY level, which can expose apikey
and Authorization values in release builds. Update the logging configuration in
the loggingInterceptor/okHttpClient setup so BODY logging is enabled only for
debug builds and disabled or removed for release builds, using the build-type
check at the point where the OkHttpClient.Builder is configured.


private val json = Json {
ignoreUnknownKeys = true
}

private val retrofit = Retrofit.Builder()
.baseUrl("${BuildConfig.SUPABASE_URL}/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()

val gundamApi: GundamApi = retrofit.create(GundamApi::class.java)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.gundamdexapp.data.network.api

import com.example.gundamdexapp.BuildConfig
import com.example.gundamdexapp.data.network.dto.GundamDto
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query

interface GundamApi {
@GET("rest/v1/gundams")
suspend fun getGundams(
@Header("apikey") apikey: String = BuildConfig.SUPABASE_ANON_KEY,
@Header("Authorization") auth: String = "Bearer ${BuildConfig.SUPABASE_ANON_KEY}",
@Query("select") selectQuery: String = "*, armaments(*)",
): Response<List<GundamDto>>

@GET("rest/v1/gundams")
suspend fun getGundamDetail(
@Header("apikey") apikey: String = BuildConfig.SUPABASE_ANON_KEY,
@Header("Authorization") auth: String = "Bearer ${BuildConfig.SUPABASE_ANON_KEY}",
@Query("id") idFilter: String,
@Query("select") selectQuery: String = "*, armaments(*)",
): Response<List<GundamDto>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.gundamdexapp.data.network.dto

import com.example.gundamdexapp.domain.model.Armament
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ArmamentDto(
@SerialName("gundam_id")
val gundamId: String,
val name: String,
val details: String? = null,
@SerialName("indicator_color")
val indicatorColor: String,
) {
fun toDomain(): Armament = Armament(
name = this.name,
details = this.details ?: "",
indicatorColor = this.indicatorColor,
Comment on lines +13 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

indicatorColor 누락/null 시 역직렬화가 실패할 수 있습니다.

indicatorColor가 non-null이고 기본값도 없어, 서버 응답에서 indicator_color가 누락되거나 null이면 kotlinx.serialization이 예외를 던져 목록 조회 전체가 실패합니다. GundamDto의 다른 필드(및 details)처럼 nullable + fallback로 방어하는 것이 일관적입니다.

🛡️ 제안 수정
     `@SerialName`("indicator_color")
-    val indicatorColor: String,
+    val indicatorColor: String? = null,
 ) {
     fun toDomain(): Armament = Armament(
         name = this.name,
         details = this.details ?: "",
-        indicatorColor = this.indicatorColor,
+        indicatorColor = this.indicatorColor ?: "",
     )
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@SerialName("indicator_color")
val indicatorColor: String,
) {
fun toDomain(): Armament = Armament(
name = this.name,
details = this.details ?: "",
indicatorColor = this.indicatorColor,
`@SerialName`("indicator_color")
val indicatorColor: String? = null,
) {
fun toDomain(): Armament = Armament(
name = this.name,
details = this.details ?: "",
indicatorColor = this.indicatorColor ?: "",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/example/gundamdexapp/data/network/dto/ArmamentDto.kt`
around lines 13 - 19, `ArmamentDto`의 `indicatorColor`가 non-null이라
`indicator_color`가 누락되거나 null일 때 `toDomain()` 전에 역직렬화가 실패합니다. `GundamDto`와
`details` 처리처럼 `indicatorColor`를 nullable로 바꾸고 `toDomain()`에서 기본값으로 fallback하도록
수정해, `ArmamentDto` 및 `ArmamentDto.toDomain()` 경로가 서버 응답 결측에 안전하도록 만드세요.

)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.example.gundamdexapp.data.network.dto

import com.example.gundamdexapp.domain.model.Armaments
import com.example.gundamdexapp.domain.model.Dimensions
import com.example.gundamdexapp.domain.model.GundamInfo
import com.example.gundamdexapp.domain.model.TechnicalSpecifications
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class GundamDto(
val id: String,
@SerialName("model_number")
val modelNumber: String? = null,
val name: String,
val series: String,
@SerialName("image_url")
val imageUrl: String? = null,
val era: String,
val faction: String,
val pilot: String? = null,
val description: String,
@SerialName("dim_weight")
val weight: String? = null,
@SerialName("dim_height")
val height: String? = null,
@SerialName("tech_generator_output")
val generatorOutput: String? = null,
@SerialName("tech_armor_material")
val armorMaterial: String? = null,
@SerialName("tech_total_trust")
val totalTrust: String? = null,
@SerialName("tech_sensor_radius")
val sensorRadius: String? = null,
@SerialName("tech_crew")
val crew: String? = null,
val armaments: List<ArmamentDto> = emptyList(),
) {
fun toDomain(): GundamInfo = GundamInfo(
id = this.id,
modelNumber = this.modelNumber ?: MISSING_TEXT,
name = this.name,
series = this.series,
imageUrl = this.imageUrl ?: MISSING_URL,
era = this.era,
faction = this.faction,
pilot = this.pilot ?: MISSING_TEXT,
description = this.description,
dimensions = Dimensions(
height = this.height ?: MISSING_TEXT,
weight = this.weight ?: MISSING_TEXT,
),
technicalSpecifications = TechnicalSpecifications(
generatorOutput = this.generatorOutput ?: MISSING_TEXT,
armorMaterial = this.armorMaterial ?: MISSING_TEXT,
totalTrust = this.totalTrust ?: MISSING_TEXT,
sensorRadius = this.sensorRadius ?: MISSING_TEXT,
crew = this.crew ?: MISSING_TEXT,
),
armaments = Armaments(this.armaments.map { it.toDomain() }),
)

companion object {
const val MISSING_TEXT = "[ ? ]"
const val MISSING_URL =
"https://imgfiles-cdn.plaync.com/file/BladeNSoul/download/20190802085052-S4BOYrvsgcmithzSaShj0-v4"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.gundamdexapp.data.network.repositoryImpl

import com.example.gundamdexapp.data.network.api.GundamApi
import com.example.gundamdexapp.domain.model.GundamInfo
import com.example.gundamdexapp.domain.repository.GundamRepository

class GundamRepositoryImpl(
private val api: GundamApi,
) : GundamRepository {
override suspend fun getGundamList(): Result<List<GundamInfo>> = try {
val response = api.getGundams()

if (response.isSuccessful) {
val dtoList = response.body() ?: emptyList()

val domainList = dtoList.map { it.toDomain() }
Result.success(domainList)
} else {
Result.failure(Exception("서버 통신 실패 : ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}

override suspend fun getGundamDetail(id: String): Result<List<GundamInfo>> = try {
val response = api.getGundamDetail(idFilter = "eq.$id")

if (response.isSuccessful) {
val gundamDto = response.body() ?: emptyList()

val domainList = gundamDto.map { it.toDomain() }
Result.success(domainList)
} else {
Result.failure(Exception("서버 통신 실패 : ${response.code()}"))
}
Comment on lines +25 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 상세 ViewModel에서 getGundamDetail 결과 처리 방식 확인
rg -nP 'getGundamDetail|\.first\(|\[0\]|getOrNull' --type=kotlin -C3 app/src/main/java/com/example/gundamdexapp/feature/detail

Repository: noeyhoj/Gundamdex

Length of output: 1572


빈 결과 처리 로직 상세 설명

getGundamDetail 구현은 빈 리스트에 대해 firstOrNull() 을 사용하므로 NoSuchElementException 은 발생하지 않습니다. 다만, 빈 데이터를 null 로 전달받아 setUiState 내부에서 IllegalArgumentException 를 던지는 방식이 적용되어 있습니다.

상세 조회 API 가 존재하지 않는 경우 빈 리스트를 성공 응답으로 반환할 때, 이를 IllegalArgumentException 로 처리하는 것이 의도한 동작인지 확인해 주세요. UI 레이어에서 throw 를 사용하여 제어 흐름을 처리하는 것보다 정상적인 UiState 상태 전환 (예: 에러 상태 또는 빈 콘텐츠) 으로 처리하는 것이 클린 아키텍처 관점에서 권장됩니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/example/gundamdexapp/data/network/repositoryImpl/GundamRepositoryImpl.kt`
around lines 25 - 35, `GundamRepositoryImpl.getGundamDetail` is returning an
empty list as a successful result, but the current flow later turns that into
`null` and triggers `IllegalArgumentException` in `setUiState`. Update the
repository/UI contract so empty detail responses are handled explicitly as a
normal state (for example, a failure result or a dedicated empty-content UI
state) instead of relying on `throw` in the UI layer. Check the
`getGundamDetail` repository method and the `setUiState` logic that consumes its
result, and make the state transition consistent with the intended behavior.

} catch (e: Exception) {
Result.failure(e)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.gundamdexapp.domain.repository

import com.example.gundamdexapp.domain.model.GundamInfo

interface GundamRepository {
suspend fun getGundamList(): Result<List<GundamInfo>>
suspend fun getGundamDetail(id: String): Result<List<GundamInfo>>
}

This file was deleted.

Loading