Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 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.android)
Expand All @@ -18,6 +20,13 @@ android {

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

val properties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
properties.load(localPropertiesFile.inputStream())
}
val baseUrl = properties.getProperty("BASE_URL")// ?: "http://10.0.2.2:8080/"
buildConfigField("String", "BASE_URL", "\"$baseUrl\"")
Comment on lines +23 to +29
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

BASE_URL null 안전성 문제를 해결해야 합니다.

local.propertiesBASE_URL 속성이 없으면 baseUrl이 null이 되어 빌드 에러가 발생합니다. 주석 처리된 기본값을 보아 fallback 값을 고려했던 것으로 보입니다.

다음과 같이 수정하여 null 안전성을 확보하세요:

         val properties = Properties()
         val localPropertiesFile = rootProject.file("local.properties")
         if (localPropertiesFile.exists()) {
             properties.load(localPropertiesFile.inputStream())
         }
-        val baseUrl = properties.getProperty("BASE_URL")// ?: "http://10.0.2.2:8080/"
+        val baseUrl = properties.getProperty("BASE_URL") ?: "http://10.0.2.2:8080/"
         buildConfigField("String", "BASE_URL", "\"$baseUrl\"")

또는 더 명확하게 에러를 발생시키려면:

-        val baseUrl = properties.getProperty("BASE_URL")// ?: "http://10.0.2.2:8080/"
+        val baseUrl = properties.getProperty("BASE_URL") 
+            ?: throw GradleException("BASE_URL not found in local.properties")
         buildConfigField("String", "BASE_URL", "\"$baseUrl\"")
📝 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
val properties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
properties.load(localPropertiesFile.inputStream())
}
val baseUrl = properties.getProperty("BASE_URL")// ?: "http://10.0.2.2:8080/"
buildConfigField("String", "BASE_URL", "\"$baseUrl\"")
val properties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
properties.load(localPropertiesFile.inputStream())
}
val baseUrl = properties.getProperty("BASE_URL") ?: "http://10.0.2.2:8080/"
buildConfigField("String", "BASE_URL", "\"$baseUrl\"")
🤖 Prompt for AI Agents
In app/build.gradle.kts around lines 23 to 29, BASE_URL may be null when
local.properties lacks that key; update the code to ensure null-safety by
reading the property with a fallback or explicit error: obtain baseUrl =
properties.getProperty("BASE_URL") ?: "http://10.0.2.2:8080/" (or throw
IllegalStateException("BASE_URL not set in local.properties") if you prefer
failing fast) and then pass that non-null value into buildConfigField so the
build never receives a null BASE_URL.

}

buildTypes {
Expand All @@ -38,6 +47,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

Expand Down Expand Up @@ -74,4 +84,10 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)

// Retrofit & OkHttp
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
}
25 changes: 21 additions & 4 deletions app/src/main/java/com/example/kuit6_android_api/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package com.example.kuit6_android_api

import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
Expand All @@ -32,6 +38,7 @@ class MainActivity : ComponentActivity() {
}
}

@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

Expand All @@ -40,15 +47,23 @@ class MainActivity : ComponentActivity() {

setContent {
KUIT6_Android_APITheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background

val snackBarState = remember { SnackbarHostState() }

Scaffold(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
snackbarHost = {
SnackbarHost(snackBarState)
}
) {
val navController = rememberNavController()

NavGraph(
navController = navController,
startDestination = PostListRoute
startDestination = PostListRoute,
snackBarState = snackBarState
)
}
}
Expand All @@ -71,6 +86,7 @@ class MainActivity : ComponentActivity() {
) == PackageManager.PERMISSION_GRANTED -> {
// 이미 권한이 있음
}

shouldShowRequestPermissionRationale(permission) -> {
// 권한 거부 이력이 있음 - 설명 표시 후 재요청
Toast.makeText(
Expand All @@ -80,6 +96,7 @@ class MainActivity : ComponentActivity() {
).show()
requestPermissionLauncher.launch(permission)
}

else -> {
// 권한 요청
requestPermissionLauncher.launch(permission)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.example.kuit6_android_api.data.api


import com.example.kuit6_android_api.data.model.request.PostCreateRequest
import com.example.kuit6_android_api.data.model.response.BaseResponse
import com.example.kuit6_android_api.data.model.response.PostResponse
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query

interface ApiService {
// 게시글 목록 조회
// http://3.34.136.227:8080/api/posts
@GET(value = "/api/posts")
suspend fun getPosts(): BaseResponse<List<PostResponse>> // suspend fun: 일시 중단 될 수도 있는 함수

// 게시글 생성
// http://3.34.136.227:8080/api/posts
@POST(value = "/api/posts")
suspend fun createPost(
@Query(value = "author") author: String = "anonymous",
@Body request: PostCreateRequest
): BaseResponse<PostResponse>

// 게시글 상세 조회
@GET(value = "/api/posts/{id}")
suspend fun getDetail(
@Path("id") id: Long
): BaseResponse<PostResponse>

// 게시글 수정 [스낵바 - 6주차 미션]
@PUT(value = "/api/posts/{id}")
suspend fun updatePost(
@Path(value = "id") id: Long,
@Body request: PostCreateRequest
): BaseResponse<PostResponse>

// 게시글 삭제 [스낵바 - 6주차 미션]
@DELETE(value = "/api/posts/{id}")
suspend fun deletePost(
@Path("id") id: Long
): BaseResponse<Unit> // 서버가 data 없으면 Unit/Any? 로 받기

// 이미지 업로드
@Multipart
@POST("/api/images/upload")
suspend fun uploadImage(
@Part file: MultipartBody.Part
): BaseResponse<Map<String, String>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.example.kuit6_android_api.data.api

import com.example.kuit6_android_api.BuildConfig
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object RetrofitClient { // 싱글톤

private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}

private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()

private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
Comment on lines +23 to +27
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

직렬화 라이브러리 불일치 문제를 해결해야 합니다.

GsonConverterFactory를 사용하고 있지만, 모든 데이터 모델(AuthorResponse, PostResponse, BaseResponse, PostCreateRequest)은 kotlinx.serialization@SerialName 어노테이션을 사용합니다. Gson은 @SerialName을 인식하지 못하므로 직렬화/역직렬화가 제대로 작동하지 않습니다.

해결 방법 1 (권장): kotlinx-serialization 컨버터 사용

build.gradle.kts에 의존성 추가:

implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")

RetrofitClient 수정:

 package com.example.kuit6_android_api.data.api
 
 import com.example.kuit6_android_api.BuildConfig
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
 import okhttp3.OkHttpClient
 import okhttp3.logging.HttpLoggingInterceptor
 import retrofit2.Retrofit
-import retrofit2.converter.gson.GsonConverterFactory
 import java.util.concurrent.TimeUnit
 
 object RetrofitClient {
 
+    private val json = Json {
+        ignoreUnknownKeys = true
+        coerceInputValues = true
+    }
+
     private val loggingInterceptor = HttpLoggingInterceptor().apply {
         level = HttpLoggingInterceptor.Level.BODY
     }
 
     private val okHttpClient = OkHttpClient.Builder()
         .addInterceptor(loggingInterceptor)
         .connectTimeout(30, TimeUnit.SECONDS)
         .readTimeout(30, TimeUnit.SECONDS)
         .writeTimeout(30, TimeUnit.SECONDS)
         .build()
 
     private val retrofit: Retrofit = Retrofit.Builder()
         .baseUrl(BuildConfig.BASE_URL)
         .client(okHttpClient)
-        .addConverterFactory(GsonConverterFactory.create())
+        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
         .build()
 
     val apiService: ApiService = retrofit.create(ApiService::class.java)
 }

그리고 모든 데이터 클래스에 @Serializable 어노테이션 추가 필요 (다른 파일 코멘트 참조)

해결 방법 2: 모든 데이터 모델을 Gson 어노테이션으로 변경

모든 데이터 모델의 @SerialName@SerializedName으로 변경 (다른 파일 코멘트 참조)

🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt
around lines 23 to 27, the Retrofit instance uses GsonConverterFactory while
your models use kotlinx.serialization @SerialName, causing incorrect
serialization; switch to the kotlinx-serialization converter by adding the
retrofit2-kotlinx-serialization-converter dependency to build.gradle.kts and
replace GsonConverterFactory.create() with the kotlinx serialization converter
configured with your Json instance, and ensure all data classes are annotated
with @Serializable; alternatively (less recommended) convert all @SerialName
annotations to Gson’s @SerializedName and keep GsonConverterFactory.


val apiService: ApiService = retrofit.create(ApiService::class.java)
}
17 changes: 0 additions & 17 deletions app/src/main/java/com/example/kuit6_android_api/data/model/Post.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.kuit6_android_api.data.model.request

import kotlinx.serialization.SerialName

data class PostCreateRequest(
@SerialName(value = "title") val title: String,
@SerialName(value = "content") val content: String,
@SerialName(value = "imageUrl") val imageUrl: String? // nullable
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.kuit6_android_api.data.model.response

import kotlinx.serialization.SerialName

data class AuthorResponse(
@SerialName(value = "id") val id: Long,
@SerialName(value = "username") val username: String,
@SerialName(value = "profileImageUrl") val profileImageUrl: String? // nullable
)
Comment on lines +5 to +9
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

@serializable 어노테이션이 누락되었습니다.

kotlinx.serialization@SerialName을 사용하고 있지만, 데이터 클래스에 @Serializable 어노테이션이 없습니다. 그런데 RetrofitClient.kt에서 GsonConverterFactory를 사용하고 있어서, 현재 설정으로는 @SerialName 어노테이션이 무시됩니다.

다음 중 하나를 선택하여 수정해야 합니다:

  1. 권장: Retrofit 컨버터를 kotlinx-serialization-converter로 변경하고 모든 데이터 클래스에 @Serializable 추가
  2. @SerialName 대신 Gson의 @SerializedName 사용

해결 방법 1 (권장): kotlinx-serialization 사용

build.gradle.kts에 의존성 추가:

implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")

데이터 클래스 수정:

 package com.example.kuit6_android_api.data.model.response
 
 import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
 
+@Serializable
 data class AuthorResponse(
     @SerialName(value = "id") val id: Long,
     @SerialName(value = "username") val username: String,
     @SerialName(value = "profileImageUrl") val profileImageUrl: String?
 )

그리고 RetrofitClient.kt도 수정 필요 (별도 코멘트 참조)

해결 방법 2: Gson 사용

 package com.example.kuit6_android_api.data.model.response
 
-import kotlinx.serialization.SerialName
+import com.google.gson.annotations.SerializedName
 
 data class AuthorResponse(
-    @SerialName(value = "id") val id: Long,
-    @SerialName(value = "username") val username: String,
-    @SerialName(value = "profileImageUrl") val profileImageUrl: String?
+    @SerializedName("id") val id: Long,
+    @SerializedName("username") val username: String,
+    @SerializedName("profileImageUrl") val profileImageUrl: String?
 )
🤖 Prompt for AI Agents
In
app/src/main/java/com/example/kuit6_android_api/data/model/response/AuthorResponse.kt
around lines 5 to 9, the data class uses kotlinx.serialization's @SerialName but
is missing the required @Serializable annotation and the project currently uses
Gson via Retrofit, so @SerialName is being ignored; fix by either (recommended)
adding @Serializable to this data class and switching Retrofit to the
kotlinx-serialization converter (add the kotlin serialization converter and
kotlinx-serialization-json dependencies, update RetrofitClient to use the
KotlinxSerializationConverterFactory) or alternatively replace @SerialName with
Gson's @SerializedName and keep the current Gson converter; apply the same
choice consistently across all models and update imports accordingly.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.kuit6_android_api.data.model.response

import kotlinx.serialization.SerialName

data class BaseResponse<T>(
@SerialName(value = "success") val success: Boolean,
@SerialName(value = "message") val message: String?,
@SerialName(value = "data") val data: T?, //nullable
@SerialName(value = "timestamp") val timestamp: String
)
Comment on lines +5 to +10
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

@serializable 어노테이션이 누락되었습니다.

AuthorResponse.kt와 동일한 문제입니다. @SerialName을 사용하고 있지만 @Serializable 어노테이션이 없고, Retrofit에서는 Gson을 사용하고 있어 직렬화가 제대로 작동하지 않습니다.

kotlinx-serialization 사용 시:

 package com.example.kuit6_android_api.data.model.response
 
 import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
 
+@Serializable
 data class BaseResponse<T>(
     @SerialName(value = "success") val success: Boolean,
     @SerialName(value = "message") val message: String?,
     @SerialName(value = "data") val data: T?,
     @SerialName(value = "timestamp") val timestamp: String
 )

Gson 사용 시:

 package com.example.kuit6_android_api.data.model.response
 
-import kotlinx.serialization.SerialName
+import com.google.gson.annotations.SerializedName
 
 data class BaseResponse<T>(
-    @SerialName(value = "success") val success: Boolean,
-    @SerialName(value = "message") val message: String?,
-    @SerialName(value = "data") val data: T?,
-    @SerialName(value = "timestamp") val timestamp: String
+    @SerializedName("success") val success: Boolean,
+    @SerializedName("message") val message: String?,
+    @SerializedName("data") val data: T?,
+    @SerializedName("timestamp") val timestamp: String
 )
📝 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
data class BaseResponse<T>(
@SerialName(value = "success") val success: Boolean,
@SerialName(value = "message") val message: String?,
@SerialName(value = "data") val data: T?, //nullable
@SerialName(value = "timestamp") val timestamp: String
)
package com.example.kuit6_android_api.data.model.response
import com.google.gson.annotations.SerializedName
data class BaseResponse<T>(
@SerializedName("success") val success: Boolean,
@SerializedName("message") val message: String?,
@SerializedName("data") val data: T?, //nullable
@SerializedName("timestamp") val timestamp: String
)
Suggested change
data class BaseResponse<T>(
@SerialName(value = "success") val success: Boolean,
@SerialName(value = "message") val message: String?,
@SerialName(value = "data") val data: T?, //nullable
@SerialName(value = "timestamp") val timestamp: String
)
package com.example.kuit6_android_api.data.model.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BaseResponse<T>(
@SerialName(value = "success") val success: Boolean,
@SerialName(value = "message") val message: String?,
@SerialName(value = "data") val data: T?, //nullable
@SerialName(value = "timestamp") val timestamp: String
)
🤖 Prompt for AI Agents
In
app/src/main/java/com/example/kuit6_android_api/data/model/response/BaseResponse.kt
around lines 5-10, the data class uses @SerialName but is missing the
@Serializable annotation; add @Serializable above the data class and import
kotlinx.serialization.Serializable so kotlinx-serialization can process the
SerialName annotations (alternatively, if the project uses Gson/Retrofit,
replace @SerialName with Gson's @SerializedName and remove @Serializable to
match the Gson serializer).

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.kuit6_android_api.data.model.response

import kotlinx.serialization.SerialName

data class PostResponse(
@SerialName(value = "id") val id: Long,
@SerialName(value = "title") val title: String,
@SerialName(value = "content") val content: String,
@SerialName(value = "imageUrl") val imageUrl: String?, // nullable
@SerialName(value = "author") val author: AuthorResponse,
@SerialName(value = "createdAt") val createdAt: String,
@SerialName(value = "updatedAt") val updatedAt: String
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.kuit6_android_api.ui.navigation

import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
Expand All @@ -13,7 +14,8 @@ import com.example.kuit6_android_api.ui.post.screen.PostListScreen
@Composable
fun NavGraph(
navController: NavHostController,
startDestination: Any = PostListRoute
startDestination: Any = PostListRoute,
snackBarState: SnackbarHostState
) {
NavHost(
navController = navController,
Expand All @@ -40,7 +42,8 @@ fun NavGraph(
},
onEditClick = { postId ->
navController.navigate(PostEditRoute(postId))
}
},
snackBarState = snackBarState
)
}

Expand All @@ -51,7 +54,8 @@ fun NavGraph(
},
onPostCreated = {
navController.popBackStack()
}
},
snackBarState = snackBarState
)
}

Expand All @@ -65,7 +69,8 @@ fun NavGraph(
},
onPostUpdated = {
navController.popBackStack()
}
},
snackBarState = snackBarState
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.example.kuit6_android_api.data.model.Author
import com.example.kuit6_android_api.data.model.Post
import com.example.kuit6_android_api.data.model.response.AuthorResponse
import com.example.kuit6_android_api.data.model.response.PostResponse
import com.example.kuit6_android_api.util.formatDateTime

@Composable
fun PostItem(
post: Post,
post: PostResponse,
onClick: () -> Unit
) {
Card(
Expand Down Expand Up @@ -153,12 +153,12 @@ fun PostItem(
fun PostItemPreview() {
MaterialTheme {
PostItem(
post = Post(
post = PostResponse(
id = 1,
title = "샘플 게시글 제목",
content = "이것은 샘플 게시글 내용입니다. 미리보기에서는 두 줄까지만 표시됩니다.",
imageUrl = null,
author = Author(1, "testuser", null),
author = AuthorResponse(1, "testuser", null),
createdAt = "2025-10-03T12:00:00",
updatedAt = "2025-10-03T12:00:00"
),
Expand Down
Loading