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
11 changes: 10 additions & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,15 @@ jobs:

- name: Build with Gradle
run: ./gradlew assembleDebug
env:
BASE_URL: ${{ secrets.BASE_URL }}
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}

- name: Run unit tests
run: ./gradlew test
env:
          BASE_URL: ${{ secrets.BASE_URL }}
          KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}

- name: Upload test reports
if: always()
Expand Down Expand Up @@ -87,4 +93,7 @@ jobs:
run: chmod +x gradlew

- name: Run Fastlane distribute
run: bundle exec fastlane distribute
run: bundle exec fastlane distribute
env:
BASE_URL: ${{ secrets.BASE_URL }}
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
324 changes: 312 additions & 12 deletions .idea/caches/deviceStreaming.xml

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import java.util.Properties
import org.gradle.api.GradleException

plugins {
alias(libs.plugins.android.application)
Expand All @@ -17,6 +18,21 @@ val properties = Properties().apply {
load(localPropertiesFile.inputStream())
}
}

fun getRequiredProperty(key: String): String {

val localValue = properties[key]?.toString()
if (!localValue.isNullOrBlank()) {
return localValue
}

val envValue = System.getenv(key.toUpperCase().replace('.', '_'))
if (!envValue.isNullOrBlank()) {
return envValue
}

throw GradleException("Property '$key' is missing. Please define it in local.properties or as an environment variable (e.g., ${key.toUpperCase().replace('.', '_')}) in CI/CD.")
}
android {
namespace = "com.hsLink.hslink"
compileSdk = libs.versions.compileSdk.get().toInt()
Expand All @@ -30,7 +46,8 @@ android {

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

buildConfigField("String", "BASE_URL", "\"${properties["base.url"]}\"")
buildConfigField("String", "BASE_URL", "\"${getRequiredProperty("base.url")}\"")
manifestPlaceholders["kakaoAppKey"] = getRequiredProperty("kakao.native.app.key")

}

Expand All @@ -41,7 +58,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "BASE_URL", "\"${properties["base.url"]}\"")
buildConfigField("String", "BASE_URL", "\"${getRequiredProperty("base.url")}\"")
}
}
compileOptions {
Expand Down Expand Up @@ -91,4 +108,11 @@ dependencies {
// KaKao
implementation("com.kakao.sdk:v2-user:2.20.1")

// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.1")

// Paging3
implementation("androidx.paging:paging-runtime-ktx:3.3.0")
implementation("androidx.paging:paging-compose:3.3.0")

}
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- 실제 네이티브 앱 키 사용 -->
<data android:host="oauth" android:scheme="kakaoa0bbd28c9384baa131a731d1b914307c" />
<data android:host="oauth" android:scheme="kakao${kakaoAppKey}" />
</intent-filter>
</activity>

Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/com/hsLink/hslink/data/di/DataStoreModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.hsLink.hslink.data.di

import android.content.Context
import com.hsLink.hslink.data.local.TokenDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {

@Provides
@Singleton
fun provideTokenDataStore(@ApplicationContext context: Context): TokenDataStore {
return TokenDataStore(context)
}
}
61 changes: 26 additions & 35 deletions app/src/main/java/com/hsLink/hslink/data/di/NetworkModule.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.hsLink.hslink.data.di

import com.hsLink.hslink.BuildConfig
import com.hsLink.hslink.data.remote.AuthInterceptor
import com.hsLink.hslink.data.service.commuunity.CommunityPostService
import com.hsLink.hslink.data.service.home.PostService
import com.hsLink.hslink.data.service.login.AuthService
Expand All @@ -13,9 +14,7 @@ import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Converter
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
Expand All @@ -24,54 +23,46 @@ object NetworkModule {

@Provides
@Singleton
fun providesLoggingInterceptor() = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
)
.build()
}

@Provides
@Singleton
fun providesOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor)
.build()

@Provides
@Singleton
fun providesConverterFactory(): Converter.Factory =
Json.Default.asConverterFactory("application/json".toMediaType())

@Provides
@Singleton
fun providesRetrofit(
client: OkHttpClient,
converterFactory: Converter.Factory,
): Retrofit {
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(converterFactory)
.client(client)
.client(okHttpClient)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
Comment on lines 44 to 48
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

Restore permissive Json configuration

Json.asConverterFactory uses the strict default settings, so any new/unknown fields from the backend will now trigger SerializationException. This is a regression compared with the typical Json { ignoreUnknownKeys = true } setup and will break the app as soon as the API adds an extra field. Please reinstate the relaxed configuration (and consider preserving coerceInputValues if we had it before) so responses keep deserializing safely.

-            .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
+            .addConverterFactory(
+                Json {
+                    ignoreUnknownKeys = true
+                    coerceInputValues = true
+                }.asConverterFactory("application/json".toMediaType())
+            )
📝 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
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(converterFactory)
.client(client)
.client(okHttpClient)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(
Json {
ignoreUnknownKeys = true
coerceInputValues = true
}.asConverterFactory("application/json".toMediaType())
)
.build()
🤖 Prompt for AI Agents
In app/src/main/java/com/hsLink/hslink/data/di/NetworkModule.kt around lines 44
to 48, the Retrofit converter is created with Json.asConverterFactory using the
default strict Json instance; restore a permissive Json instance by creating
Json { ignoreUnknownKeys = true; coerceInputValues = true } (or at least
ignoreUnknownKeys = true if coerceInputValues was not previously used) and pass
that Json instance to asConverterFactory so unknown backend fields (and coerced
values) won't throw SerializationException.

}

@Provides
@Singleton
fun providePostService(retrofit: Retrofit): PostService =
retrofit.create(PostService::class.java)
fun provideCommunityPostService(retrofit: Retrofit): CommunityPostService {
return retrofit.create(CommunityPostService::class.java)
}

@Provides
@Singleton
fun provideAuthService(retrofit: Retrofit): AuthService {
return retrofit.create(AuthService::class.java)
fun providePostService(retrofit: Retrofit): PostService {
return retrofit.create(PostService::class.java)
}

@Provides
@Singleton
fun provideCommunityPostService(retrofit: Retrofit): CommunityPostService =
retrofit.create(CommunityPostService::class.java)
fun provideAuthService(retrofit: Retrofit): AuthService {
return retrofit.create(AuthService::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hsLink.hslink.data.dto.request
package com.hsLink.hslink.data.dto.request.auth

import kotlinx.serialization.Serializable

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hsLink.hslink.data.dto.request
package com.hsLink.hslink.data.dto.request.community

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
Expand All @@ -11,4 +11,4 @@ data class PostRequestDto (
val title : String,
@SerialName("body")
val body : String
)
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hsLink.hslink.data.dto.response
package com.hsLink.hslink.data.dto.response.auth

import kotlinx.serialization.Serializable

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.hsLink.hslink.data.dto.response.community

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class CommunityListResponseDto(
@SerialName("posts")
val posts: List<CommunityListDto>,
@SerialName("currentPage")
val currentPage: Int,
@SerialName("hasNext")
val hasNext: Boolean,
@SerialName("totalElements")
val totalElements: Int,
)

@Serializable
data class CommunityListDto(
@SerialName("id")
val id: Int,
@SerialName("title")
val title: String,
@SerialName("summary")
val summary: String,
@SerialName("author")
val author: String,
@SerialName("studentId")
val studentId: String,
@SerialName("authorStatus")
val authorStatus: String,
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hsLink.hslink.data.dto.response
package com.hsLink.hslink.data.dto.response.community

import com.hsLink.hslink.domain.model.community.CommunityPostResponseEntity
import kotlinx.serialization.SerialName
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hsLink.hslink.data.dto.response
package com.hsLink.hslink.data.dto.response.home

import com.hsLink.hslink.domain.model.home.PostPopularEntity
import com.hsLink.hslink.domain.model.home.PostPromotionEntity
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hsLink.hslink.data.dto.response
package com.hsLink.hslink.data.dto.response.mypage

import kotlinx.serialization.Serializable

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hsLink.hslink.data.dto.response
package com.hsLink.hslink.data.dto.response.search

import kotlinx.serialization.Serializable

Expand Down
42 changes: 42 additions & 0 deletions app/src/main/java/com/hsLink/hslink/data/local/TokenDataStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.hsLink.hslink.data.local

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

class TokenDataStore @Inject constructor(private val context: Context) {

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "hsu_connect_tokens")

private val accessTokenKey = stringPreferencesKey("access_token")
private val refreshTokenKey = stringPreferencesKey("refresh_token")

val accessToken: Flow<String?> = context.dataStore.data
.map { preferences ->
preferences[accessTokenKey]
}

val refreshToken: Flow<String?> = context.dataStore.data
.map { preferences ->
preferences[refreshTokenKey]
}

suspend fun saveTokens(accessToken: String, refreshToken: String) {
context.dataStore.edit {
it[accessTokenKey] = accessToken
it[refreshTokenKey] = refreshToken
}
}

suspend fun clearTokens() {
context.dataStore.edit {
it.clear()
}
}
Comment on lines +13 to +41
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

🧩 Analysis chain

Consider encrypting tokens at rest.

DataStore Preferences stores data unencrypted on disk. Access and refresh tokens are sensitive credentials that, if compromised, could allow unauthorized access to user accounts. While Android's app sandboxing provides some protection, consider using EncryptedSharedPreferences or implementing encryption at the application layer for additional security.

This is particularly important if:

  • Tokens have long expiry times
  • The app handles sensitive user data
  • Compliance requirements mandate encryption at rest

If token encryption is required, would you like me to provide an implementation example using EncryptedSharedPreferences or a custom encryption wrapper?


I'll help you verify this review comment. Let me gather information about DataStore encryption practices and examine the codebase context.


🏁 Script executed:

#!/bin/bash
# Search for build.gradle or gradle dependencies to understand project setup
fd -e gradle -e toml | head -20

Length of output: 25


🏁 Script executed:

#!/bin/bash
# Search for token usage patterns in the codebase to understand sensitivity
rg -n "Token|token" --type=kt -C 3 | head -100

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check if EncryptedSharedPreferences is already used or imported anywhere
rg "EncryptedSharedPreferences|encryption|Encrypted" --type=kt -n

Length of output: 26


🌐 Web query:

Android DataStore Preferences encryption default behavior

💡 Result:

Short answer: Preferences DataStore is not encrypted by default — data is stored on disk in your app's private storage but DataStore doesn't perform encryption for you. Use Jetpack Security (EncryptedSharedPreferences / EncryptedFile), Android Keystore-based encryption, or encrypt before saving if you need confidentiality. [1][2][3]

Sources:

  • Android DataStore docs (Preferences DataStore docs/codelab). [1]
  • Android DataStore overview / architecture. [2]
  • Community/confirmation (Jetpack DataStore does not offer built-in encryption). [3]

🌐 Web query:

Kotlin DataStore encryption options EncryptedSharedPreferences alternative

💡 Result:

Short answer: EncryptedSharedPreferences has been deprecated, and the recommended alternative is to keep using Jetpack DataStore for storage and add your own encryption layer — typically by protecting an AES key with Android Keystore (or using Google Tink) and encrypting values before writing to DataStore. Other options: use a full-database solution with SQLCipher, or a maintained community library (e.g., SafeBox) that replaces EncryptedSharedPreferences. [1][2][3][4][5]

Practical options with references

  • Stop using EncryptedSharedPreferences (deprecated). [1]
  • DataStore + app-managed encryption: DataStore itself is not encrypted by default; encrypt/decrypt values in your app and store ciphertext in Preferences or Proto DataStore. Protect keys with Android Keystore (wrap/unwrap) or use an envelope approach. [2][3]
  • Use Google Tink (AndroidKeysetManager) to manage keys and perform AEAD encryption before storing in DataStore — Tink is recommended for safe primitives and key management. [4]
  • Use SQLCipher if you prefer an encrypted SQLite-backed store (suitable for more complex structured data). (community/common guidance). [3][4]
  • Consider maintained community replacements (e.g., SafeBox) if you need a drop‑in EncryptedSharedPreferences alternative — evaluate auditability and maintenance first. [5]

If you want, I can:

  • show a minimal Kotlin example of DataStore + Tink or DataStore + Keystore-wrapped AES, or
  • recommend dependencies and a migration plan from EncryptedSharedPreferences to encrypted DataStore.

Sources:
[1] EncryptedSharedPreferences API docs (androidx.security:security-crypto) — deprecation notice.
[2] Jetpack DataStore docs / release notes (DataStore is the modern replacement; it is not encrypted by default).
[3] StackOverflow / community guidance: “DataStore does not offer encryption; encrypt in-app or use SQLCipher.”
[4] Tink/key-management docs — recommended library for safe encryption and key management on Android.
[5] SafeBox GitHub (drop‑in replacement examples / benchmarks).


🏁 Script executed:

#!/bin/bash
# Check available dependencies
cat gradle/libs.versions.toml

Length of output: 6336


🏁 Script executed:

#!/bin/bash
# Search for token usage and authentication patterns (retry without type filter)
rg -n "Token|token" -C 3 | head -150

Length of output: 15601


🏁 Script executed:

#!/bin/bash
# Check for security, encryption, or crypto dependencies
rg -n "security|crypto|tink|keystore|cipher" -i -C 2

Length of output: 2325


🏁 Script executed:

#!/bin/bash
# Look for any authentication or compliance-related context
rg -n "auth|AUTH|compliance|COMPLIANCE|sensitive|SENSITIVE" -C 2 | head -80

Length of output: 4999


Encrypt tokens before storage in DataStore.

The security concern is valid—tokens are sensitive credentials that grant account access. DataStore Preferences is not encrypted by default, and your app handles authentication tokens for user accounts.

However, EncryptedSharedPreferences is deprecated. The recommended approach is to add an encryption layer within DataStore itself. Your project already includes androidx-security:security-crypto-ktx (v1.1.0-alpha06); use this to:

  • Encrypt token values before writing to DataStore and decrypt on read, OR
  • Consider DataStore + app-managed encryption using Android Keystore or Google Tink for key management

Implement encryption at the application layer in TokenDataStore to ensure tokens are protected at rest.

🤖 Prompt for AI Agents
In app/src/main/java/com/hsLink/hslink/data/local/TokenDataStore.kt around lines
13-41, the TokenDataStore currently writes plain tokens to DataStore; update it
to encrypt tokens before writing and decrypt on read. Create or obtain a master
key via androidx.security.crypto (MasterKey/AndroidKeyStore), then use a
symmetric cipher (e.g., AES/GCM) or the security-crypto helper to encrypt the
access and refresh token strings before saving them into DataStore and decrypt
them when mapping the flows; update saveTokens to encrypt both tokens before
edit, update accessToken and refreshToken flows to map and return decrypted
values (handle decryption failures by returning null and logging), and ensure
all crypto operations run off the main thread (use withContext(Dispatchers.IO)
or make encryption functions suspend).

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.hsLink.hslink.data.paging

import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.hsLink.hslink.data.service.commuunity.CommunityPostService
import com.hsLink.hslink.domain.model.community.CommunityPost
import com.hsLink.hslink.domain.model.community.toEntity

class CommunityPagingSource(
private val communityPostService: CommunityPostService,
private val type: String
) : PagingSource<Int, CommunityPost>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommunityPost> {
val page = params.key ?: 0

return try {
val response = communityPostService.getCommunity(type = type, page = page)
val posts = response.result.posts.map { it.toEntity() }

LoadResult.Page(
data = posts,
prevKey = if (page == 0) null else page - 1,
nextKey = if (response.result.hasNext) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
Comment on lines +14 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 | 🔴 Critical

Add null safety check for API response.

Line 19 accesses response.result.posts without checking if result is null or if response.isSuccess is true. If the API returns an error response, result might be null, causing a NullPointerException.

Apply this diff to add proper null safety:

 override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommunityPost> {
     val page = params.key ?: 0
 
     return try {
         val response = communityPostService.getCommunity(type = type, page = page)
-        val posts = response.result.posts.map { it.toEntity() }
+        
+        if (!response.isSuccess || response.result == null) {
+            return LoadResult.Error(Exception(response.message ?: "Unknown error"))
+        }
+        
+        val posts = response.result.posts.map { it.toEntity() }
 
         LoadResult.Page(
             data = posts,
             prevKey = if (page == 0) null else page - 1,
             nextKey = if (response.result.hasNext) page + 1 else null
         )
     } catch (e: Exception) {
         LoadResult.Error(e)
     }
 }


override fun getRefreshKey(state: PagingState<Int, CommunityPost>): Int? {
return state.anchorPosition?.let {
state.closestPageToPosition(it)?.prevKey?.plus(1) ?: state.closestPageToPosition(it)?.nextKey?.minus(1)
}
}
}
Loading