diff --git a/.editorconfig b/.editorconfig
index a71f34b..bedb362 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -14,7 +14,9 @@ trim_trailing_whitespace = true
indent_size = 4
indent_style = space
max_line_length = 120
-disabled_rules = no-wildcard-imports # 와일드카드 임포트 금지
+
+# 파일명 규칙 비활성화 (더 명확한 형식)
+ktlint_standard_filename = disabled
# Composable 함수 네이밍 규칙을 위한 설정
ktlint_experimental = true
diff --git a/.github/android-ci.yml b/.github/workflows/android-ci.yml
similarity index 93%
rename from .github/android-ci.yml
rename to .github/workflows/android-ci.yml
index 5580abd..eaa62ba 100644
--- a/.github/android-ci.yml
+++ b/.github/workflows/android-ci.yml
@@ -6,8 +6,8 @@ on:
push:
branches: [ "main", "develop" ] # 브랜치에 push될 때
pull_request:
- branches: [ "main", "develop" ] # 브랜치로 pull request가 생성될 때
- workflow_dispatch: # Github Actions 수동으로 실행될 때
+ branches: [ "main", "develop" ] # 브랜치로 pull request가 생성될 때
+ workflow_dispatch: # Github Actions 수동으로 실행될 때
# 실행될 작업(Job) 목록
jobs:
diff --git a/.gitignore b/.gitignore
index bc56943..3f935f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ build/
# Local configuration file (sdk path, etc)
local.properties
+secrets.properties
# Log/OS Files
*.log
@@ -417,3 +418,4 @@ obj/
!/gradle/wrapper/gradle-wrapper.jar
# End of https://www.toptal.com/developers/gitignore/api/macos,windows,android,androidstudio,kotlin,java,gradle,maven,intellij,git
+/node_modules/
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100755
index 0000000..88930c0
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1 --format ./scripts/commitlint-formatter-korean.js
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 0000000..64778a1
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,3 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 77e9292..fe367b5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,6 +6,10 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ktlint)
+ alias(libs.plugins.secrets.gradle.plugin)
+ alias(libs.plugins.google.services)
+ alias(libs.plugins.firebase.crashlytics)
+ alias(libs.plugins.firebase.perf)
}
android {
@@ -24,6 +28,11 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+ buildFeatures {
+ buildConfig = true
+ compose = true
+ }
+
buildTypes {
release {
isMinifyEnabled = false
@@ -37,8 +46,17 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- buildFeatures {
- compose = true
+
+ secrets {
+ // 기본 'local.properties' 대신 다른 파일 이름 지정
+ propertiesFileName = "secrets.properties"
+
+ // CI/CD 환경을 위한 기본값 파일 지정 (버전 관리에 포함 가능)
+ defaultPropertiesFileName = "secrets.defaults.properties"
+
+ // 특정 키를 무시하도록 정규식 추가 (기본적으로 "sdk.dir"은 무시됨)
+ ignoreList.add("keyToIgnore")
+ ignoreList.add("ignore*")
}
}
@@ -53,6 +71,10 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx) // ViewModel
implementation(libs.androidx.navigation.compose) // Navigation
+ implementation(libs.androidx.datastore.preferences) // DataStore Preferences
+ implementation(libs.androidx.room.runtime) // Room Runtime
+ ksp(libs.androidx.room.compiler) // Room Compiler
+ implementation(libs.androidx.room.ktx) // Room KTX
// Jetpack Compose dependencies
implementation(platform(libs.androidx.compose.bom))
@@ -60,6 +82,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons.extended) // TODO 배포시 해당 의존성 삭제하고 필요한 아이콘만 개별 추가
// Debug dependencies
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -69,11 +92,19 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ // Firebase dependencies
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.crashlytics)
+ implementation(libs.firebase.perf)
+ implementation(libs.firebase.messaging)
+ implementation(libs.firebase.config)
// Retrofit dependencies
implementation(libs.retrofit)
implementation(libs.logging.interceptor)
implementation(libs.retrofit2.kotlinx.serialization.converter)
+ implementation(libs.kotlinx.serialization.json)
// Coroutines dependencies
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
@@ -86,6 +117,10 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose)
// Timber dependency for logging
implementation(libs.timber)
+ // Browser (Chrome Custom Tabs)
+ implementation(libs.androidx.browser)
+ // Security Crypto for EncryptedSharedPreferences
+ implementation(libs.androidx.security.crypto)
// // DataStore dependencies
// implementation("androidx.datastore:datastore-preferences:1.0.0")
// // Room dependencies
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2fedb4a..455398f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,11 +14,9 @@
android:maxSdkVersion="32" />
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/autoever/everp/EverpApp.kt b/app/src/main/java/com/autoever/everp/EverpApp.kt
deleted file mode 100644
index 1307792..0000000
--- a/app/src/main/java/com/autoever/everp/EverpApp.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.autoever.everp
-
-import android.app.Application
-import dagger.hilt.android.HiltAndroidApp
-
-@HiltAndroidApp
-class EverpApp : Application() {
- override fun onCreate() {
- super.onCreate()
- }
-}
diff --git a/app/src/main/java/com/autoever/everp/EverpApplication.kt b/app/src/main/java/com/autoever/everp/EverpApplication.kt
new file mode 100644
index 0000000..ba4cc68
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/EverpApplication.kt
@@ -0,0 +1,60 @@
+package com.autoever.everp
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.os.Build
+import com.autoever.everp.common.exception.GlobalExceptionHandler
+import dagger.hilt.android.HiltAndroidApp
+import com.autoever.everp.service.fcm.MyFirebaseMessagingService
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltAndroidApp
+class EverpApplication : Application() {
+
+ @Inject
+ lateinit var globalExceptionHandler: GlobalExceptionHandler
+
+ override fun onCreate() {
+ super.onCreate()
+ initTimber()
+ initGlobalExceptionHandler()
+ createNotificationChannels()
+ }
+
+ /**
+ * 전역 예외 처리기 초기화
+ *
+ * 가장 먼저 초기화하여 앱 실행 중 발생하는 모든 예외를 잡을 수 있도록 함
+ */
+ private fun initGlobalExceptionHandler() {
+ try {
+ globalExceptionHandler.initialize()
+ Timber.i("Global exception handler initialized")
+ } catch (e: Exception) {
+ // 초기화 실패 시에도 앱은 계속 실행
+ Timber.e(e, "Failed to initialize global exception handler")
+ }
+ }
+
+ private fun initTimber() {
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+ }
+
+ private fun createNotificationChannels() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ MyFirebaseMessagingService.DEFAULT_CHANNEL_ID,
+ "일반 알림",
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = "EvERP 기본 알림 채널"
+ }
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/MainActivity.kt b/app/src/main/java/com/autoever/everp/MainActivity.kt
index 9abecd0..1599696 100644
--- a/app/src/main/java/com/autoever/everp/MainActivity.kt
+++ b/app/src/main/java/com/autoever/everp/MainActivity.kt
@@ -4,49 +4,50 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.lifecycle.lifecycleScope
+import com.autoever.everp.domain.repository.PushNotificationRepository
+import com.autoever.everp.ui.MainScreen
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
+import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
import com.autoever.everp.ui.theme.EverpTheme
+import com.autoever.everp.ui.navigation.AppNavGraph
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var notificationRepository: PushNotificationRepository
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ getFcmToken()
setContent {
EverpTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding),
- )
- }
+ MainScreen()
+// Surface(modifier = Modifier.fillMaxSize()) {
+// AppNavGraph()
+// }
}
}
}
-}
-@Composable
-fun Greeting(
- name: String,
- modifier: Modifier = Modifier,
-) {
- Text(
- text = "Hello $name!",
- modifier = modifier,
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun GreetingPreview() {
- EverpTheme {
- Greeting("Android")
+ private fun getFcmToken() {
+ // Repository를 통해서만 FCM 토큰 접근
+ // MainActivity에서는 Firebase 객체에 직접 접근하지 않음
+ lifecycleScope.launch {
+ try {
+ val token = notificationRepository.getToken()
+ Timber.tag("FCM").i("FCM Token: $token")
+ // TODO: 서버에 토큰 전송 또는 로컬 저장 등 필요한 작업 수행
+ } catch (e: Exception) {
+ Timber.tag("FCM").e(e, "Fetching FCM token failed")
+ }
+ }
}
}
diff --git a/app/src/main/java/com/autoever/everp/auth/api/AuthApi.kt b/app/src/main/java/com/autoever/everp/auth/api/AuthApi.kt
new file mode 100644
index 0000000..31ab3fb
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/api/AuthApi.kt
@@ -0,0 +1,19 @@
+package com.autoever.everp.auth.api
+
+import com.autoever.everp.auth.config.AuthConfig
+import com.autoever.everp.auth.model.TokenResponse
+
+/**
+ * 인증 서버 연동 API
+ * - 인가 코드 교환(Authorization Code + PKCE)
+ * - 로그아웃
+ */
+interface AuthApi {
+ suspend fun exchangeAuthCodeForToken(
+ config: AuthConfig,
+ code: String,
+ codeVerifier: String,
+ ): TokenResponse
+
+ suspend fun logout(accessToken: String?): Boolean
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/api/HttpAuthApi.kt b/app/src/main/java/com/autoever/everp/auth/api/HttpAuthApi.kt
new file mode 100644
index 0000000..79aa0a7
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/api/HttpAuthApi.kt
@@ -0,0 +1,109 @@
+package com.autoever.everp.auth.api
+
+import com.autoever.everp.auth.config.AuthConfig
+import com.autoever.everp.auth.model.TokenResponse
+import java.io.BufferedReader
+import java.io.OutputStreamWriter
+import java.net.HttpURLConnection
+import java.net.URL
+import java.net.URLEncoder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+/**
+ * HttpURLConnection 기반 AuthApi 구현.
+ * 기존 AuthService/LogoutService 로직을 통합.
+ */
+class HttpAuthApi : AuthApi {
+ private companion object {
+ const val TAG = "AuthApi"
+ }
+
+ override suspend fun exchangeAuthCodeForToken(
+ config: AuthConfig,
+ code: String,
+ codeVerifier: String,
+ ): TokenResponse = withContext(Dispatchers.IO) {
+ val tokenUrl = config.tokenEndpoint
+ val url = URL(tokenUrl)
+ val conn = (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
+ doOutput = true
+ connectTimeout = 10000
+ readTimeout = 15000
+ }
+
+ val params = mapOf(
+ "grant_type" to "authorization_code",
+ "code" to code,
+ "redirect_uri" to config.redirectUri,
+ "client_id" to config.clientId,
+ "code_verifier" to codeVerifier,
+ )
+ val body = params.entries.joinToString("&") { (k, v) ->
+ "${urlEncode(k)}=${urlEncode(v)}"
+ }
+
+ try {
+ OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(body) }
+ val status = conn.responseCode
+ val stream = if (status in 200..299) conn.inputStream else conn.errorStream
+ val resp = stream.bufferedReader(Charsets.UTF_8).use(BufferedReader::readText)
+ if (status !in 200..299) {
+ Timber.tag(TAG).e("[ERROR] 토큰 교환 실패: HTTP ${status} | ${resp}")
+ throw IllegalStateException("토큰 교환 실패: HTTP ${status}")
+ }
+ val json = org.json.JSONObject(resp)
+ val token = TokenResponse(
+ accessToken = json.optString("access_token"),
+ refreshToken = json.optString("refresh_token").ifBlank { null },
+ tokenType = json.optString("token_type").ifBlank { null },
+ expiresIn = json.optLong("expires_in").let { if (it == 0L) null else it },
+ idToken = json.optString("id_token").ifBlank { null },
+ )
+ if (token.accessToken.isBlank()) {
+ Timber.tag(TAG).e("[ERROR] 토큰 응답에 access_token이 없습니다: ${resp}")
+ throw IllegalStateException("토큰 응답 파싱 실패")
+ }
+ Timber.tag(TAG).i("[INFO] 토큰 교환 성공")
+ token
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ override suspend fun logout(accessToken: String?): Boolean = withContext(Dispatchers.IO) {
+ val url = URL(com.autoever.everp.auth.endpoint.AuthEndpoint.LOGOUT)
+ val conn = (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ doOutput = false
+ connectTimeout = 10000
+ readTimeout = 15000
+ if (!accessToken.isNullOrBlank()) {
+ setRequestProperty("Authorization", "Bearer $accessToken")
+ }
+ }
+ try {
+ val status = conn.responseCode
+ val stream = if (status in 200..299) conn.inputStream else conn.errorStream
+ val resp = stream?.bufferedReader(Charsets.UTF_8)?.use(BufferedReader::readText)
+ if (status in 200..299) {
+ Timber.tag(TAG).i("[INFO] 로그아웃 성공: HTTP $status")
+ true
+ } else {
+ Timber.tag(TAG).e("[ERROR] 로그아웃 실패: HTTP $status | ${resp ?: "(no body)"}")
+ false
+ }
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] 로그아웃 호출 중 예외: ${e.message}")
+ false
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ private fun urlEncode(v: String): String =
+ URLEncoder.encode(v, Charsets.UTF_8.name())
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/api/HttpUserApi.kt b/app/src/main/java/com/autoever/everp/auth/api/HttpUserApi.kt
new file mode 100644
index 0000000..95c0791
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/api/HttpUserApi.kt
@@ -0,0 +1,56 @@
+package com.autoever.everp.auth.api
+
+import com.autoever.everp.auth.endpoint.AuthEndpoint
+import com.autoever.everp.auth.model.UserInfo
+import com.autoever.everp.common.error.UnauthorizedException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import timber.log.Timber
+import java.io.BufferedReader
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * HttpURLConnection 기반 UserApi 구현.
+ * 기존 GWService.getUserInfo와 동일한 동작을 제공한다.
+ */
+class HttpUserApi : UserApi {
+ private companion object { const val TAG = "UserApi" }
+
+ override suspend fun getUserInfo(accessToken: String): UserInfo = withContext(Dispatchers.IO) {
+ val url = URL(AuthEndpoint.USER_INFO)
+ val conn = (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ setRequestProperty("Accept", "application/json")
+ setRequestProperty("Authorization", "Bearer $accessToken")
+ connectTimeout = 10000
+ readTimeout = 15000
+ }
+ try {
+ val status = conn.responseCode
+ val stream = if (status in 200..299) conn.inputStream else conn.errorStream
+ val resp = stream.bufferedReader(Charsets.UTF_8).use(BufferedReader::readText)
+ if (status == 401) {
+ Timber.tag(TAG).e("[ERROR] 사용자 정보 조회 실패: HTTP ${status} | ${resp}")
+ throw UnauthorizedException("HTTP 401")
+ }
+ if (status !in 200..299) {
+ Timber.tag(TAG).e("[ERROR] 사용자 정보 조회 실패: HTTP ${status} | ${resp}")
+ throw IllegalStateException("사용자 정보 조회 실패: HTTP ${status}")
+ }
+ // API 응답은 { success, message, data: { ... } } 형태일 수 있으므로 data 객체를 우선 시도
+ val root = JSONObject(resp)
+ val json = root.optJSONObject("data") ?: root
+ UserInfo(
+ userId = json.optString("userId").ifBlank { null },
+ userName = json.optString("userName").ifBlank { null },
+ loginEmail = json.optString("loginEmail").ifBlank { null },
+ userRole = json.optString("userRole").ifBlank { null },
+ userType = json.optString("userType").ifBlank { null },
+ )
+ } finally {
+ conn.disconnect()
+ }
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/api/UserApi.kt b/app/src/main/java/com/autoever/everp/auth/api/UserApi.kt
new file mode 100644
index 0000000..e655ead
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/api/UserApi.kt
@@ -0,0 +1,11 @@
+package com.autoever.everp.auth.api
+
+import com.autoever.everp.auth.model.UserInfo
+
+/**
+ * 사용자 정보 조회 API 계약.
+ */
+interface UserApi {
+ suspend fun getUserInfo(accessToken: String): UserInfo
+}
+
diff --git a/app/src/main/java/com/autoever/everp/auth/config/AuthConfig.kt b/app/src/main/java/com/autoever/everp/auth/config/AuthConfig.kt
new file mode 100644
index 0000000..6788c72
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/config/AuthConfig.kt
@@ -0,0 +1,77 @@
+package com.autoever.everp.auth.config
+
+import android.net.Uri
+import android.util.Log
+import com.autoever.everp.auth.endpoint.AuthEndpoint
+import timber.log.Timber
+import androidx.core.net.toUri
+
+/**
+ * OAuth2 Authorization Code + PKCE 설정 및 인가 URL 생성 유틸.
+ */
+data class AuthConfig(
+ val authorizationEndpoint: String,
+ val tokenEndpoint: String,
+ val clientId: String,
+ val redirectUri: String,
+ val scopes: List,
+) {
+ val redirectURL: Uri
+ get() = try {
+ Uri.parse(redirectUri)
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] 검증되지 않은 Redirect URI: $redirectUri")
+ throw IllegalArgumentException("검증되지 않은 Redirect URI 형식입니다.")
+ }
+
+ val redirectScheme: String? get() = redirectURL.scheme
+ val redirectHost: String? get() = redirectURL.host
+ val redirectPath: String get() = redirectURL.path ?: ""
+
+ /**
+ * 인가 요청 URI 생성
+ * - response_type=code
+ * - client_id, redirect_uri, scope, code_challenge, state, code_challenge_method=S256
+ */
+ fun buildAuthorizationUri(
+ codeChallenge: String,
+ state: String,
+ ): Uri {
+ return try {
+ val base = authorizationEndpoint.toUri()
+ val scopeValue = scopes.joinToString(separator = " ")
+ base.buildUpon()
+ .appendQueryParameter("response_type", "code")
+ .appendQueryParameter("client_id", clientId)
+ .appendQueryParameter("redirect_uri", redirectUri)
+ .appendQueryParameter("scope", scopeValue)
+ .appendQueryParameter("code_challenge", codeChallenge)
+ .appendQueryParameter("state", state)
+ .appendQueryParameter("code_challenge_method", "S256")
+ .build()
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] Authorization URL 생성 실패: ${e.message}")
+ throw IllegalStateException("Authorization URL 생성 실패", e)
+ }
+ }
+
+ companion object {
+ private const val TAG = "AuthConfig"
+
+ /**
+ * 기본 설정(항상 프로덕션 도메인 사용).
+ * - authBase: https://auth.everp.co.kr
+ * - clientId: everp-aos
+ * - redirectUri: everp-aos://callback (Manifest에 등록됨)
+ */
+ fun default(): AuthConfig {
+ return AuthConfig(
+ authorizationEndpoint = AuthEndpoint.AUTHORIZE,
+ tokenEndpoint = AuthEndpoint.TOKEN,
+ clientId = "everp-aos",
+ redirectUri = "everp-aos://callback",
+ scopes = listOf("erp.user.profile", "offline_access"),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/endpoint/AuthEndpoint.kt b/app/src/main/java/com/autoever/everp/auth/endpoint/AuthEndpoint.kt
new file mode 100644
index 0000000..ce0b04e
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/endpoint/AuthEndpoint.kt
@@ -0,0 +1,25 @@
+package com.autoever.everp.auth.endpoint
+
+/**
+ * 인증/인가 및 사용자 정보 관련 엔드포인트를 한 곳에 모은 상수 집합.
+ * - 인가 코드 요청: https://auth.everp.co.kr/oauth2/authorize
+ * - 토큰 교환: https://auth.everp.co.kr/oauth2/token
+ * - 로그아웃: https://auth.everp.co.kr/logout
+ * - 사용자 정보: https://everp.co.kr/api/user/info
+ */
+object AuthEndpoint {
+ // 인증 서버 (Authorization Server)
+ const val AUTH_BASE: String = "https://auth.everp.co.kr"
+ const val LOCAL_BASE: String = "http://10.0.2.2:8081"
+
+ // 앱 메인 도메인 (게이트웨이 기반 API 호출용)
+ const val EVERP_BASE: String = "https://everp.co.kr"
+
+ // OAuth2 인가 코드 (PKCE 적용)
+ const val AUTHORIZE: String = "$AUTH_BASE/oauth2/authorize"
+ const val TOKEN: String = "$AUTH_BASE/oauth2/token"
+ const val LOGOUT: String = "$AUTH_BASE/logout"
+
+ // 사용자 정보 (게이트웨이 -> `/api` 기본 경로)
+ const val USER_INFO: String = "https://api.everp.co.kr/api/user/info"
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/flow/AuthCct.kt b/app/src/main/java/com/autoever/everp/auth/flow/AuthCct.kt
new file mode 100644
index 0000000..5ee0cbb
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/flow/AuthCct.kt
@@ -0,0 +1,36 @@
+package com.autoever.everp.auth.flow
+
+import android.content.Context
+import androidx.browser.customtabs.CustomTabsIntent
+import com.autoever.everp.auth.config.AuthConfig
+import com.autoever.everp.auth.pkce.PKCEGenerator
+import com.autoever.everp.auth.pkce.StateGenerator
+import timber.log.Timber
+
+/**
+ * Chrome Custom Tabs 기반 인가 플로우 실행 유틸.
+ */
+object AuthCct {
+ fun start(context: Context) {
+ Timber.tag("AuthCCT").i("[INFO] CCT 플로우 시작")
+ val config = AuthConfig.default()
+ val pkce = PKCEGenerator.generatePair()
+ val state = StateGenerator.makeState()
+
+ AuthFlowMemory.config = config
+ AuthFlowMemory.pkce = pkce
+ AuthFlowMemory.state = state
+
+
+ val uri = config.buildAuthorizationUri(
+ codeChallenge = pkce.codeChallenge,
+ state = state,
+ )
+ Timber.tag("AuthCCT").i("[INFO] Authorize 요청 URL: $uri")
+
+ val cct = CustomTabsIntent.Builder()
+ .setShowTitle(true)
+ .build()
+ cct.launchUrl(context, uri)
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/flow/AuthFlowMemory.kt b/app/src/main/java/com/autoever/everp/auth/flow/AuthFlowMemory.kt
new file mode 100644
index 0000000..7998786
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/flow/AuthFlowMemory.kt
@@ -0,0 +1,17 @@
+package com.autoever.everp.auth.flow
+
+/**
+ * 인가 플로우 중 임시 데이터를 메모리에 보관.
+ * - 앱 프로세스 내 한시적 저장용으로만 사용.
+ */
+object AuthFlowMemory {
+ var pkce: com.autoever.everp.auth.pkce.PKCEPair? = null
+ var state: String? = null
+ var config: com.autoever.everp.auth.config.AuthConfig? = null
+
+ fun clear() {
+ pkce = null
+ state = null
+ config = null
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/model/TokenResponse.kt b/app/src/main/java/com/autoever/everp/auth/model/TokenResponse.kt
new file mode 100644
index 0000000..47e9694
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/model/TokenResponse.kt
@@ -0,0 +1,9 @@
+package com.autoever.everp.auth.model
+
+data class TokenResponse(
+ val accessToken: String,
+ val refreshToken: String? = null,
+ val tokenType: String? = null,
+ val expiresIn: Long? = null,
+ val idToken: String? = null,
+)
diff --git a/app/src/main/java/com/autoever/everp/auth/model/UserInfo.kt b/app/src/main/java/com/autoever/everp/auth/model/UserInfo.kt
new file mode 100644
index 0000000..db44025
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/model/UserInfo.kt
@@ -0,0 +1,13 @@
+package com.autoever.everp.auth.model
+
+/**
+ * 사용자 기본 정보 도메인 모델.
+ */
+data class UserInfo(
+ val userId: String?,
+ val userName: String?,
+ val loginEmail: String?,
+ val userRole: String?,
+ val userType: String?,
+)
+
diff --git a/app/src/main/java/com/autoever/everp/auth/pkce/PKCEGenerator.kt b/app/src/main/java/com/autoever/everp/auth/pkce/PKCEGenerator.kt
new file mode 100644
index 0000000..f3c0356
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/pkce/PKCEGenerator.kt
@@ -0,0 +1,48 @@
+package com.autoever.everp.auth.pkce
+
+import android.util.Base64
+import timber.log.Timber
+import java.security.MessageDigest
+import java.security.SecureRandom
+
+data class PKCEPair(
+ val codeVerifier: String,
+ val codeChallenge: String,
+)
+
+/**
+ * PKCE(code_verifier, code_challenge) 생성 유틸.
+ * - code_verifier: URL-safe Base64(패딩 제거), 길이 43~128 권장(기본 64바이트 → ~86문자)
+ * - code_challenge: S256(SHA-256) → URL-safe Base64(패딩 제거)
+ */
+object PKCEGenerator {
+ private const val TAG = "PKCEGenerator"
+ private const val DEFAULT_VERIFIER_BYTES = 64
+
+ fun generatePair(verifierBytes: Int = DEFAULT_VERIFIER_BYTES): PKCEPair {
+ val verifier = generateCodeVerifier(verifierBytes)
+ val challenge = generateCodeChallenge(verifier)
+ Timber.tag(TAG).i("[INFO] PKCE 값 생성 완료 (verifier 길이: ${verifier.length})")
+ return PKCEPair(verifier, challenge)
+ }
+
+ fun generateCodeVerifier(numBytes: Int = DEFAULT_VERIFIER_BYTES): String {
+ require(numBytes >= 32) { "code_verifier용 바이트 길이는 최소 32 이상이어야 합니다." }
+ val random = SecureRandom()
+ val bytes = ByteArray(numBytes)
+ random.nextBytes(bytes)
+ // URL-safe Base64, padding 제거, 줄바꿈 제거
+ val b64 = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
+ // Base64.URL_SAFE는 '-'와 '_'를 사용하므로 URL-safe 만족
+ if (b64.length < 43 || b64.length > 128) {
+ Timber.tag(TAG).i("[INFO] code_verifier 길이 조정이 필요할 수 있습니다. (현재: ${b64.length})")
+ }
+ return b64
+ }
+
+ fun generateCodeChallenge(verifier: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.UTF_8))
+ // URL-safe Base64, padding 제거, 줄바꿈 제거
+ return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/pkce/StateGenerator.kt b/app/src/main/java/com/autoever/everp/auth/pkce/StateGenerator.kt
new file mode 100644
index 0000000..05f299a
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/pkce/StateGenerator.kt
@@ -0,0 +1,27 @@
+package com.autoever.everp.auth.pkce
+
+import android.util.Base64
+import timber.log.Timber
+import java.security.SecureRandom
+
+/**
+ * OAuth2 state 생성 유틸 (URL-safe Base64, padding 제거).
+ */
+object StateGenerator {
+ private const val TAG = "StateGenerator"
+
+ fun makeState(lengthBytes: Int = 64): String {
+ require(lengthBytes >= 32) { "state 바이트 길이는 최소 32 이상이어야 합니다." }
+ return try {
+ val random = SecureRandom()
+ val bytes = ByteArray(lengthBytes)
+ random.nextBytes(bytes)
+ val state = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
+ Timber.tag(TAG).i("[INFO] state 생성 완료 (길이: ${state.length})")
+ state
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] state 생성 중 오류가 발생했습니다: ${e.message}")
+ throw e
+ }
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/repository/AuthRepository.kt b/app/src/main/java/com/autoever/everp/auth/repository/AuthRepository.kt
new file mode 100644
index 0000000..b19d44d
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/repository/AuthRepository.kt
@@ -0,0 +1,18 @@
+package com.autoever.everp.auth.repository
+
+import com.autoever.everp.auth.config.AuthConfig
+import com.autoever.everp.auth.model.TokenResponse
+
+/**
+ * 인증 도메인 저장소 계약.
+ * 화면(ViewModel)에서는 이 계약에만 의존합니다.
+ */
+interface AuthRepository {
+ suspend fun exchange(
+ code: String,
+ verifier: String,
+ config: AuthConfig = AuthConfig.default(),
+ ): TokenResponse
+
+ suspend fun logout(accessToken: String?): Boolean
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/repository/DefaultAuthRepository.kt b/app/src/main/java/com/autoever/everp/auth/repository/DefaultAuthRepository.kt
new file mode 100644
index 0000000..a18b822
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/repository/DefaultAuthRepository.kt
@@ -0,0 +1,18 @@
+package com.autoever.everp.auth.repository
+
+import com.autoever.everp.auth.config.AuthConfig
+import com.autoever.everp.auth.model.TokenResponse
+import com.autoever.everp.auth.api.AuthApi
+import javax.inject.Inject
+
+class DefaultAuthRepository @Inject constructor(
+ private val api: AuthApi,
+) : AuthRepository {
+ override suspend fun exchange(code: String, verifier: String, config: AuthConfig): TokenResponse {
+ return api.exchangeAuthCodeForToken(config, code, verifier)
+ }
+
+ override suspend fun logout(accessToken: String?): Boolean {
+ return api.logout(accessToken)
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/repository/DefaultUserRepository.kt b/app/src/main/java/com/autoever/everp/auth/repository/DefaultUserRepository.kt
new file mode 100644
index 0000000..73388af
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/repository/DefaultUserRepository.kt
@@ -0,0 +1,14 @@
+package com.autoever.everp.auth.repository
+
+import com.autoever.everp.auth.api.UserApi
+import com.autoever.everp.auth.model.UserInfo
+import javax.inject.Inject
+
+class DefaultUserRepository @Inject constructor(
+ private val api: UserApi,
+) : UserRepository {
+ override suspend fun fetchUserInfo(accessToken: String): UserInfo {
+ return api.getUserInfo(accessToken)
+ }
+}
+
diff --git a/app/src/main/java/com/autoever/everp/auth/repository/UserRepository.kt b/app/src/main/java/com/autoever/everp/auth/repository/UserRepository.kt
new file mode 100644
index 0000000..c3c1231
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/repository/UserRepository.kt
@@ -0,0 +1,11 @@
+package com.autoever.everp.auth.repository
+
+import com.autoever.everp.auth.model.UserInfo
+
+/**
+ * 사용자 정보 도메인 저장소 계약.
+ */
+interface UserRepository {
+ suspend fun fetchUserInfo(accessToken: String): UserInfo
+}
+
diff --git a/app/src/main/java/com/autoever/everp/auth/session/AuthState.kt b/app/src/main/java/com/autoever/everp/auth/session/AuthState.kt
new file mode 100644
index 0000000..2576dea
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/session/AuthState.kt
@@ -0,0 +1,6 @@
+package com.autoever.everp.auth.session
+
+sealed class AuthState {
+ data object Unauthenticated : AuthState()
+ data class Authenticated(val accessToken: String) : AuthState()
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/session/SessionManager.kt b/app/src/main/java/com/autoever/everp/auth/session/SessionManager.kt
new file mode 100644
index 0000000..e78da3e
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/session/SessionManager.kt
@@ -0,0 +1,60 @@
+package com.autoever.everp.auth.session
+
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SessionManager @Inject constructor(
+ private val tokenStore: TokenStore,
+) {
+ private val _state = MutableStateFlow(AuthState.Unauthenticated)
+ val state: StateFlow = _state
+
+ init {
+ refreshFromStore()
+ }
+
+ fun refreshFromStore() {
+ try {
+ val token = tokenStore.getAccessToken()
+ if (token.isNullOrEmpty()) {
+ _state.value = AuthState.Unauthenticated
+ Timber.tag(TAG).i("[INFO] 저장소에서 토큰이 없어 비인증 상태로 설정했습니다.")
+ } else {
+ _state.value = AuthState.Authenticated(token)
+ Timber.tag(TAG).i("[INFO] 저장소의 토큰으로 인증 상태를 설정했습니다. (길이: ${token.length})")
+ }
+ } catch (e: Exception) {
+ _state.value = AuthState.Unauthenticated
+ Log.e(TAG, "[ERROR] 저장소에서 토큰을 불러오는 중 오류가 발생했습니다: ${e.message}")
+ }
+ }
+
+ fun setAuthenticated(accessToken: String) {
+ try {
+ tokenStore.saveAccessToken(accessToken)
+ _state.value = AuthState.Authenticated(accessToken)
+ Timber.tag(TAG).i("[INFO] 인증 완료 상태로 전환했습니다. (토큰 길이: ${accessToken.length})")
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] 인증 상태 설정 중 오류가 발생했습니다: ${e.message}")
+ }
+ }
+
+ fun signOut() {
+ try {
+ tokenStore.clear()
+ _state.value = AuthState.Unauthenticated
+ Timber.tag(TAG).i("[INFO] 로그아웃 완료: 인증 상태를 해제했습니다.")
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] 로그아웃 처리 중 오류가 발생했습니다: ${e.message}")
+ }
+ }
+
+ private companion object {
+ const val TAG = "SessionManager"
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/session/TokenStore.kt b/app/src/main/java/com/autoever/everp/auth/session/TokenStore.kt
new file mode 100644
index 0000000..95302ac
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/session/TokenStore.kt
@@ -0,0 +1,11 @@
+package com.autoever.everp.auth.session
+
+/**
+ * 토큰 저장/조회 간단 스텁 인터페이스.
+ * 이후 보안 저장소(EncryptedSharedPreferences)로 교체 예정.
+ */
+interface TokenStore {
+ fun getAccessToken(): String?
+ fun saveAccessToken(token: String)
+ fun clear()
+}
diff --git a/app/src/main/java/com/autoever/everp/auth/session/TokenStoreImpl.kt b/app/src/main/java/com/autoever/everp/auth/session/TokenStoreImpl.kt
new file mode 100644
index 0000000..cc75eff
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/auth/session/TokenStoreImpl.kt
@@ -0,0 +1,53 @@
+package com.autoever.everp.auth.session
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import timber.log.Timber
+
+/**
+ * SharedPreferences 기반의 간단한 TokenStore 구현.
+ * 추후 EncryptedSharedPreferences로 대체 가능.
+ */
+class TokenStoreImpl(
+ private val prefs: SharedPreferences,
+) : TokenStore {
+
+ override fun getAccessToken(): String? {
+ return try {
+ val token = prefs.getString(KEY_ACCESS_TOKEN, null)
+ if (token.isNullOrEmpty()) {
+ Timber.tag(TAG).i("[INFO] 저장된 액세스 토큰이 없습니다.")
+ null
+ } else {
+ Timber.tag(TAG).i("[INFO] 저장된 액세스 토큰을 불러왔습니다. (길이: ${token.length})")
+ token
+ }
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] 액세스 토큰 조회 중 오류가 발생했습니다: ${e.message}")
+ null
+ }
+ }
+
+ override fun saveAccessToken(token: String) {
+ try {
+ prefs.edit { putString(KEY_ACCESS_TOKEN, token) }
+ Timber.tag(TAG).i("[INFO] 액세스 토큰을 저장했습니다. (길이: ${token.length})")
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] 액세스 토큰 저장 중 오류가 발생했습니다: ${e.message}")
+ }
+ }
+
+ override fun clear() {
+ try {
+ prefs.edit { remove(KEY_ACCESS_TOKEN) }
+ Timber.tag(TAG).i("[INFO] 액세스 토큰을 삭제했습니다.")
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("[ERROR] 액세스 토큰 삭제 중 오류가 발생했습니다: ${e.message}")
+ }
+ }
+
+ private companion object {
+ const val KEY_ACCESS_TOKEN = "access_token"
+ const val TAG = "TokenStore"
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/common/.gitkeep b/app/src/main/java/com/autoever/everp/common/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/java/com/autoever/everp/common/error/AppExceptions.kt b/app/src/main/java/com/autoever/everp/common/error/AppExceptions.kt
new file mode 100644
index 0000000..2f3d830
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/common/error/AppExceptions.kt
@@ -0,0 +1,10 @@
+package com.autoever.everp.common.error
+
+/**
+ * 앱 전반에서 공통으로 사용하는 예외 타입.
+ */
+open class AppException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
+
+/** 401 Unauthorized 표준화 예외 */
+class UnauthorizedException(message: String = "Unauthorized", cause: Throwable? = null) : AppException(message, cause)
+
diff --git a/app/src/main/java/com/autoever/everp/common/exception/GlobalExceptionHandler.kt b/app/src/main/java/com/autoever/everp/common/exception/GlobalExceptionHandler.kt
new file mode 100644
index 0000000..a410f8e
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/common/exception/GlobalExceptionHandler.kt
@@ -0,0 +1,159 @@
+package com.autoever.everp.common.exception
+
+import android.content.Context
+import android.content.Intent
+import android.os.Process
+import com.autoever.everp.domain.exception.ExceptionMapper
+import com.autoever.everp.domain.exception.EverpException
+import com.google.firebase.crashlytics.FirebaseCrashlytics
+import dagger.hilt.android.qualifiers.ApplicationContext
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.system.exitProcess
+
+/**
+ * 전역 예외 처리기
+ *
+ * ### 역할
+ * 1. **앱 크래시 방지**: 처리되지 않은 예외를 잡아 앱 강제 종료 방지
+ * 2. **로그 기록**: Crashlytics와 Timber에 예외 정보 기록
+ * 3. **사용자 알림**: 오류 화면으로 이동하여 사용자에게 안내
+ *
+ * ### 주의사항
+ * - 이 핸들러는 **마지막 방어선**입니다
+ * - Repository/ViewModel에서 적절히 처리한 예외는 여기까지 오지 않습니다
+ * - 여기서 잡히는 예외는 주로 코드 버그나 예상치 못한 오류입니다
+ */
+@Singleton
+class GlobalExceptionHandler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val crashlytics: FirebaseCrashlytics,
+) : Thread.UncaughtExceptionHandler {
+
+ private var defaultHandler: Thread.UncaughtExceptionHandler? = null
+
+ /**
+ * 기본 핸들러 저장 (Application에서 설정)
+ */
+ fun initialize() {
+ defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler(this)
+ Timber.tag(TAG).i("GlobalExceptionHandler initialized")
+ }
+
+ /**
+ * 처리되지 않은 예외 처리
+ */
+ override fun uncaughtException(thread: Thread, throwable: Throwable) {
+ try {
+ Timber.tag(TAG).e(throwable, "========== UNCAUGHT EXCEPTION ==========")
+ Timber.tag(TAG).e("Thread: ${thread.name}")
+ Timber.tag(TAG).e("Exception: ${throwable.javaClass.simpleName}")
+ Timber.tag(TAG).e("Message: ${throwable.message}")
+
+ // EverpException으로 변환하여 분류
+ val everpException = if (throwable is EverpException) {
+ throwable
+ } else {
+ ExceptionMapper.mapToEverpException(throwable)
+ }
+
+ Timber.tag(TAG).e("Mapped to: ${everpException.javaClass.simpleName}")
+ Timber.tag(TAG).e("Error Code: ${everpException.getErrorCode()}")
+ Timber.tag(TAG).e("User Message: ${everpException.getUserMessage()}")
+
+ // Crashlytics에 기록
+ recordToCrashlytics(thread, throwable, everpException)
+
+ // 에러 화면 표시 (사용자에게 알림)
+ showErrorActivity(everpException)
+
+ } catch (e: Exception) {
+ // 예외 처리 중 오류 발생 시 로그만 기록
+ Timber.tag(TAG).e(e, "Error while handling uncaught exception")
+ } finally {
+ // 잠시 대기하여 ErrorActivity가 시작될 시간 확보
+ try {
+ Thread.sleep(100)
+ } catch (e: InterruptedException) {
+ // 무시
+ }
+
+ // 기본 핸들러 호출 (앱 종료)
+ // ErrorActivity가 떠있는 상태에서 기본 핸들러가 호출되지만
+ // ErrorActivity는 새로운 태스크로 실행되었기 때문에 유지됨
+ defaultHandler?.uncaughtException(thread, throwable)
+ ?: run {
+ // 기본 핸들러가 없으면 직접 종료
+ Process.killProcess(Process.myPid())
+ exitProcess(10)
+ }
+ }
+ }
+
+ /**
+ * Crashlytics에 예외 기록
+ */
+ private fun recordToCrashlytics(
+ thread: Thread,
+ originalException: Throwable,
+ everpException: EverpException,
+ ) {
+ try {
+ // 커스텀 키 설정
+ crashlytics.setCustomKey("thread_name", thread.name)
+ crashlytics.setCustomKey("error_code", everpException.getErrorCode())
+ crashlytics.setCustomKey("user_message", everpException.getUserMessage())
+ crashlytics.setCustomKey("is_retryable", everpException.isRetryable())
+ crashlytics.setCustomKey("exception_type", everpException.javaClass.simpleName)
+
+ // 원본 예외 기록 (stackTrace 포함)
+ crashlytics.recordException(originalException)
+
+ Timber.tag(TAG).i("Exception recorded to Crashlytics")
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Failed to record to Crashlytics")
+ }
+ }
+
+ /**
+ * 에러 화면으로 이동
+ *
+ * ### 동작 방식
+ * 1. ErrorActivity를 새로운 태스크로 실행
+ * 2. 기존 액티비티 스택 모두 제거
+ * 3. 사용자에게 오류 정보 표시
+ * 4. 재시작 또는 종료 선택 가능
+ *
+ * ### 주의사항
+ * - Activity가 없는 상태에서도 실행 가능 (FLAG_ACTIVITY_NEW_TASK)
+ * - 뒤로가기로 돌아갈 수 없음 (FLAG_ACTIVITY_CLEAR_TASK)
+ * - 앱 재시작 또는 종료만 가능
+ */
+ private fun showErrorActivity(everpException: EverpException) {
+ try {
+ val errorActivityClass = Class.forName("com.autoever.everp.ui.error.ErrorActivity")
+ val intent = Intent(context, errorActivityClass).apply {
+ // 새로운 태스크로 실행하고 기존 액티비티 스택 제거
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+
+ // 에러 정보 전달
+ putExtra("error_code", everpException.getErrorCode())
+ putExtra("error_message", everpException.getUserMessage())
+ putExtra("is_retryable", everpException.isRetryable())
+ }
+
+ context.startActivity(intent)
+ Timber.tag(TAG).i("Error activity started")
+
+ } catch (e: Exception) {
+ // ErrorActivity가 없거나 실행 실패 시 로그만 기록하고 계속 진행
+ Timber.tag(TAG).e(e, "Failed to show error activity")
+ }
+ }
+
+ companion object {
+ private const val TAG = "GlobalExceptionHandler"
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/AlarmLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/AlarmLocalDataSource.kt
new file mode 100644
index 0000000..2327eb0
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/AlarmLocalDataSource.kt
@@ -0,0 +1,14 @@
+package com.autoever.everp.data.datasource.local
+
+import com.autoever.everp.data.datasource.remote.dto.common.PageResponse
+import com.autoever.everp.domain.model.notification.Notification
+import com.autoever.everp.domain.model.notification.NotificationCount
+import kotlinx.coroutines.flow.Flow
+
+interface AlarmLocalDataSource {
+ fun observeNotifications(): Flow>
+ suspend fun setNotifications(page: PageResponse)
+
+ fun observeNotificationCount(): Flow
+ suspend fun setNotificationCount(count: NotificationCount)
+}
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/DeviceLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/DeviceLocalDataSource.kt
new file mode 100644
index 0000000..0512a27
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/DeviceLocalDataSource.kt
@@ -0,0 +1,52 @@
+package com.autoever.everp.data.datasource.local
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.provider.Settings
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DeviceLocalDataSource @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ /**
+ * Android ID를 가져옵니다.
+ * 앱이 삭제되고 재설치되어도 동일한 값이 유지됩니다.
+ */
+ @SuppressLint("HardwareIds")
+ fun getAndroidId(): String {
+ return Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
+ ?: ""
+ }
+
+ /**
+ * 기기 제조사 이름을 가져옵니다.
+ */
+ fun getManufacturer(): String {
+ return Build.MANUFACTURER
+ }
+
+ /**
+ * 기기 모델 이름을 가져옵니다.
+ */
+ fun getModel(): String {
+ return Build.MODEL
+ }
+
+ /**
+ * OS 버전을 가져옵니다.
+ */
+ fun getOsVersion(): String {
+ return Build.VERSION.RELEASE
+ }
+
+ /**
+ * SDK 버전을 가져옵니다.
+ */
+ fun getSdkVersion(): Int {
+ return Build.VERSION.SDK_INT
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/FcmLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/FcmLocalDataSource.kt
new file mode 100644
index 0000000..061f51d
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/FcmLocalDataSource.kt
@@ -0,0 +1,29 @@
+package com.autoever.everp.data.datasource.local
+
+import com.autoever.everp.data.datasource.remote.dto.common.PageResponse
+import com.autoever.everp.domain.model.invoice.InvoiceDetail
+import com.autoever.everp.domain.model.invoice.InvoiceListItem
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * FCM(재무 관리) 로컬 데이터소스 인터페이스
+ */
+interface FcmLocalDataSource {
+ // ========== AP 인보이스 (매입) ==========
+ fun observeApInvoiceList(): Flow>
+ suspend fun setApInvoiceList(page: PageResponse)
+
+ fun observeApInvoiceDetail(invoiceId: String): Flow
+ suspend fun setApInvoiceDetail(invoiceId: String, detail: InvoiceDetail)
+
+ // ========== AR 인보이스 (매출) ==========
+ fun observeArInvoiceList(): Flow>
+ suspend fun setArInvoiceList(page: PageResponse)
+
+ fun observeArInvoiceDetail(invoiceId: String): Flow
+ suspend fun setArInvoiceDetail(invoiceId: String, detail: InvoiceDetail)
+
+ // ========== 캐시 관리 ==========
+ suspend fun clearAll()
+}
+
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/MmLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/MmLocalDataSource.kt
new file mode 100644
index 0000000..79c30cf
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/MmLocalDataSource.kt
@@ -0,0 +1,27 @@
+package com.autoever.everp.data.datasource.local
+
+import com.autoever.everp.data.datasource.remote.dto.common.PageResponse
+import com.autoever.everp.domain.model.purchase.PurchaseOrderDetail
+import com.autoever.everp.domain.model.purchase.PurchaseOrderListItem
+import com.autoever.everp.domain.model.supplier.SupplierDetail
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * MM(자재 관리) 로컬 데이터소스 인터페이스
+ */
+interface MmLocalDataSource {
+ // ========== 공급업체 ==========
+ fun observeSupplierDetail(supplierId: String): Flow
+ suspend fun setSupplierDetail(supplierId: String, detail: SupplierDetail)
+
+ // ========== 구매 주문 ==========
+ fun observePurchaseOrderList(): Flow>
+ suspend fun setPurchaseOrderList(page: PageResponse)
+
+ fun observePurchaseOrderDetail(purchaseOrderId: String): Flow
+ suspend fun setPurchaseOrderDetail(purchaseOrderId: String, detail: PurchaseOrderDetail)
+ suspend fun removePurchaseOrderDetail(purchaseOrderId: String)
+
+ // ========== 캐시 관리 ==========
+ suspend fun clearAll()
+}
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/SdLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/SdLocalDataSource.kt
new file mode 100644
index 0000000..178185b
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/SdLocalDataSource.kt
@@ -0,0 +1,35 @@
+package com.autoever.everp.data.datasource.local
+
+import com.autoever.everp.data.datasource.remote.dto.common.PageResponse
+import com.autoever.everp.domain.model.customer.CustomerDetail
+import com.autoever.everp.domain.model.quotation.QuotationDetail
+import com.autoever.everp.domain.model.quotation.QuotationListItem
+import com.autoever.everp.domain.model.sale.SalesOrderDetail
+import com.autoever.everp.domain.model.sale.SalesOrderListItem
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * SD(영업 관리) 로컬 데이터소스 인터페이스
+ */
+interface SdLocalDataSource {
+ // ========== 견적서 ==========
+ fun observeQuotationList(): Flow>
+ suspend fun setQuotationList(page: PageResponse)
+
+ fun observeQuotationDetail(quotationId: String): Flow
+ suspend fun setQuotationDetail(quotationId: String, detail: QuotationDetail)
+
+ // ========== 고객사 ==========
+ fun observeCustomerDetail(customerId: String): Flow
+ suspend fun setCustomerDetail(customerId: String, detail: CustomerDetail)
+
+ // ========== 주문서 ==========
+ fun observeSalesOrderList(): Flow>
+ suspend fun setSalesOrderList(page: PageResponse)
+
+ fun observeSalesOrderDetail(salesOrderId: String): Flow
+ suspend fun setSalesOrderDetail(salesOrderId: String, detail: SalesOrderDetail)
+
+ // ========== 캐시 관리 ==========
+ suspend fun clearAll()
+}
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/UserLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/UserLocalDataSource.kt
new file mode 100644
index 0000000..75eeea4
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/UserLocalDataSource.kt
@@ -0,0 +1,24 @@
+package com.autoever.everp.data.datasource.local
+
+import com.autoever.everp.domain.model.user.UserInfo
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 사용자 정보 로컬 데이터소스 인터페이스
+ */
+interface UserLocalDataSource {
+ /**
+ * 사용자 정보 관찰
+ */
+ fun observeUserInfo(): Flow
+
+ /**
+ * 사용자 정보 저장
+ */
+ suspend fun setUserInfo(userInfo: UserInfo)
+
+ /**
+ * 사용자 정보 삭제 (로그아웃)
+ */
+ suspend fun clearUserInfo()
+}
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/.gitkeep b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/AlarmLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/AlarmLocalDataSourceImpl.kt
new file mode 100644
index 0000000..7925cd8
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/AlarmLocalDataSourceImpl.kt
@@ -0,0 +1,39 @@
+package com.autoever.everp.data.datasource.local.impl
+
+import com.autoever.everp.data.datasource.local.AlarmLocalDataSource
+import com.autoever.everp.data.datasource.remote.dto.common.PageDto
+import com.autoever.everp.data.datasource.remote.dto.common.PageResponse
+import com.autoever.everp.data.datasource.remote.http.service.NotificationListItemDto
+import com.autoever.everp.domain.model.notification.Notification
+import com.autoever.everp.domain.model.notification.NotificationCount
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AlarmLocalDataSourceImpl @Inject constructor() : AlarmLocalDataSource {
+ private val notificationsFlow = MutableStateFlow(
+ value = PageResponse(
+ content = emptyList(),
+ page = PageDto(0, 0, 0, 0, false)
+ ),
+ )
+
+ private val countFlow = MutableStateFlow(
+ NotificationCount(totalCount = 0, unreadCount = 0, readCount = 0),
+ )
+
+ override fun observeNotifications(): Flow> = notificationsFlow.asStateFlow()
+
+ override suspend fun setNotifications(page: PageResponse) {
+ notificationsFlow.value = page
+ }
+
+ override fun observeNotificationCount(): Flow = countFlow.asStateFlow()
+
+ override suspend fun setNotificationCount(count: NotificationCount) {
+ countFlow.value = count
+ }
+}
diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/FcmLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/FcmLocalDataSourceImpl.kt
new file mode 100644
index 0000000..d038ef1
--- /dev/null
+++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/FcmLocalDataSourceImpl.kt
@@ -0,0 +1,81 @@
+package com.autoever.everp.data.datasource.local.impl
+
+import com.autoever.everp.data.datasource.local.FcmLocalDataSource
+import com.autoever.everp.data.datasource.remote.dto.common.PageDto
+import com.autoever.everp.data.datasource.remote.dto.common.PageResponse
+import com.autoever.everp.domain.model.invoice.InvoiceDetail
+import com.autoever.everp.domain.model.invoice.InvoiceListItem
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * FCM(재무 관리) 로컬 데이터소스 구현체
+ */
+@Singleton
+class FcmLocalDataSourceImpl @Inject constructor() : FcmLocalDataSource {
+
+ // AP 인보이스 캐시
+ private val apInvoiceListFlow = MutableStateFlow(
+ PageResponse(
+ content = emptyList(),
+ page = PageDto(0, 0, 0, 0, false),
+ ),
+ )
+ private val apInvoiceDetailsFlow = MutableStateFlow