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
18 changes: 18 additions & 0 deletions .github/workflows/android-pull-request-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ jobs:
- name: set up Android SDK
uses: android-actions/setup-android@v2

- name: Access Near DEV_URL
env:
NEAR_DEV_URL: ${{ secrets.NEAR_DEV_URL }}
run: |
echo "NEAR_DEV_URL=\"NEAR_DEV_URL\"" >> local.properties

- name: Access Near PROD_URL
env:
NEAR_DEV_URL: ${{ secrets.NEAR_PROD_URL }}
run: |
echo "NEAR_PROD_URL=\"NEAR_PROD_URL\"" >> local.properties

- name: Access Near TEMP_TOKEN
env:
NEAR_DEV_URL: ${{ secrets.TEMP_TOKEN }}
run: |
echo "TEMP_TOKEN=\"TEMP_TOKEN\"" >> local.properties

- name: Grant execute permission for gradlew
run: chmod +x gradlew

Expand Down
16 changes: 14 additions & 2 deletions Near/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
Expand Down Expand Up @@ -28,6 +30,12 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
buildConfigField("String", "NEAR_URL", getProperty("NEAR_PROD_URL"))
buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요
}
debug {
buildConfigField("String", "NEAR_URL", getProperty("NEAR_DEV_URL"))
buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요

Choose a reason for hiding this comment

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

high

BuildConfig에 토큰과 같은 민감 정보를 저장하는 것은 보안상 매우 위험합니다. APK를 디컴파일하면 이 값을 쉽게 추출할 수 있기 때문입니다. 임시 토큰이라 할지라도, 이러한 방식은 지양하는 것이 좋습니다. 향후에는 Android Keystore 시스템을 사용하거나 Secrets Gradle Plugin과 같은 라이브러리를 활용하여 보다 안전하게 민감 정보를 관리하는 것을 강력히 권장합니다. TODO 주석으로 이미 인지하고 계신 점은 좋지만, 이는 높은 수준의 보안 위험이므로 프로덕션 출시 전 반드시 해결해야 합니다.

}
}
compileOptions {
Expand All @@ -38,6 +46,7 @@ android {
jvmTarget = "11"
}
buildFeatures {
buildConfig = true
compose = true
}
}
Expand All @@ -63,9 +72,10 @@ dependencies {
implementation(libs.hilt.android)
kapt(libs.hilt.android.compiler)
implementation(libs.hilt.navigation.compose)
// Retrofit
// Retrofit & OkHttp
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.retrofit.kotlin.serialization.converter)
implementation(libs.logging.interceptor)
// Glide
implementation(libs.glide)
kapt(libs.glide.compiler)
Expand All @@ -79,3 +89,5 @@ dependencies {
// Serialization
implementation(libs.kotlin.serialization.json)
}

fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey)

Choose a reason for hiding this comment

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

medium

com.android.build.gradle.internal 패키지에 속한 API는 Gradle의 내부 구현에 해당하므로 직접 사용하는 것은 위험합니다. Gradle 버전이 업데이트될 때 예고 없이 변경되거나 제거될 수 있어, 향후 빌드 실패의 원인이 될 수 있습니다. java.util.Properties를 사용하여 local.properties 파일을 직접 읽는 것이 더 안정적이고 권장되는 방법입니다.

fun getProperty(propertyKey: String): String {
    val properties = java.util.Properties()
    val localPropertiesFile = rootProject.file("local.properties")
    if (localPropertiesFile.exists()) {
        localPropertiesFile.inputStream().use { input ->
            properties.load(input)
        }
    }
    return properties.getProperty(propertyKey) ?: error("'$propertyKey' not found in local.properties")
}

2 changes: 2 additions & 0 deletions Near/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

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

<application
android:name=".NearApplication"
android:allowBackup="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.alarmy.near.data.di

import com.alarmy.near.data.repository.DefaultFriendRepository
import com.alarmy.near.data.repository.ExampleRepository
import com.alarmy.near.data.repository.ExampleRepositoryImpl
import com.alarmy.near.data.repository.FriendRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
Expand All @@ -14,4 +16,8 @@ interface RepositoryModule {
@Binds
@Singleton
abstract fun bindExampleRepository(exampleRepositoryImpl: ExampleRepositoryImpl): ExampleRepository

@Binds
@Singleton
abstract fun bindFriendRepository(friendRepository: DefaultFriendRepository): FriendRepository
}
16 changes: 16 additions & 0 deletions Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.alarmy.near.data.mapper

import com.alarmy.near.model.Friend
import com.alarmy.near.network.response.FriendEntity

fun FriendEntity.toModel(): Friend =
Friend(
friendId = friendId,
position = position,
source = source,
name = name,
imageUrl = imageUrl,
fileName = fileName,
checkRate = checkRate,
lastContactAt = lastContactAt,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.alarmy.near.data.repository

import com.alarmy.near.data.mapper.toModel
import com.alarmy.near.model.Friend
import com.alarmy.near.network.service.FriendService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject

class DefaultFriendRepository
@Inject
constructor(
private val friendService: FriendService,
) : FriendRepository {
override fun fetchFriends(): Flow<List<Friend>> =
flow {
emit(
friendService.fetchFriends().map {
it.toModel()
},
)
}
Comment on lines +15 to +22

Choose a reason for hiding this comment

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

high

현재 DefaultFriendRepositoryfetchFriends 함수는 API 호출 시 발생할 수 있는 예외(네트워크 오류 등)를 처리하지 않습니다. 예외가 발생하면 flow가 비정상적으로 종료되고, 이를 수집하는 ViewModel에서 처리하지 않으면 앱이 강제 종료될 수 있습니다. try-catch 블록을 사용하여 예외를 처리하고, 도메인 특화 예외를 던지거나 Result와 같은 래퍼 클래스를 사용하여 결과를 반환하는 것이 좋습니다.

        override fun fetchFriends(): Flow<List<Friend>> =
            flow {
                try {
                    emit(
                        friendService.fetchFriends().map {
                            it.toModel()
                        },
                    )
                } catch (e: Exception) {
                    // TODO: 도메인에 맞는 에러로 변환하여 처리하거나 로깅 필요
                    throw e
                }
            }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.alarmy.near.data.repository

import com.alarmy.near.model.Friend
import kotlinx.coroutines.flow.Flow

interface FriendRepository {
fun fetchFriends(): Flow<List<Friend>>
}
12 changes: 12 additions & 0 deletions Near/app/src/main/java/com/alarmy/near/model/Friend.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.alarmy.near.model

data class Friend(
val friendId: String,
val position: Int,
val source: String,
val name: String,
val imageUrl: String? = null,
val fileName: String? = null,
val checkRate: Int,
val lastContactAt: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.alarmy.near.network.auth

import com.alarmy.near.BuildConfig
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject

class TokenInterceptor
@Inject
constructor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return chain.proceed(
request =
request
.newBuilder()
.addHeader("Authorization", "Bearer ${BuildConfig.TEMP_TOKEN}")
.build(),
)
}
}
31 changes: 0 additions & 31 deletions Near/app/src/main/java/com/alarmy/near/network/di/AppModule.kt

This file was deleted.

62 changes: 62 additions & 0 deletions Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.alarmy.near.network.di

import com.alarmy.near.BuildConfig
import com.alarmy.near.network.auth.TokenInterceptor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
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
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private val json =
Json {
encodeDefaults = true
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}

private val jsonConverterFactory = json.asConverterFactory("application/json".toMediaType())

@Provides
@Singleton
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
tokenInterceptor: TokenInterceptor,
): OkHttpClient =
OkHttpClient
.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(tokenInterceptor)
.build()

@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().let {
if (BuildConfig.DEBUG) {
it.setLevel(HttpLoggingInterceptor.Level.BODY)
} else {
it.setLevel(HttpLoggingInterceptor.Level.NONE)
}
}
Comment on lines +44 to +51

Choose a reason for hiding this comment

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

critical

현재 provideLoggingInterceptor 함수는 HttpLoggingInterceptor를 반환해야 하지만, let 블록의 마지막 구문인 it.setLevel(...)Unit을 반환하므로 실제로는 Unit이 반환되어 컴파일 오류가 발생합니다. 객체 생성 후 속성을 설정하는 경우에는 apply 스코프 함수를 사용하는 것이 더 관용적이고 안전합니다.

Suggested change
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().let {
if (BuildConfig.DEBUG) {
it.setLevel(HttpLoggingInterceptor.Level.BODY)
} else {
it.setLevel(HttpLoggingInterceptor.Level.NONE)
}
}
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}


@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit
.Builder()
.baseUrl(BuildConfig.NEAR_URL)
.client(okHttpClient)
.addConverterFactory(jsonConverterFactory)
.build()
}
17 changes: 17 additions & 0 deletions Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.alarmy.near.network.di

import com.alarmy.near.network.service.FriendService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object ServiceModule {
@Provides
@Singleton
fun provideFriendService(retrofit: Retrofit): FriendService = retrofit.create(FriendService::class.java)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.alarmy.near.network.response

import kotlinx.serialization.Serializable

@Serializable
data class FriendEntity(
val friendId: String,
val position: Int,
val source: String,
val name: String,
val imageUrl: String? = null,
val fileName: String? = null,
val checkRate: Int,
val lastContactAt: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.alarmy.near.network.service

import com.alarmy.near.network.response.FriendEntity
import retrofit2.http.GET

interface FriendService {
@GET("/friend/list")
suspend fun fetchFriends(): List<FriendEntity>
Comment on lines +6 to +8

Choose a reason for hiding this comment

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

medium

엔드포인트 경로 /friend/list가 문자열로 하드코딩되어 있습니다. 이렇게 '매직 스트링'을 사용하면 오타가 발생하기 쉽고, 나중에 경로를 변경할 때 여러 곳을 수정해야 하는 번거로움이 있습니다. companion object 내에 상수로 정의하여 재사용성과 유지보수성을 높이는 것이 좋습니다.

Suggested change
interface FriendService {
@GET("/friend/list")
suspend fun fetchFriends(): List<FriendEntity>
interface FriendService {
@GET(FRIEND_LIST)
suspend fun fetchFriends(): List<FriendEntity>
companion object {
private const val FRIEND_LIST = "/friend/list"
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ internal fun HomeRoute(
onAlarmClick: () -> Unit = {},
onMyPageClick: () -> Unit = {},
) {
val uiState = viewModel.uiStateFlow.collectAsStateWithLifecycle()
val uiState = viewModel.friendsFlow.collectAsStateWithLifecycle()
HomeScreen(
onContactClick = {},
onAlarmClick = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,36 @@ package com.alarmy.near.presentation.feature.home

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.alarmy.near.data.repository.ExampleRepository
import com.alarmy.near.data.repository.FriendRepository
import com.alarmy.near.model.Friend
import com.alarmy.near.presentation.feature.home.model.HomeUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class HomeViewModel
@Inject
constructor(
private val exampleRepository: ExampleRepository,
private val friendRepository: FriendRepository,
) : ViewModel() {
// Example: 여러번 초기화되는 StateFlow
private val _uiStateFlow = MutableStateFlow(HomeUiState.Loading)
val uiStateFlow = _uiStateFlow.asStateFlow()

// Example: 한 번만 초기화되는 StateFlow
private val exampleStateFlow =
exampleRepository
.getData()
.map {
// Mapping to UIState
}.stateIn(
val friendsFlow: StateFlow<List<Friend>> =
friendRepository
.fetchFriends()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUiState.Loading,
initialValue = emptyList(),
)
Comment on lines +26 to 33

Choose a reason for hiding this comment

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

high

friendRepository.fetchFriends()에서 발생하는 예외를 처리하지 않고 stateIn으로 StateFlow를 만들고 있습니다. 만약 네트워크 요청이 실패하여 예외가 발생하면, 이 Flow를 구독하는 UI 레이어에서 앱이 비정상 종료될 수 있습니다. catch 연산자를 사용하여 예외를 처리하고, 사용자에게 오류 상태를 알리거나 안정적인 상태(예: 빈 목록)를 반환하도록 처리해야 합니다.

Suggested change
val friendsFlow: StateFlow<List<Friend>> =
friendRepository
.fetchFriends()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUiState.Loading,
initialValue = emptyList(),
)
val friendsFlow: StateFlow<List<Friend>> =
friendRepository
.fetchFriends()
.catch { exception ->
// TODO: 에러 로깅 및 사용자에게 보여줄 에러 상태 처리
emit(emptyList()) // 임시로 빈 리스트를 반환하여 크래시 방지
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)


fun fetchContacts() {
viewModelScope.launch {
exampleRepository
.getData()
.catch {
// handle error
}.collect {
// updateUI
}
}
}

fun removeContact(id: Long) {
// contactRepository.removeContact(id)
}
Expand Down
Loading