Skip to content

[쇼핑 주문 1, 2 단계] 지오 미션 제출합니다. #101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 84 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from 83 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
51faddb
0단계 - 기본 코드 준비
giovannijunseokim May 21, 2025
1045229
docs: add feature requirements and programming requirements
giovannijunseokim May 27, 2025
49d9925
feat: implement postProducts
jerry8282 May 27, 2025
c61df96
feat: implement getShoppingCart
giovannijunseokim May 27, 2025
6d0f167
feat: implement getProducts
jerry8282 May 27, 2025
41b6fa2
feat: implement how to deserialize ProductResponse
jerry8282 May 27, 2025
29bf4d0
feat: implement http logging interceptor
giovannijunseokim May 27, 2025
4eb567c
refactor: remove in memory data sources
giovannijunseokim May 27, 2025
3d96377
feat: implement paging
giovannijunseokim May 27, 2025
4ade358
refactor: useless LoggingInterceptor
jerry8282 May 27, 2025
58d49c6
refactor: rename ProductResponse -> ProductsResponse
jerry8282 May 27, 2025
a6da981
refactor: implement getProductById()
jerry8282 May 27, 2025
b7bdb5b
feat: use product response at view
giovannijunseokim May 27, 2025
77791b4
refactor: implement postShoppingCartItem
jerry8282 May 27, 2025
522450f
feat: implement getCart
giovannijunseokim May 28, 2025
2df399d
fix: fix how to calculate hasNext
giovannijunseokim May 28, 2025
00e4ec3
refactor: implement loadQuantity
jerry8282 May 28, 2025
bec6e07
fix: fix minusPage()
giovannijunseokim May 28, 2025
c913d17
fix: page start 0
giovannijunseokim May 28, 2025
840daa1
feat: implement deleteShoppingCartItem
giovannijunseokim May 28, 2025
b607dc0
refactor: implement HttpMethod
jerry8282 May 28, 2025
2674b6f
fix: fix infinite API Call by minus page
jerry8282 May 28, 2025
dc107b4
feat: Implement fetch and record recent products
jerry8282 May 28, 2025
6903135
refactor: modify domain model
giovannijunseokim May 28, 2025
c5e3abc
refactor: reorder api methods
giovannijunseokim May 28, 2025
9aca3f2
refactor: implement data sources
giovannijunseokim May 28, 2025
efbc84b
refactor: implement repositories
jerry8282 May 28, 2025
8d3674a
refactor: implement RecentViewedProduct data source
giovannijunseokim May 28, 2025
7577426
refactor: implement CartItemsSize
jerry8282 May 28, 2025
d54d751
refactor: implement initialScreen
jerry8282 May 28, 2025
6cf001b
refactor: implement load more products
giovannijunseokim May 28, 2025
6df3794
refactor: implement plus quantity
giovannijunseokim May 28, 2025
69d38fc
refactor: implement minus quantity
jerry8282 May 28, 2025
85f2891
refactor: implement addCartItem
jerry8282 May 28, 2025
1ff7a9b
refactor: implement add and get latest viewed product
giovannijunseokim May 28, 2025
9a30c9c
refactor: implement ProductDetailActivity result call back
jerry8282 May 28, 2025
2bc6556
refactor: implement onSelectLatestViewedProduct
jerry8282 May 28, 2025
81fcca8
refactor: implement sorting recent view products
jerry8282 May 28, 2025
ead30aa
refactor: rename shoppingCart -> cart
giovannijunseokim May 28, 2025
c2a9bbb
refactor: implement loadCartItems
jerry8282 May 28, 2025
01e5311
refactor: implement plus, minus page
giovannijunseokim May 28, 2025
a591bac
refactor: implement OnCartPaginationListener
giovannijunseokim May 28, 2025
94589aa
refactor: implement always loadProducts
jerry8282 May 28, 2025
617c618
refactor: implement show image in cart
jerry8282 May 28, 2025
ab1dfd1
refactor: remove comment
jerry8282 May 28, 2025
b7db1ee
refactor: implement handle events
jerry8282 May 28, 2025
71d8a57
refactor: add skeleton at activity_products
giovannijunseokim May 28, 2025
87072b5
refactor: add skeleton at activity_product_detail
giovannijunseokim May 28, 2025
d77c0b2
refactor: add skeleton at activity_shopping_cart
jerry8282 May 28, 2025
cb42585
feat: implement encrypted storage for store id, pw
giovannijunseokim May 28, 2025
c7fe647
feat: create Retrofit ProductService
giovannijunseokim May 28, 2025
d2b6a31
feat: create Retrofit CartService
jerry8282 May 28, 2025
951e206
docs: update README
giovannijunseokim May 28, 2025
d90a816
feat: add shoppingCartProductCheckBox
jerry8282 May 28, 2025
725ea71
feat: implement selectedCartItemIds
jerry8282 May 28, 2025
3a714b7
feat: implement updating cart item quantity
giovannijunseokim May 29, 2025
e06ea57
fix: remove oldCartItem before add newCartItem in selectedCartItems
giovannijunseokim May 29, 2025
71fc2af
feat: implement calculating totalPrice
jerry8282 May 29, 2025
c044eac
feat: init RecommendActivity
giovannijunseokim May 29, 2025
d5c1e78
refactor: minimize dto models
giovannijunseokim May 29, 2025
9367d33
feat: add category in domain
giovannijunseokim May 29, 2025
5138b66
feat: implement filter prohibited items
jerry8282 May 29, 2025
38d691b
feat: handle events
jerry8282 May 29, 2025
9ad5719
refactor: remove comment
jerry8282 May 29, 2025
3aa93ab
refactor: changes by renaming
jerry8282 May 29, 2025
e3e7325
feat: connect recyclerView to adapter
jerry8282 May 29, 2025
1a9247e
fix: fix errors
giovannijunseokim May 29, 2025
0869c31
fix: remove broken test
giovannijunseokim May 29, 2025
be9842a
test: test RecentViewedCategoryBasedAlgorithm
giovannijunseokim May 29, 2025
9eca1f9
refactor: remove targetApi
giovannijunseokim May 30, 2025
1063871
refactor: add branch for debug
giovannijunseokim May 30, 2025
5127b3e
refactor: hide BASE_URL
giovannijunseokim May 30, 2025
c3daeb4
refactor: use pageable generic class
giovannijunseokim May 30, 2025
eee920f
refactor: use AuthInterceptor
giovannijunseokim Jun 2, 2025
ad0f799
refactor: use not null type in request
giovannijunseokim Jun 2, 2025
0ea8c93
refactor: handle case that don't have any viewed product
giovannijunseokim Jun 2, 2025
ad1dbe1
refactor: don't override equals, hashCode in data class
giovannijunseokim Jun 2, 2025
0fa2eb0
refactor: use ListAdapter
giovannijunseokim Jun 3, 2025
74f8ee1
refactor: refactor by using databinding in CartProductViewHolder.kt
giovannijunseokim Jun 3, 2025
e3ffeda
feat: implement recommendation
giovannijunseokim Jun 4, 2025
94632e0
feat: implement way to add product at cart
giovannijunseokim Jun 4, 2025
2880fcd
feat: implement way to update product quantity at cart
giovannijunseokim Jun 4, 2025
4093a5c
feat: implement showing cart size and total price
giovannijunseokim Jun 4, 2025
0ac4db4
feat: implement update cart after navigate to recommend view
giovannijunseokim Jun 4, 2025
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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,24 @@
# android-shopping-order
# android-shopping-order

Comment on lines +1 to +2
Copy link
Member

Choose a reason for hiding this comment

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

README 작성 좋습니다👍

Copy link
Member

Choose a reason for hiding this comment

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

참고로 아래와 같은 형태로, PR 에서 봐야할 커밋들을 필터를 걸 수 있습니다!
(필터를 안걸면 모든 코드가 로드 되다 보니... 제 맥이 버거워 하는군요)
PR > 좌측 commit > shift + click > 봐야할 커밋 드래그
PR 필터

Copy link
Author

Choose a reason for hiding this comment

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

그 점을 신경쓰지 못했네요 😅
PR 소개글 맨 위에 추가해두었습니다!

## 기능 요구 사항

- [ ] 데이터가 로딩되기 전 상태에서는 스켈레톤 UI를 노출한다.

## 프로그래밍 요구 사항

- [x] 서버를 연동한다.
- [x] 사용자의 장바구니 목록 조회 API를 연동한다.
- [x] 장바구니 아이템 추가 API를 연동한다.
- [x] 장바구니 아이템 삭제 API를 연동한다.
- [x] 장바구니 아이템 수량 변경 API를 연동한다.
- [x] 장바구니 아이템 수량 조회 API를 연동한다.
- [x] 상품 목록 조회 API를 연동한다.
- [x] 상품 상세 조회 API를 연동한다.
- [ ] 기존에 작성한 테스트가 깨지면 안 된다.
- [x] 사용자 인증 정보를 저장한다. (적절한 저장 방법을 선택한다)
- [x] 장바구니 화면에서 특정 상품만 골라 주문하기 버튼을 누를 수 있다.
- [ ] 별도의 화면에서 상품 추천 알고리즘으로 사용자에게 적절한 상품을 추천해준다. (쿠팡 UX 참고)
- [x] 상품 추천 알고리즘은 최근 본 상품 카테고리를 기반으로 최대 10개 노출한다.
- [x] 해당 카테고리 상품이 10개 미만이라면 해당하는 개수만큼만 노출
- [x] 장바구니에 이미 추가된 상품이라면 미노출
- [ ] 추천된 상품을 해당 화면에서 바로 추가하여 같이 주문할 수 있다.
28 changes: 27 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import java.util.Properties

plugins {
id("kotlin-kapt")
alias(libs.plugins.android.application)
alias(libs.plugins.android.junit5)
alias(libs.plugins.kotlin.android)
id("kotlin-kapt")
alias(libs.plugins.serialization)
}

private val localProperties =
Properties().apply {
val localFile = rootProject.file("local.properties")
if (localFile.exists()) {
load(localFile.inputStream())
}
}

android {
namespace = "woowacourse.shopping"
compileSdk = 35
Expand All @@ -22,12 +33,18 @@ android {
}

buildTypes {
debug {
buildConfigField("boolean", "DEBUG", "true")
buildConfigField("String", "BASE_URL", localProperties["base.url.dev"].toString())
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
buildConfigField("boolean", "DEBUG", "false")
buildConfigField("String", "BASE_URL", localProperties["base.url.release"].toString())
}
}
compileOptions {
Expand All @@ -45,11 +62,13 @@ android {
}
buildFeatures {
dataBinding = true
buildConfig = true
}
}

dependencies {
kapt(libs.androidx.room.compiler)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
Expand All @@ -58,6 +77,13 @@ dependencies {
implementation(libs.google.glide)
implementation(libs.room.runtime)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.logging.interceptor)
implementation(libs.shimmer)
implementation(libs.androidx.security.crypto)
implementation(libs.retrofit)
Comment on lines +83 to +84
Copy link
Member

Choose a reason for hiding this comment

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

libs.androidx.security.crypto를 채택하신 이유가 있을까요~?

Copy link
Author

Choose a reason for hiding this comment

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

사용자의 아이디와 비밀번호를 암호화하여 저장하기 위함입니다! 😁

처음에는 간단한 구조이기 때문에 키-값 형태로 SharedPreferences를 통해 저장했습니다.

하지만 이는 기기 내부에 파일로 저장되어 위험하다는 것을 학습했습니다. 🤔
위 부분을 Device Explorer로 탐색해 찾아보니, 값들이 평문으로 저장되어 있었습니다.

따라서 암호화하여 저장하는 방법을 찾다 보니 EncryptedSharedPreferences를 발견했고, 사용해보았습니다.
이 또한 Device Explorer로 탐색해 찾아보니, 값들이 암호화되어 저장된 것을 확인했습니다. 👍

Copy link
Member

Choose a reason for hiding this comment

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

그렇군요~
간단하게 SharedPreference에는 암호화가 안되어 있어서, 사용을 해보려고 했다면 괜찮을 것 같군요!
실제 서비스라면 이렇게 값에 대해서 로컬에 그대로 넣어두는 방식을 사용하지는 않을 것이라서, 연습용이었다면 일단은 넘어갈 수 있을 것 같습니다😀

implementation(libs.converter.gson)
implementation(libs.retrofit2.kotlinx.serialization.converter)
Comment on lines +80 to +86
Copy link
Member

Choose a reason for hiding this comment

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

아마 프로젝트를 설정할 때부터 version catalogs를 사용하도록 되어 있었을 것 같은데, 이전에는 gradle을 kotlin 이전에 groovy로 적던 시절도 있었어요~
오래된 프로젝트일 수록 마이그레이션이 이뤄지지 않은 경우 groovy, 또는 version catalogs 가 적용이 안된 프로젝트일 때도 있답니다!
이전에는 어떻게 gradle 을 정의했었는지도 추후에 시간이 남을 때 살펴본다면 많은 도움이 될 것 같다는 생각이 듭니다💪

지금 당장 하기에는 너무 과한 내용일 것 같아서 추후에 한번 시간이 남을 때 살펴보시면 좋을 것 같아요

  • groovy -> groovy to kotlin -> buildSrc등 version catalogs 이전 gradle 관리법 -> version catalogs -> custom plugins

testImplementation(libs.assertj.core)
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotest.runner.junit5)
Expand Down
16 changes: 9 additions & 7 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" >
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />

Expand All @@ -10,19 +9,22 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Shopping"
tools:targetApi="31" >
android:theme="@style/Theme.Shopping">
<activity
android:name=".view.recommend.RecommendActivity"
android:exported="false" />
<activity
android:name=".view.productDetail.ProductDetailActivity"
android:exported="true" />
<activity
android:name=".view.shoppingCart.ShoppingCartActivity"
android:name=".view.cart.CartActivity"
android:exported="false" />
<activity
android:name=".view.product.ProductsActivity"
android:exported="true" >
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

Expand All @@ -31,4 +33,4 @@
</activity>
</application>

</manifest>
</manifest>
41 changes: 41 additions & 0 deletions app/src/main/java/woowacourse/shopping/data/API.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package woowacourse.shopping.data

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 woowacourse.shopping.BuildConfig
import woowacourse.shopping.data.cart.service.CartService
import woowacourse.shopping.data.product.service.ProductService

object API {
private val client: OkHttpClient by lazy {
OkHttpClient
.Builder()
.addInterceptor(AuthInterceptor())
.addHttpLoggingInterceptor()
.build()
}

private val retrofit by lazy {
Retrofit
.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(client)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
}

val productService: ProductService = retrofit.create(ProductService::class.java)
val cartService: CartService = retrofit.create(CartService::class.java)

private fun OkHttpClient.Builder.addHttpLoggingInterceptor() =
addInterceptor(
HttpLoggingInterceptor().apply {
level =
if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
},
)
}
27 changes: 27 additions & 0 deletions app/src/main/java/woowacourse/shopping/data/AuthInterceptor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package woowacourse.shopping.data

import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response

class AuthInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val authToken: String = AuthStorage.authToken ?: return chain.proceed(originalRequest)

val request: Request =
originalRequest
.newBuilder()
.addHeader(
AUTH_HEADER_KEY,
authToken,
).build()

val response: Response = chain.proceed(request)
return response
}

companion object {
private const val AUTH_HEADER_KEY = "Authorization"
}
}
50 changes: 50 additions & 0 deletions app/src/main/java/woowacourse/shopping/data/AuthStorage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package woowacourse.shopping.data

import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import java.util.Base64

object AuthStorage {
private lateinit var storage: SharedPreferences

fun init(applicationContext: Context) {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

storage =
EncryptedSharedPreferences.create(
FILE_NAME,
Comment on lines +17 to +18
Copy link
Member

@Gyuil-Hwnag Gyuil-Hwnag May 29, 2025

Choose a reason for hiding this comment

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

이 부분은 EncryptedSharedPreferences 은 Deprecatd 되어진걸로 보여지는데, 사용을 하신 이유가 있을까요~?
추가적으로 코드레빗의 코멘트도 괜찮은 내용인 것 같군요
코드레빗 코멘트
https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences

Copy link
Author

@giovannijunseokim giovannijunseokim Jun 2, 2025

Choose a reason for hiding this comment

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

고객의 id, pw 또는 토큰을 로컬 저장소에 저장해야 할 것 같은데, 키-값 형태의 비교적 간단한 데이터이기 때문에 EncryptedSharedPreference를 사용해 보았습니다!

다만 Deprecated된 것을 확인했기에, 다른 방법을 찾아보도록 하겠습니다! 😁

Copy link
Member

@Gyuil-Hwnag Gyuil-Hwnag Jun 5, 2025

Choose a reason for hiding this comment

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

방법이라면, 아래와 같은 방법도 있을 것 같다는 생각이 들었어요~

  1. 암호화키를 localProperties 에 저장을 한다.
  2. id / password 를 저장할 때 해당 키를 이용하여 암호화를 한다.
  3. 해당 키를 서버에 보낼때 복호화 하여 보낸다.
    그치만, 이 방법도 네트워크를 탈취하는등에 문제가 발생한다면 온전하게 보안적인 요소를 노출하는 것이라서 AccessToken / RefreshToken / Expire Time 등 여러가지 방법들이 있어요!
    이 부분은 추후 시간이 날때 한번 살펴본다면, 도움이 될 것 같습니다💪

masterKeyAlias,
applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
Comment on lines +13 to +24
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

암호화 초기화 실패 상황을 어떻게 처리할지 고민해보세요.

EncryptedSharedPreferences 생성 과정에서 다음과 같은 문제들이 발생할 수 있습니다:

  • 디바이스가 암호화를 지원하지 않는 경우
  • 키 생성이나 저장소 생성이 실패하는 경우
  • 이전 버전과의 호환성 문제

이런 예외 상황들을 어떻게 감지하고 처리할지, 그리고 사용자에게 어떤 경험을 제공할지 생각해보세요.

🤖 Prompt for AI Agents
In app/src/main/java/woowacourse/shopping/data/AuthStorage.kt around lines 13 to
24, the init function does not handle exceptions that may occur during
EncryptedSharedPreferences creation, such as unsupported device encryption or
key generation failures. Wrap the initialization code in a try-catch block to
catch relevant exceptions, log or handle the errors appropriately, and provide
fallback behavior or user notifications to ensure graceful degradation and
better user experience.


var id: String?
get() = storage.getString(KEY_ID, DEFAULT_ID)
set(value) = storage.edit { putString(KEY_ID, value) }

var pw: String?
get() = storage.getString(KEY_PW, DEFAULT_PW)
set(value) = storage.edit { putString(KEY_PW, value) }

val authToken: String?
get() {
if (id == null || pw == null) {
return null
}

val valueToEncode = "$id:$pw".toByteArray()
return BASIC_AUTH_FORMAT.format(Base64.getEncoder().encodeToString(valueToEncode))
}

private const val KEY_ID = "woowacourse.shopping.KEY_ID"
private const val DEFAULT_ID = "giovannijunseokim"
private const val KEY_PW = "woowacourse.shopping.KEY_PW"
private const val DEFAULT_PW = "password"
private const val FILE_NAME = "auth"
private const val BASIC_AUTH_FORMAT = "Basic %s"
}
31 changes: 31 additions & 0 deletions app/src/main/java/woowacourse/shopping/data/HttpMethod.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package woowacourse.shopping.data

import okhttp3.RequestBody

sealed interface HttpMethod {
val name: String
val body: RequestBody?

object Get : HttpMethod {
override val name: String = "GET"
override val body: RequestBody? = null
}

class Post(
override val body: RequestBody,
) : HttpMethod {
override val name: String = "POST"
}

class Delete(
override val body: RequestBody? = null,
) : HttpMethod {
override val name: String = "DELETE"
}

class Patch(
override val body: RequestBody,
) : HttpMethod {
override val name: String = "PATCH"
}
}
Loading