Initial commit: HotDeal Alarm Android App

Features:
- Multi-site hot deal scraping (Ppomppu, Clien, Ruriweb, Coolenjoy)
- Site filter with color-coded badges
- Board display names (e.g., ppomppu8 -> 알리뽐뿌)
- Anti-bot protection with request delays and User-Agent rotation
- Keyword matching and notifications
- Material Design 3 UI
This commit is contained in:
sanjeok77
2026-03-04 01:29:34 +09:00
commit 25a349f273
84 changed files with 6455 additions and 0 deletions

86
.gitignore vendored Normal file
View File

@@ -0,0 +1,86 @@
# Gradle files
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
# Local configuration file (sdk path, etc)
local.properties
# Android Studio
.idea/
*.iml
*.ipr
*.iws
.navigation/
captures/
# Keystore files
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
# OS-specific files
.DS_Store
Thumbs.db
# Log files
*.log
# Temporary files
*.tmp
*.temp
*.swp
*~
# Build outputs
app/build/
app/release/
app/debug/
*.apk
*.aab
# Kotlin
.kotlin/
# Room schema exports
app/schemas/
# Test outputs
app/test-results/
app/reports/
# Environment files
.env
.env.local
.env.*.local
# Documentation (keep only README.md)
ARCHITECTURE.md
DESIGN_IMPROVEMENT_PLAN.md
DEVELOPMENT_REPORT.md
PHASE2_REPORT.md
PHASE3_REPORT.md
# Node modules (if any)
node_modules/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Misc
*.bak
*.orig

234
README.md Normal file
View File

@@ -0,0 +1,234 @@
# 핫딜 알람 Android 앱
## 프로젝트 개요
Android 12 (API 31) ~ Android 16 최신 버전을 지원하는 프로덕션 수준의 핫딜 알람 앱입니다.
### 지원 사이트
- **뽐뿌** (ppomppu, ppomppu4, ppomppu8, money)
- **클리앙** (allsell, jirum)
- **루리웹** (1020, 600004)
- **쿨엔조이** (jirum)
- ~~퀘이사존~~ (제거됨 - Cloudflare 보호)
- **뽐뿌** (ppomppu, ppomppu4, ppomppu8, money)
- **클리앙** (allsell, jirum)
- **루리웹** (1020, 600004)
- **쿨엔조이** (jirum)
- **퀘이사존** (qb_saleinfo)
### 주요 기능
- ✅ 사이트/게시판 선택적 모니터링
- ✅ 키워드 기반 필터링 알림
- ✅ 백그라운드 폴링 (WorkManager)
- ✅ 로컬 알림 시스템
- ✅ 핫딜 히스토리 관리
- ✅ Android 12-16 완벽 지원
---
## 기술 스택
### Core
- **Kotlin 1.9.22**
- **Coroutines + Flow** (비동기 처리)
- **Hilt** (의존성 주입)
- **Jetpack Compose** (UI)
### Data
- **Room 2.6.1** (로컬 데이터베이스)
- **DataStore** (설정 저장)
- **OkHttp 4.12.0** (HTTP 클라이언트)
- **Jsoup 1.17.2** (HTML 파싱)
### Background
- **WorkManager 2.9.0** (주기적 폴링)
### Architecture
- **MVVM + Clean Architecture (3-layer)**
- **Repository Pattern**
- **Single Activity Architecture**
---
## 프로젝트 구조
```
com.hotdeal.alarm/
├── di/ # Hilt DI 모듈
│ ├── DatabaseModule.kt
│ └── NetworkModule.kt
├── data/
│ ├── local/db/ # Room 데이터베이스
│ │ ├── entity/ # Entity 클래스
│ │ ├── dao/ # DAO 인터페이스
│ │ ├── AppDatabase.kt
│ │ └── Converters.kt
│ └── remote/
│ ├── scraper/ # 사이트별 스크래퍼
│ └── interceptor/ # OkHttp 인터셉터
├── domain/
│ └── model/ # 도메인 모델
│ ├── HotDeal.kt
│ ├── SiteConfig.kt
│ ├── Keyword.kt
│ └── SiteType.kt
├── presentation/
│ ├── main/ # 메인 화면
│ ├── settings/ # 설정 화면
│ ├── deallist/ # 핫딜 목록 화면
│ └── components/ # 재사용 컴포넌트
├── worker/ # WorkManager Worker
│ ├── HotDealPollingWorker.kt
│ └── WorkerScheduler.kt
├── service/ # 서비스
│ └── NotificationService.kt
└── util/ # 유틸리티
├── Constants.kt
└── BootReceiver.kt
```
---
## 빌드 방법
### 요구사항
- Android Studio Hedgehog 이상
- JDK 17
- Android SDK 35
### 빌드 명령어
```bash
cd android_app
./gradlew assembleDebug
```
### APK 설치 (WSL 환경)
WSL에서 Windows ADB를 사용하여 설치:
```bash
# Windows ADB 경로 (WSL에서)
/mnt/c/Users/$USER/AppData/Local/Android/Sdk/platform-tools/adb.exe install app/build/outputs/apk/debug/app-debug.apk
```
또는 PowerShell/CMD에서:
```powershell
adb install "\\wsl.localhost\Ubuntu\home\work\hotdeal_alarm\app\build\outputs\apk\debug\app-debug.apk"
```
```bash
cd android_app
./gradlew assembleDebug
```
### 릴리즈 빌드
```bash
./gradlew assembleRelease
```
---
## 권한
### 필수 권한
| 권한 | 용도 | Android 버전 |
|------|------|--------------|
| INTERNET | 웹 스크래핑 | 모든 버전 |
| FOREGROUND_SERVICE | 백그라운드 폴링 | 모든 버전 |
| POST_NOTIFICATIONS | 알림 표시 | Android 13+ |
| SCHEDULE_EXACT_ALARM | 정확한 알람 | Android 12+ |
| RECEIVE_BOOT_COMPLETED | 부팅 시 자동 시작 | 모든 버전 |
---
## 백그라운드 폴링 전략
### WorkManager 기반
| 간격 | 전략 | 설명 |
|------|------|------|
| 1-2분 | Foreground Service | 빈번한 폴링 (알림 표시) |
| 3-5분 | PeriodicWorkManager | 표준 폴링 |
| 15분+ | PeriodicWorkManager | 배터리 최적화 모드 |
### Constraints
- 네트워크 연결 필요
- 배터리 부족 시 실행 안 함
- Doze 모드 대응
---
## 알림 시스템
### Notification Channels
| Channel | 중요도 | 용도 |
|---------|--------|------|
| hotdeal_urgent | HIGH | 키워드 매칭 핫딜 |
| hotdeal_normal | DEFAULT | 새로운 핫딜 |
| hotdeal_background | LOW | 폴링 상태 |
---
## 스크래핑 구현
### Anti-Bot 대응
1. **User-Agent 회전**: Android Chrome, Desktop Chrome 등 번갈아 사용
2. **Rate Limiting**: 요청 간 최소 3초 대기
3. **Retry with Backoff**: 실패 시 지수 백오프로 재시도
4. **Header 조작**: Referer, Accept-Language 등 추가
### Cloudflare 대응
- ~~퀘이사존~~ 제거됨
- 퀘이사존은 Cloudflare 보호가 적용될 수 있음
- 일반 HTTP 요청으로 차단될 경우 WebView 기반 bypass 고려
---
## 테스트
### Unit Tests
```bash
./gradlew test
```
### Instrumentation Tests
```bash
./gradlew connectedAndroidTest
```
---
## 향후 계획
### Phase 2
- [x] FCM 기반 서버 푸시 (로컬 알림으로 대체)
- [x] 위젯 지원
- [x] 다크 모드 개선
- [x] 공유 기능
### Phase 3
- [x] 디자인 시스템 구축
- [x] 로딩 스켈레톤 (Shimmer Effect)
- [x] 빈 상태 컴포넌트
- [x] 애니메이션 컴포넌트
### Phase 4
- [ ] 가격 비교 기능
- [ ] 즐겨찾기 기능
- [ ] 알림 그룹화
---
## 라이선스
MIT License
---
## 기여
버그 리포트나 기능 제안은 GitHub Issues를 이용해주세요.

140
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,140 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.hotdeal.alarm"
compileSdk = 35
defaultConfig {
applicationId = "com.hotdeal.alarm"
minSdk = 31
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview"
)
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
all {
it.testLogging {
events("passed", "failed", "skipped")
showStandardStreams = true
}
}
}
}
}
dependencies {
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.22")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// AndroidX Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
// Jetpack Compose
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.7.6")
// Hilt Dependency Injection
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("androidx.hilt:hilt-work:1.1.0")
ksp("androidx.hilt:hilt-compiler:1.1.0")
// Room Database
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// DataStore Preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.9.0")
// OkHttp & Jsoup (Web Scraping)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jsoup:jsoup:1.17.2")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("org.robolectric:robolectric:4.11.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

18
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,18 @@
# Add project specific ProGuard rules here.
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.hotdeal.alarm.data.local.db.entity.** { *; }
-keep class com.hotdeal.alarm.domain.model.** { *; }
# OkHttp
-dontwarn okhttp3.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Jsoup
-keep class org.jsoup.** { *; }
-keepclassmembers class org.jsoup.** { *; }
# Kotlin Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}

View File

@@ -0,0 +1,105 @@
package com.hotdeal.alarm
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.presentation.components.DealItem
import org.junit.Rule
import org.junit.Test
/**
* DealItem 컴포넌트 UI 테스트
*/
class DealItemTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `DealItem should display title correctly`() {
// Given
val deal = HotDeal(
id = "test_123",
siteName = "ppomppu",
boardName = "ppomppu",
title = "갤럭시 S24 울트라 핫딜",
url = "https://test.com",
createdAt = System.currentTimeMillis()
)
// When
composeTestRule.setContent {
DealItem(deal = deal, onClick = {})
}
// Then
composeTestRule.onNodeWithText("갤럭시 S24 울트라 핫딜").assertExists()
}
@Test
fun `DealItem should display site name`() {
// Given
val deal = HotDeal(
id = "test_123",
siteName = "ppomppu",
boardName = "ppomppu",
title = "Test Deal",
url = "https://test.com",
createdAt = System.currentTimeMillis()
)
// When
composeTestRule.setContent {
DealItem(deal = deal, onClick = {})
}
// Then
composeTestRule.onNodeWithText("뽐뿌").assertExists()
}
@Test
fun `DealItem should be clickable`() {
// Given
var clicked = false
val deal = HotDeal(
id = "test_123",
siteName = "ppomppu",
boardName = "ppomppu",
title = "Test Deal",
url = "https://test.com",
createdAt = System.currentTimeMillis()
)
// When
composeTestRule.setContent {
DealItem(deal = deal, onClick = { clicked = true })
}
// Then
composeTestRule.onNodeWithText("Test Deal").performClick()
assert(clicked)
}
@Test
fun `DealItem should show keyword match indicator when isKeywordMatch is true`() {
// Given
val deal = HotDeal(
id = "test_123",
siteName = "ppomppu",
boardName = "ppomppu",
title = "Test Deal",
url = "https://test.com",
createdAt = System.currentTimeMillis(),
isKeywordMatch = true
)
// When
composeTestRule.setContent {
DealItem(deal = deal, onClick = {})
}
// Then - 키워드 매칭 아이콘이 표시되어야 함
composeTestRule.onNodeWithContentDescription("키워드 매칭").assertExists()
}
}

View File

@@ -0,0 +1,108 @@
package com.hotdeal.alarm
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.hotdeal.alarm.domain.model.Keyword
import com.hotdeal.alarm.domain.model.MatchMode
import com.hotdeal.alarm.presentation.settings.KeywordItem
import org.junit.Rule
import org.junit.Test
/**
* KeywordItem 컴포넌트 UI 테스트
*/
class KeywordItemTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `KeywordItem should display keyword text`() {
// Given
val keyword = Keyword(
id = 1L,
keyword = "갤럭시",
isEnabled = true
)
// When
composeTestRule.setContent {
KeywordItem(
keyword = keyword,
onToggle = {},
onDelete = {}
)
}
// Then
composeTestRule.onNodeWithText("갤럭시").assertExists()
}
@Test
fun `KeywordItem should have toggle switch`() {
// Given
val keyword = Keyword(
id = 1L,
keyword = "test",
isEnabled = true
)
// When
composeTestRule.setContent {
KeywordItem(
keyword = keyword,
onToggle = {},
onDelete = {}
)
}
// Then
composeTestRule.onNodeWithText("test").assertExists()
}
@Test
fun `KeywordItem should have delete button`() {
// Given
val keyword = Keyword(
id = 1L,
keyword = "test",
isEnabled = true
)
// When
composeTestRule.setContent {
KeywordItem(
keyword = keyword,
onToggle = {},
onDelete = {}
)
}
// Then
composeTestRule.onNodeWithContentDescription("삭제").assertExists()
}
@Test
fun `KeywordItem toggle should call onToggle`() {
// Given
var toggled = false
val keyword = Keyword(
id = 1L,
keyword = "test",
isEnabled = true
)
// When
composeTestRule.setContent {
KeywordItem(
keyword = keyword,
onToggle = { toggled = true },
onDelete = {}
)
}
// Then - Switch를 찾아서 클릭
composeTestRule.onNode(isToggleable()).performClick()
assert(toggled)
}
}

View File

@@ -0,0 +1,126 @@
package com.hotdeal.alarm
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.hotdeal.alarm.presentation.components.PermissionDialog
import org.junit.Rule
import org.junit.Test
/**
* PermissionDialog UI 테스트
*/
class PermissionDialogTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `PermissionDialog should display title`() {
// Given
val title = "알림 권한 필요"
// When
composeTestRule.setContent {
PermissionDialog(
title = title,
message = "Test message",
onDismiss = {},
onOpenSettings = {}
)
}
// Then
composeTestRule.onNodeWithText(title).assertExists()
}
@Test
fun `PermissionDialog should display message`() {
// Given
val message = "핫딜 알림을 받으려면 알림 권한이 필요합니다."
// When
composeTestRule.setContent {
PermissionDialog(
title = "Test",
message = message,
onDismiss = {},
onOpenSettings = {}
)
}
// Then
composeTestRule.onNodeWithText(message).assertExists()
}
@Test
fun `PermissionDialog should have settings button`() {
// When
composeTestRule.setContent {
PermissionDialog(
title = "Test",
message = "Test message",
onDismiss = {},
onOpenSettings = {}
)
}
// Then
composeTestRule.onNodeWithText("설정 열기").assertExists()
}
@Test
fun `PermissionDialog should have cancel button`() {
// When
composeTestRule.setContent {
PermissionDialog(
title = "Test",
message = "Test message",
onDismiss = {},
onOpenSettings = {}
)
}
// Then
composeTestRule.onNodeWithText("취소").assertExists()
}
@Test
fun `PermissionDialog should call onOpenSettings when settings button clicked`() {
// Given
var settingsClicked = false
// When
composeTestRule.setContent {
PermissionDialog(
title = "Test",
message = "Test message",
onDismiss = {},
onOpenSettings = { settingsClicked = true }
)
}
// Then
composeTestRule.onNodeWithText("설정 열기").performClick()
assert(settingsClicked)
}
@Test
fun `PermissionDialog should call onDismiss when cancel button clicked`() {
// Given
var dismissed = false
// When
composeTestRule.setContent {
PermissionDialog(
title = "Test",
message = "Test message",
onDismiss = { dismissed = true },
onOpenSettings = {}
)
}
// Then
composeTestRule.onNodeWithText("취소").performClick()
assert(dismissed)
}
}

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Background Work -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Exact Alarms (Android 12+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- Boot Completed -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".HotDealApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HotDealAlarm"
android:usesCleartextTraffic="true"
tools:targetApi="35">
<!-- Disable default WorkManager initializer to use HiltWorkerFactory -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".presentation.main.MainActivity"
android:exported="true"
android:theme="@style/Theme.HotDealAlarm">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground Service for frequent polling -->
<service
android:name=".service.ForegroundPollingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Boot Receiver -->
<!-- Widget -->
<receiver
android:name=".widget.HotDealWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,24 @@
package com.hotdeal.alarm
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class HotDealApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
// Application initialization
}
}

View File

@@ -0,0 +1,56 @@
package com.hotdeal.alarm.data.local.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.hotdeal.alarm.data.local.db.dao.HotDealDao
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
import com.hotdeal.alarm.data.local.db.entity.HotDealEntity
import com.hotdeal.alarm.data.local.db.entity.KeywordEntity
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
/**
* 핫딜 알람 데이터베이스
*/
@Database(
entities = [
HotDealEntity::class,
SiteConfigEntity::class,
KeywordEntity::class
],
version = 3,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun hotDealDao(): HotDealDao
abstract fun siteConfigDao(): SiteConfigDao
abstract fun keywordDao(): KeywordDao
companion object {
const val DATABASE_NAME = "hotdeal_alarm_db"
/**
* Migration 1 -> 2: 키워드 매칭 모드 추가
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE keywords ADD COLUMN matchMode TEXT NOT NULL DEFAULT 'CONTAINS'")
}
}
/**
* Migration 2 -> 3: 인덱스 추가
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS index_hot_deals_siteName_boardName ON hot_deals(siteName, boardName)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_hot_deals_createdAt ON hot_deals(createdAt)")
}
}
}
}

View File

@@ -0,0 +1,19 @@
package com.hotdeal.alarm.data.local.db
import androidx.room.TypeConverter
/**
* Room Type Converters
*/
class Converters {
@TypeConverter
fun fromStringList(value: String?): List<String> {
return value?.split(",")?.map { it.trim() } ?: emptyList()
}
@TypeConverter
fun toStringList(list: List<String>?): String? {
return list?.joinToString(",")
}
}

View File

@@ -0,0 +1,23 @@
package com.hotdeal.alarm.data.local.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.hotdeal.alarm.data.local.db.entity.DealEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface DealDao {
@Query("SELECT * FROM deals ORDER BY timestamp DESC")
fun getAllDeals(): Flow<List<DealEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDeals(deals: List<DealEntity>)
@Query("DELETE FROM deals WHERE timestamp < :threshold")
suspend fun deleteOldDeals(threshold: Long)
@Query("UPDATE deals SET isRead = 1 WHERE id = :id")
suspend fun markAsRead(id: String)
}

View File

@@ -0,0 +1,102 @@
package com.hotdeal.alarm.data.local.db.dao
import androidx.room.*
import com.hotdeal.alarm.data.local.db.entity.HotDealEntity
import kotlinx.coroutines.flow.Flow
/**
* 핫딜 DAO
*/
@Dao
interface HotDealDao {
/**
* 모든 핫딜 조회 (Flow)
*/
@Query("SELECT * FROM hot_deals ORDER BY createdAt DESC")
fun observeAllDeals(): Flow<List<HotDealEntity>>
/**
* 특정 사이트의 핫딜 조회
*/
@Query("SELECT * FROM hot_deals WHERE siteName = :siteName ORDER BY createdAt DESC")
fun observeDealsBySite(siteName: String): Flow<List<HotDealEntity>>
/**
* 특정 사이트/게시판의 핫딜 조회
*/
@Query("SELECT * FROM hot_deals WHERE siteName = :siteName AND boardName = :boardName ORDER BY createdAt DESC")
fun observeDealsBySiteAndBoard(siteName: String, boardName: String): Flow<List<HotDealEntity>>
/**
* 최근 핫딜 조회
*/
@Query("SELECT * FROM hot_deals WHERE createdAt > :since ORDER BY createdAt DESC")
suspend fun getRecentDeals(since: Long): List<HotDealEntity>
/**
* 알림 미발송 핫딜 조회
*/
@Query("SELECT * FROM hot_deals WHERE isNotified = 0 ORDER BY createdAt DESC")
suspend fun getUnnotifiedDeals(): List<HotDealEntity>
/**
* ID로 핫딜 조회
*/
@Query("SELECT * FROM hot_deals WHERE id = :id")
suspend fun getDealById(id: String): HotDealEntity?
/**
* URL로 핫딜 조회 (중복 체크용)
*/
@Query("SELECT * FROM hot_deals WHERE url = :url LIMIT 1")
suspend fun getDealByUrl(url: String): HotDealEntity?
/**
* 핫딜 일괄 저장 (중복 무시)
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertDeals(deals: List<HotDealEntity>)
/**
* 단일 핫딜 저장
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDeal(deal: HotDealEntity)
/**
* 알림 발송 상태 업데이트
*/
@Query("UPDATE hot_deals SET isNotified = 1 WHERE id IN (:ids)")
suspend fun markAsNotified(ids: List<String>)
/**
* 키워드 매칭 상태 업데이트
*/
@Query("UPDATE hot_deals SET isKeywordMatch = 1 WHERE id IN (:ids)")
suspend fun markAsKeywordMatch(ids: List<String>)
/**
* 오래된 핫딜 삭제
*/
@Query("DELETE FROM hot_deals WHERE createdAt < :threshold")
suspend fun deleteOldDeals(threshold: Long)
/**
* 전체 핫딜 삭제
*/
@Query("DELETE FROM hot_deals")
suspend fun deleteAllDeals()
/**
* 핫딜 개수 조회
*/
@Query("SELECT COUNT(*) FROM hot_deals")
suspend fun getDealCount(): Int
/**
* 제목으로 검색
*/
@Query("SELECT * FROM hot_deals WHERE title LIKE '%' || :query || '%' ORDER BY createdAt DESC")
fun searchDeals(query: String): Flow<List<HotDealEntity>>
}

View File

@@ -0,0 +1,84 @@
package com.hotdeal.alarm.data.local.db.dao
import androidx.room.*
import com.hotdeal.alarm.data.local.db.entity.KeywordEntity
import kotlinx.coroutines.flow.Flow
/**
* 키워드 DAO
*/
@Dao
interface KeywordDao {
/**
* 모든 키워드 조회 (Flow)
*/
@Query("SELECT * FROM keywords ORDER BY createdAt DESC")
fun observeAllKeywords(): Flow<List<KeywordEntity>>
/**
* 모든 키워드 조회
*/
@Query("SELECT * FROM keywords ORDER BY createdAt DESC")
suspend fun getAllKeywords(): List<KeywordEntity>
/**
* 활성화된 키워드 조회
*/
@Query("SELECT * FROM keywords WHERE isEnabled = 1 ORDER BY createdAt DESC")
suspend fun getEnabledKeywords(): List<KeywordEntity>
/**
* ID로 키워드 조회
*/
@Query("SELECT * FROM keywords WHERE id = :id")
suspend fun getKeywordById(id: Long): KeywordEntity?
/**
* 키워드 저장
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertKeyword(keyword: KeywordEntity): Long
/**
* 키워드 일괄 저장
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertKeywords(keywords: List<KeywordEntity>)
/**
* 키워드 업데이트
*/
@Update
suspend fun updateKeyword(keyword: KeywordEntity)
/**
* 활성화 상태 업데이트
*/
@Query("UPDATE keywords SET isEnabled = :enabled WHERE id = :id")
suspend fun updateEnabled(id: Long, enabled: Boolean)
/**
* 키워드 삭제
*/
@Delete
suspend fun deleteKeyword(keyword: KeywordEntity)
/**
* ID로 키워드 삭제
*/
@Query("DELETE FROM keywords WHERE id = :id")
suspend fun deleteKeywordById(id: Long)
/**
* 전체 키워드 삭제
*/
@Query("DELETE FROM keywords")
suspend fun deleteAllKeywords()
/**
* 키워드 개수 조회
*/
@Query("SELECT COUNT(*) FROM keywords")
suspend fun getKeywordCount(): Int
}

View File

@@ -0,0 +1,72 @@
package com.hotdeal.alarm.data.local.db.dao
import androidx.room.*
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
import kotlinx.coroutines.flow.Flow
/**
* 사이트 설정 DAO
*/
@Dao
interface SiteConfigDao {
/**
* 모든 설정 조회
*/
@Query("SELECT * FROM site_configs")
suspend fun getAllConfigs(): List<SiteConfigEntity>
/**
* 모든 설정 조회 (Flow)
*/
@Query("SELECT * FROM site_configs")
fun observeAllConfigs(): Flow<List<SiteConfigEntity>>
/**
* 활성화된 설정 조회
*/
@Query("SELECT * FROM site_configs WHERE isEnabled = 1")
suspend fun getEnabledConfigs(): List<SiteConfigEntity>
/**
* 특정 사이트의 설정 조회
*/
@Query("SELECT * FROM site_configs WHERE siteName = :siteName")
suspend fun getConfigsBySite(siteName: String): List<SiteConfigEntity>
/**
* 특정 설정 조회
*/
@Query("SELECT * FROM site_configs WHERE siteBoardKey = :key")
suspend fun getConfigByKey(key: String): SiteConfigEntity?
/**
* 설정 저장
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertConfig(config: SiteConfigEntity)
/**
* 설정 일괄 저장
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertConfigs(configs: List<SiteConfigEntity>)
/**
* 활성화 상태 업데이트
*/
@Query("UPDATE site_configs SET isEnabled = :enabled WHERE siteBoardKey = :key")
suspend fun updateEnabled(key: String, enabled: Boolean)
/**
* 마지막 스크래핑 시간 업데이트
*/
@Query("UPDATE site_configs SET lastScrapedAt = :timestamp WHERE siteBoardKey = :key")
suspend fun updateLastScrapedAt(key: String, timestamp: Long)
/**
* 설정 삭제
*/
@Delete
suspend fun deleteConfig(config: SiteConfigEntity)
}

View File

@@ -0,0 +1,15 @@
package com.hotdeal.alarm.data.local.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "deals")
data class DealEntity(
@PrimaryKey val id: String,
val title: String,
val url: String,
val price: String?,
val source: String,
val timestamp: Long,
val isRead: Boolean = false
)

View File

@@ -0,0 +1,65 @@
package com.hotdeal.alarm.data.local.db.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.hotdeal.alarm.domain.model.HotDeal
/**
* 핫딜 Entity
*/
@Entity(
tableName = "hot_deals",
indices = [
Index(value = ["siteName", "boardName"]),
Index(value = ["createdAt"])
]
)
data class HotDealEntity(
@PrimaryKey
val id: String,
val siteName: String,
val boardName: String,
val title: String,
val url: String,
val mallUrl: String?,
val createdAt: Long,
val isNotified: Boolean = false,
val isKeywordMatch: Boolean = false
) {
/**
* Domain 모델로 변환
*/
fun toDomain(): HotDeal {
return HotDeal(
id = id,
siteName = siteName,
boardName = boardName,
title = title,
url = url,
mallUrl = mallUrl,
createdAt = createdAt,
isNotified = isNotified,
isKeywordMatch = isKeywordMatch
)
}
companion object {
/**
* Domain 모델에서 Entity 생성
*/
fun fromDomain(domain: HotDeal): HotDealEntity {
return HotDealEntity(
id = domain.id,
siteName = domain.siteName,
boardName = domain.boardName,
title = domain.title,
url = domain.url,
mallUrl = domain.mallUrl,
createdAt = domain.createdAt,
isNotified = domain.isNotified,
isKeywordMatch = domain.isKeywordMatch
)
}
}
}

View File

@@ -0,0 +1,45 @@
package com.hotdeal.alarm.data.local.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.hotdeal.alarm.domain.model.Keyword
import com.hotdeal.alarm.domain.model.MatchMode
/**
* 키워드 Entity
*/
@Entity(tableName = "keywords")
data class KeywordEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val keyword: String,
val isEnabled: Boolean = true,
val matchMode: String = MatchMode.CONTAINS.name,
val createdAt: Long = System.currentTimeMillis()
) {
fun toDomain(): Keyword {
return Keyword(
id = id,
keyword = keyword,
isEnabled = isEnabled,
matchMode = try {
MatchMode.valueOf(matchMode)
} catch (e: IllegalArgumentException) {
MatchMode.CONTAINS
},
createdAt = createdAt
)
}
companion object {
fun fromDomain(domain: Keyword): KeywordEntity {
return KeywordEntity(
id = domain.id,
keyword = domain.keyword,
isEnabled = domain.isEnabled,
matchMode = domain.matchMode.name,
createdAt = domain.createdAt
)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.hotdeal.alarm.data.local.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.hotdeal.alarm.domain.model.SiteConfig
/**
* 사이트 설정 Entity
*/
@Entity(tableName = "site_configs")
data class SiteConfigEntity(
@PrimaryKey
val siteBoardKey: String,
val siteName: String,
val boardName: String,
val displayName: String,
val isEnabled: Boolean = false,
val lastScrapedAt: Long? = null
) {
fun toDomain(): SiteConfig {
return SiteConfig(
siteBoardKey = siteBoardKey,
siteName = siteName,
boardName = boardName,
displayName = displayName,
isEnabled = isEnabled,
lastScrapedAt = lastScrapedAt
)
}
companion object {
fun fromDomain(domain: SiteConfig): SiteConfigEntity {
return SiteConfigEntity(
siteBoardKey = domain.siteBoardKey,
siteName = domain.siteName,
boardName = domain.boardName,
displayName = domain.displayName,
isEnabled = domain.isEnabled,
lastScrapedAt = domain.lastScrapedAt
)
}
}
}

View File

@@ -0,0 +1,47 @@
package com.hotdeal.alarm.data.local.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
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
class ThemePreferences(private val context: Context) {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "theme_preferences")
companion object {
private val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
private val DYNAMIC_COLORS_KEY = booleanPreferencesKey("dynamic_colors")
const val THEME_LIGHT = "light"
const val THEME_DARK = "dark"
const val THEME_SYSTEM = "system"
}
val themeMode: Flow<String> = context.dataStore.data
.map { preferences ->
preferences[THEME_MODE_KEY] ?: THEME_SYSTEM
}
val dynamicColors: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[DYNAMIC_COLORS_KEY] ?: true
}
suspend fun setThemeMode(mode: String) {
context.dataStore.edit { preferences ->
preferences[THEME_MODE_KEY] = mode
}
}
suspend fun setDynamicColors(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[DYNAMIC_COLORS_KEY] = enabled
}
}
}

View File

@@ -0,0 +1,64 @@
package com.hotdeal.alarm.data.remote.cloudflare
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Cloudflare 우회 쿠키 관리자
*/
class BypassCookieManager(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
"cloudflare_cookies",
Context.MODE_PRIVATE
)
/**
* 쿠키 저장
*/
suspend fun saveCookies(domain: String, cookies: Map<String, String>) = withContext(Dispatchers.IO) {
prefs.edit().apply {
cookies.forEach { (key, value) ->
putString("${domain}_$key", value)
}
apply()
}
}
/**
* 쿠키 로드
*/
suspend fun loadCookies(domain: String): Map<String, String> = withContext(Dispatchers.IO) {
val cookies = mutableMapOf<String, String>()
prefs.all.forEach { (key, value) ->
if (key.startsWith("${domain}_")) {
val cookieKey = key.removePrefix("${domain}_")
cookies[cookieKey] = value as String
}
}
cookies
}
/**
* 쿠키 삭제
*/
suspend fun clearCookies(domain: String) = withContext(Dispatchers.IO) {
prefs.edit().apply {
prefs.all.keys.forEach { key ->
if (key.startsWith("${domain}_")) {
remove(key)
}
}
apply()
}
}
/**
* 쿠키를 헤더 문자열로 변환
*/
fun toCookieHeader(cookies: Map<String, String>): String {
return cookies.entries.joinToString("; ") { "${it.key}=${it.value}" }
}
}

View File

@@ -0,0 +1,83 @@
package com.hotdeal.alarm.data.remote.cloudflare
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Cloudflare 우회 헬퍼
*/
class CloudflareBypass(private val context: Context) {
data class BypassResult(
val html: String,
val cookies: Map<String, String>
)
/**
* Cloudflare 챌린지 우회
*/
suspend fun bypass(url: String): BypassResult = suspendCancellableCoroutine { continuation ->
val webView = WebView(context)
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.loadWithOverviewMode = true
var challengeCompleted = false
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Cloudflare 챌린지 완료 확인
view?.evaluateJavascript(
"(function() { return document.body !== null && !document.body.innerHTML.includes('cloudflare'); })();"
) { result ->
if (result == "true" && !challengeCompleted) {
challengeCompleted = true
// HTML 추출
view?.evaluateJavascript(
"(function() { return document.documentElement.outerHTML; })();"
) { html ->
// 쿠키 추출
val cookieManager = android.webkit.CookieManager.getInstance()
val cookies = cookieManager.getCookie(url)
val cookieMap = parseCookies(cookies)
continuation.resume(
BypassResult(
html = html.trim('"').replace("\\u003C", "<").replace("\\u003E", ">"),
cookies = cookieMap
)
)
}
}
}
}
}
webView.loadUrl(url)
continuation.invokeOnCancellation {
webView.stopLoading()
webView.destroy()
}
}
private fun parseCookies(cookieString: String?): Map<String, String> {
if (cookieString.isNullOrEmpty()) return emptyMap()
return cookieString.split(";")
.map { it.trim() }
.filter { it.contains("=") }
.associate {
val parts = it.split("=", limit = 2)
parts[0] to parts.getOrElse(1) { "" }
}
}
}

View File

@@ -0,0 +1,31 @@
package com.hotdeal.alarm.data.remote.interceptor
import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.atomic.AtomicLong
/**
* Rate Limiting 인터셉터
*
* 동시성 안전을 위해 AtomicLong 사용
*/
class RateLimitInterceptor(
private val minIntervalMillis: Long = 3000L
) : Interceptor {
private val lastRequestTime = AtomicLong(0L)
override fun intercept(chain: Interceptor.Chain): Response {
val currentTime = System.currentTimeMillis()
val lastTime = lastRequestTime.get()
val timeSinceLastRequest = currentTime - lastTime
if (timeSinceLastRequest < minIntervalMillis) {
val sleepTime = minIntervalMillis - timeSinceLastRequest
Thread.sleep(sleepTime)
}
lastRequestTime.set(System.currentTimeMillis())
return chain.proceed(chain.request())
}
}

View File

@@ -0,0 +1,48 @@
package com.hotdeal.alarm.data.remote.interceptor
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
/**
* 재시도 인터셉터 (지수 백오프)
*/
class RetryInterceptor(
private val maxRetries: Int = 3,
private val initialDelayMs: Long = 1000L
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var lastException: IOException? = null
var response: Response? = null
for (attempt in 0..maxRetries) {
try {
response?.close()
response = chain.proceed(request)
if (response.isSuccessful) {
return response
}
// 서버 에러 또는 rate limiting인 경우 재시도
if (response.code in listOf(429, 500, 502, 503, 504)) {
response.close()
val delay = initialDelayMs * (1 shl attempt) // 지수 백오프
Thread.sleep(delay)
continue
}
return response
} catch (e: IOException) {
lastException = e
response?.close()
val delay = initialDelayMs * (1 shl attempt)
Thread.sleep(delay)
}
}
throw lastException ?: IOException("Max retries exceeded")
}
}

View File

@@ -0,0 +1,39 @@
package com.hotdeal.alarm.data.remote.interceptor
import okhttp3.Interceptor
import okhttp3.Response
import kotlin.random.Random
/**
* User-Agent 회전 인터셉터
*/
class UserAgentInterceptor : Interceptor {
private val userAgents = listOf(
// Android Chrome
"Mozilla/5.0 (Linux; Android 14; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
// Desktop Chrome (일부 사이트는 데스크톱 선호)
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("User-Agent", userAgents.random())
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Accept-Encoding", "gzip, deflate, br")
.header("Connection", "keep-alive")
.header("Upgrade-Insecure-Requests", "1")
.header("Sec-Fetch-Dest", "document")
.header("Sec-Fetch-Mode", "navigate")
.header("Sec-Fetch-Site", "none")
.header("Sec-Fetch-User", "?1")
.header("Cache-Control", "max-age=0")
.build()
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,103 @@
package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
/**
* 스크래퍼 기본 클래스
*/
abstract class BaseScraper(
protected val client: OkHttpClient
) {
/**
* 사이트 이름
*/
abstract val siteName: String
/**
* 기본 URL
*/
abstract val baseUrl: String
/**
* 게시판 URL 생성
*/
abstract fun getBoardUrl(board: String): String
/**
* 스크래핑 수행
*/
abstract suspend fun scrape(board: String): Result<List<HotDeal>>
/**
* HTML 인코딩 (기본값: UTF-8)
*/
protected open val charset: String = "UTF-8"
/**
* HTML 가져오기
*/
protected suspend fun fetchHtml(url: String): String? = withContext(Dispatchers.IO) {
try {
Log.d("Scraper", "[$siteName] 요청: $url")
val request = Request.Builder()
.url(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", baseUrl)
.header("Connection", "keep-alive")
.header("Upgrade-Insecure-Requests", "1")
.build()
client.newCall(request).execute().use { response ->
Log.d("Scraper", "[$siteName] 응답 코드: ${response.code}")
if (!response.isSuccessful) {
Log.e("Scraper", "[$siteName] 요청 실패: ${response.code}")
return@withContext null
}
// 문자열로 직접 읽기 (OkHttp가 자동으로 인코딩 처리)
val html = response.body?.string()
if (html == null) {
Log.e("Scraper", "[$siteName] 응답 바디가 null입니다")
return@withContext null
}
Log.d("Scraper", "[$siteName] HTML 길이: ${html.length}")
// 디버깅: HTML 일부 출력
if (html.length > 0) {
Log.d("Scraper", "[$siteName] HTML 샘플: ${html.take(300)}")
}
html
}
} catch (e: Exception) {
Log.e("Scraper", "[$siteName] 요청 예외: ${e.message}")
null
}
}
/**
* HTML 파싱
*/
protected fun parseHtml(html: String, baseUrl: String): org.jsoup.nodes.Document {
return Jsoup.parse(html, baseUrl)
}
/**
* 상대 URL을 절대 URL로 변환
*/
protected fun resolveUrl(baseUrl: String, relativeUrl: String): String {
if (relativeUrl.startsWith("http")) return relativeUrl
if (relativeUrl.startsWith("//")) return "https:$relativeUrl"
if (relativeUrl.startsWith("/")) return baseUrl.trimEnd('/') + relativeUrl
return baseUrl.trimEnd('/') + "/" + relativeUrl
}
}

View File

@@ -0,0 +1,105 @@
package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Elements
import kotlin.random.Random
/**
* 클리앙 스크래퍼
*/
class ClienScraper(client: OkHttpClient) : BaseScraper(client) {
override val siteName: String = "clien"
override val baseUrl: String = "https://www.clien.net"
override fun getBoardUrl(board: String): String {
return when (board) {
"allsell" -> "$baseUrl/service/group/allsell"
else -> "$baseUrl/service/board/$board"
}
}
override suspend fun scrape(board: String): Result<List<HotDeal>> = withContext(Dispatchers.IO) {
try {
val url = getBoardUrl(board)
Log.d("Clien", "스크래핑 시작: $url")
// 요청 간격 랜덤화 (2~4초)
val delayTime = Random.nextLong(2000, 4000)
Log.d("Clien", "요청 대기: ${delayTime}ms")
delay(delayTime)
val userAgent = getRandomUserAgent()
val doc: Document = Jsoup.connect(url)
.userAgent(userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", "https://www.clien.net/")
.timeout(30000)
.followRedirects(true)
.get()
Log.d("Clien", "문서 파싱 성공, 길이: ${doc.html().length}")
val deals = mutableListOf<HotDeal>()
val elements: Elements = doc.select("div.list_item.symph_row")
Log.d("Clien", "찾은 요소: ${elements.size}")
var count = 0
elements.forEach { item ->
if (count >= 20) return@forEach
try {
val titleElement = item.selectFirst("a.list_subject, span.list_subject a")
val title = titleElement?.text()?.trim() ?: return@forEach
if (title.isEmpty()) return@forEach
val href = titleElement.attr("href")
val dealUrl = resolveUrl(baseUrl, href)
val postId = href.substringAfterLast("/").substringBefore("?").ifEmpty {
href.substringAfterLast("/").ifEmpty { return@forEach }
}
val deal = HotDeal(
id = HotDeal.generateId(siteName, postId),
siteName = siteName,
boardName = board,
title = title,
url = dealUrl,
createdAt = System.currentTimeMillis()
)
deals.add(deal)
count++
Log.d("Clien", "[$count] $title")
} catch (e: Exception) {
Log.e("Clien", "파싱 에러: ${e.message}")
}
}
Log.d("Clien", "파싱 완료: ${deals.size}")
Result.success(deals)
} catch (e: Exception) {
Log.e("Clien", "스크래핑 실패: ${e.message}", e)
Result.failure(e)
}
}
private fun getRandomUserAgent(): String {
val userAgents = listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0"
)
return userAgents.random()
}
}

View File

@@ -0,0 +1,112 @@
package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Elements
import kotlin.random.Random
/**
* 쿨엔조이 스크래퍼
*/
class CoolenjoyScraper(client: OkHttpClient) : BaseScraper(client) {
override val siteName: String = "coolenjoy"
override val baseUrl: String = "https://coolenjoy.net"
override fun getBoardUrl(board: String): String {
return "$baseUrl/bbs/$board"
}
override suspend fun scrape(board: String): Result<List<HotDeal>> = withContext(Dispatchers.IO) {
try {
val url = getBoardUrl(board)
Log.d("Coolenjoy", "스크래핑 시작: $url")
// 요청 간격 랜덤화 (2~4초)
val delayTime = Random.nextLong(2000, 4000)
Log.d("Coolenjoy", "요청 대기: ${delayTime}ms")
delay(delayTime)
val userAgent = getRandomUserAgent()
val doc: Document = Jsoup.connect(url)
.userAgent(userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", "https://coolenjoy.net/")
.timeout(30000)
.followRedirects(true)
.get()
Log.d("Coolenjoy", "문서 파싱 성공, 길이: ${doc.html().length}")
val deals = mutableListOf<HotDeal>()
val elements: Elements = doc.select("a.na-subject")
Log.d("Coolenjoy", "찾은 요소: ${elements.size}")
var count = 0
elements.forEach { element ->
if (count >= 20) return@forEach
try {
val title = element.text().trim()
if (title.isEmpty()) return@forEach
val href = element.attr("href")
val dealUrl = resolveUrl(baseUrl, href)
val postId = extractPostId(href)
if (postId.isEmpty()) return@forEach
val deal = HotDeal(
id = HotDeal.generateId(siteName, postId),
siteName = siteName,
boardName = board,
title = title,
url = dealUrl,
createdAt = System.currentTimeMillis()
)
deals.add(deal)
count++
Log.d("Coolenjoy", "[$count] $title")
} catch (e: Exception) {
Log.e("Coolenjoy", "파싱 에러: ${e.message}")
}
}
Log.d("Coolenjoy", "파싱 완료: ${deals.size}")
Result.success(deals)
} catch (e: Exception) {
Log.e("Coolenjoy", "스크래핑 실패: ${e.message}", e)
Result.failure(e)
}
}
private fun extractPostId(href: String): String {
val fromPath = href.substringAfterLast("/").substringBefore("?")
if (fromPath.isNotEmpty() && fromPath.all { it.isDigit() }) {
return fromPath
}
val fromWrId = href.substringAfter("wr_id=").substringBefore("&")
if (fromWrId.isNotEmpty()) {
return fromWrId
}
return ""
}
private fun getRandomUserAgent(): String {
val userAgents = listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0"
)
return userAgents.random()
}
}

View File

@@ -0,0 +1,122 @@
package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Elements
import kotlin.random.Random
/**
* 뽐뿌 스크래퍼
*/
class PpomppuScraper(client: OkHttpClient) : BaseScraper(client) {
override val siteName: String = "ppomppu"
override val baseUrl: String = "https://www.ppomppu.co.kr/zboard/"
override fun getBoardUrl(board: String): String {
return "${baseUrl}zboard.php?id=$board"
}
override suspend fun scrape(board: String): Result<List<HotDeal>> = withContext(Dispatchers.IO) {
try {
val url = getBoardUrl(board)
Log.d("Ppomppu", "스크래핑 시작: $url")
// 요청 간격 랜덤화 (2~4초) - 차단 방지
val delayTime = Random.nextLong(2000, 4000)
Log.d("Ppomppu", "요청 대기: ${delayTime}ms")
delay(delayTime)
// Jsoup으로 직접 연결 (User-Agent 회전)
val userAgent = getRandomUserAgent()
Log.d("Ppomppu", "User-Agent: $userAgent")
val doc: Document = Jsoup.connect(url)
.userAgent(userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Accept-Charset", "utf-8,ISO-8859-1")
.header("Referer", "https://www.ppomppu.co.kr/")
.timeout(30000)
.followRedirects(true)
.get()
Log.d("Ppomppu", "문서 파싱 성공, 길이: ${doc.html().length}")
val deals = mutableListOf<HotDeal>()
// 셀렉터로 요소 찾기
val elements: Elements = doc.select("a.baseList-title")
Log.d("Ppomppu", "찾은 요소: ${elements.size}")
// 최대 20개까지만 처리
var count = 0
elements.forEach { element ->
if (count >= 20) return@forEach
try {
val title = element.text().trim()
if (title.isEmpty()) return@forEach
val href = element.attr("href")
// 공지사항 제외
if (href.contains("regulation") || href.contains("notice")) return@forEach
val dealUrl = resolveUrl(baseUrl, href)
// postId 추출
val postId = extractPostId(href)
if (postId.isEmpty()) {
Log.w("Ppomppu", "postId 추출 실패: href=$href")
return@forEach
}
val deal = HotDeal(
id = HotDeal.generateId(siteName, postId),
siteName = siteName,
boardName = board,
title = title,
url = dealUrl,
createdAt = System.currentTimeMillis()
)
deals.add(deal)
count++
Log.d("Ppomppu", "[$count] $title")
} catch (e: Exception) {
Log.e("Ppomppu", "파싱 에러: ${e.message}")
}
}
Log.d("Ppomppu", "파싱 완료: ${deals.size}")
Result.success(deals)
} catch (e: Exception) {
Log.e("Ppomppu", "스크래핑 실패: ${e.message}", e)
Result.failure(e)
}
}
private fun extractPostId(href: String): String {
val afterNo = href.substringAfter("no=", "")
if (afterNo.isEmpty()) return ""
val postId = afterNo.takeWhile { it != '&' && it != '/' && it != '#' }
return if (postId.isNotEmpty() && postId.all { it.isDigit() }) postId else ""
}
private fun getRandomUserAgent(): String {
val userAgents = listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0"
)
return userAgents.random()
}
}

View File

@@ -0,0 +1,113 @@
package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Elements
import kotlin.random.Random
/**
* 루리웹 스크래퍼
*/
class RuriwebScraper(client: OkHttpClient) : BaseScraper(client) {
override val siteName: String = "ruriweb"
override val baseUrl: String = "https://bbs.ruliweb.com"
override fun getBoardUrl(board: String): String {
return "$baseUrl/market/board/$board"
}
override suspend fun scrape(board: String): Result<List<HotDeal>> = withContext(Dispatchers.IO) {
try {
val url = getBoardUrl(board)
Log.d("Ruriweb", "스크래핑 시작: $url")
// 요청 간격 랜덤화 (2~4초)
val delayTime = Random.nextLong(2000, 4000)
Log.d("Ruriweb", "요청 대기: ${delayTime}ms")
delay(delayTime)
val userAgent = getRandomUserAgent()
val doc: Document = Jsoup.connect(url)
.userAgent(userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", "https://bbs.ruliweb.com/")
.timeout(30000)
.followRedirects(true)
.get()
Log.d("Ruriweb", "문서 파싱 성공, 길이: ${doc.html().length}")
val deals = mutableListOf<HotDeal>()
val elements: Elements = doc.select("td.subject a.subject_link.deco")
Log.d("Ruriweb", "찾은 요소: ${elements.size}")
var count = 0
elements.forEach { element ->
if (count >= 20) return@forEach
try {
val strongEl = element.selectFirst("strong")
val title = (strongEl?.text() ?: element.text()).trim()
if (title.isEmpty()) return@forEach
val href = element.attr("href")
val dealUrl = resolveUrl(baseUrl, href)
val postId = extractPostId(href)
if (postId.isEmpty()) return@forEach
val deal = HotDeal(
id = HotDeal.generateId(siteName, postId),
siteName = siteName,
boardName = board,
title = title,
url = dealUrl,
createdAt = System.currentTimeMillis()
)
deals.add(deal)
count++
Log.d("Ruriweb", "[$count] $title")
} catch (e: Exception) {
Log.e("Ruriweb", "파싱 에러: ${e.message}")
}
}
Log.d("Ruriweb", "파싱 완료: ${deals.size}")
Result.success(deals)
} catch (e: Exception) {
Log.e("Ruriweb", "스크래핑 실패: ${e.message}", e)
Result.failure(e)
}
}
private fun extractPostId(href: String): String {
val fromRead = href.substringAfter("/read/").substringBefore("?")
if (fromRead.isNotEmpty() && fromRead.all { it.isDigit() }) {
return fromRead
}
val fromPath = href.substringAfterLast("/").substringBefore("?")
if (fromPath.isNotEmpty() && fromPath.all { it.isDigit() }) {
return fromPath
}
return ""
}
private fun getRandomUserAgent(): String {
val userAgents = listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0"
)
return userAgents.random()
}
}

View File

@@ -0,0 +1,45 @@
package com.hotdeal.alarm.data.remote.scraper
import com.hotdeal.alarm.domain.model.SiteType
/**
* 스크래퍼 팩토리
*/
class ScraperFactory(
private val ppomppu: PpomppuScraper,
private val clien: ClienScraper,
private val ruriweb: RuriwebScraper,
private val coolenjoy: CoolenjoyScraper
) {
/**
* 사이트 타입에 따른 스크래퍼 반환
*/
fun getScraper(siteType: SiteType): BaseScraper {
return when (siteType) {
SiteType.PPOMPPU -> ppomppu
SiteType.CLIEN -> clien
SiteType.RURIWEB -> ruriweb
SiteType.COOLENJOY -> coolenjoy
}
}
/**
* 사이트 이름으로 스크래퍼 반환
*/
fun getScraper(siteName: String): BaseScraper? {
return when (siteName.lowercase()) {
"ppomppu" -> ppomppu
"clien" -> clien
"ruriweb" -> ruriweb
"coolenjoy" -> coolenjoy
else -> null
}
}
/**
* 모든 스크래퍼 반환
*/
fun getAllScrapers(): List<BaseScraper> {
return listOf(ppomppu, clien, ruriweb, coolenjoy)
}
}

View File

@@ -0,0 +1,55 @@
package com.hotdeal.alarm.di
import android.content.Context
import androidx.room.Room
import com.hotdeal.alarm.data.local.db.AppDatabase
import com.hotdeal.alarm.data.local.db.dao.HotDealDao
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
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
/**
* Database Module - Hilt DI
*/
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
AppDatabase.DATABASE_NAME
)
.addMigrations(
AppDatabase.MIGRATION_1_2,
AppDatabase.MIGRATION_2_3
)
.fallbackToDestructiveMigration()
.build()
}
@Provides
fun provideHotDealDao(database: AppDatabase): HotDealDao {
return database.hotDealDao()
}
@Provides
fun provideSiteConfigDao(database: AppDatabase): SiteConfigDao {
return database.siteConfigDao()
}
@Provides
fun provideKeywordDao(database: AppDatabase): KeywordDao {
return database.keywordDao()
}
}

View File

@@ -0,0 +1,80 @@
package com.hotdeal.alarm.di
import com.hotdeal.alarm.data.remote.interceptor.RateLimitInterceptor
import com.hotdeal.alarm.data.remote.interceptor.RetryInterceptor
import com.hotdeal.alarm.data.remote.interceptor.UserAgentInterceptor
import com.hotdeal.alarm.data.remote.scraper.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
* Network Module - Hilt DI
*/
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor)
.addInterceptor(UserAgentInterceptor())
.addInterceptor(RetryInterceptor(maxRetries = 3))
.addInterceptor(RateLimitInterceptor(minIntervalMillis = 3000L))
.build()
}
@Provides
@Singleton
fun providePpomppuScraper(client: OkHttpClient): PpomppuScraper {
return PpomppuScraper(client)
}
@Provides
@Singleton
fun provideClienScraper(client: OkHttpClient): ClienScraper {
return ClienScraper(client)
}
@Provides
@Singleton
fun provideRuriwebScraper(client: OkHttpClient): RuriwebScraper {
return RuriwebScraper(client)
}
@Provides
@Singleton
fun provideCoolenjoyScraper(client: OkHttpClient): CoolenjoyScraper {
return CoolenjoyScraper(client)
}
@Provides
@Singleton
fun provideScraperFactory(
ppomppu: PpomppuScraper,
clien: ClienScraper,
ruriweb: RuriwebScraper,
coolenjoy: CoolenjoyScraper
): ScraperFactory {
return ScraperFactory(
ppomppu = ppomppu,
clien = clien,
ruriweb = ruriweb,
coolenjoy = coolenjoy
)
}
}

View File

@@ -0,0 +1,46 @@
package com.hotdeal.alarm.domain.model
/**
* 핫딜 도메인 모델
*/
data class HotDeal(
val id: String, // siteName + "_" + postId
val siteName: String, // ppomppu, clien, ruriweb, coolenjoy, quasarzone
val boardName: String, // ppomppu, ppomppu4, allsell, jirum, etc.
val title: String, // 게시글 제목
val url: String, // 게시글 URL
val mallUrl: String? = null, // 쇼핑몰 URL (추출된 경우)
val createdAt: Long, // 수집 시간 (timestamp)
val isNotified: Boolean = false,
val isKeywordMatch: Boolean = false
) {
/**
* 사이트 타입 반환
*/
val siteType: SiteType?
get() = SiteType.fromName(siteName)
/**
* 게시판 표시 이름 반환 (ppomppu8 → 알리뽐뿌)
*/
val boardDisplayName: String
get() {
val site = siteType ?: return boardName
return site.boards.find { it.id == boardName }?.displayName ?: boardName
}
/**
* 사이트 표시 이름 + 게시판 표시 이름 (예: 뽐뿌 - 알리뽐뿌)
*/
val fullDisplayName: String
get() = "${siteType?.displayName ?: siteName} - $boardDisplayName"
/**
* 고유 ID 생성
*/
companion object {
fun generateId(siteName: String, postId: String): String {
return "${siteName}_$postId"
}
}
}

View File

@@ -0,0 +1,31 @@
package com.hotdeal.alarm.domain.model
/**
* 키워드 도메인 모델
*/
data class Keyword(
val id: Long = 0,
val keyword: String,
val isEnabled: Boolean = true,
val matchMode: MatchMode = MatchMode.CONTAINS,
val createdAt: Long = System.currentTimeMillis()
) {
/**
* 제목이 키워드와 매칭되는지 확인
*/
fun matches(title: String): Boolean {
if (!isEnabled || keyword.isBlank()) return false
return when (matchMode) {
MatchMode.CONTAINS -> title.contains(keyword, ignoreCase = true)
MatchMode.EXACT -> title.equals(keyword, ignoreCase = true)
MatchMode.REGEX -> {
try {
Regex(keyword, RegexOption.IGNORE_CASE).containsMatchIn(title)
} catch (e: Exception) {
false // 잘못된 정규식
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
package com.hotdeal.alarm.domain.model
/**
* 사이트 설정 도메인 모델
*/
data class SiteConfig(
val siteBoardKey: String, // "ppomppu_ppomppu", "clien_allsell", etc.
val siteName: String,
val boardName: String,
val displayName: String, // "뽐뿌게시판", "사고팔고", etc.
val isEnabled: Boolean = false,
val lastScrapedAt: Long? = null
) {
companion object {
fun generateKey(siteName: String, boardName: String): String {
return "${siteName}_$boardName"
}
}
}

View File

@@ -0,0 +1,62 @@
package com.hotdeal.alarm.domain.model
/**
* 사이트 타입 열거형
*/
enum class SiteType(
val displayName: String,
val boards: List<BoardInfo>
) {
PPOMPPU(
displayName = "뽐뿌",
boards = listOf(
BoardInfo("ppomppu", "뽐뿌게시판"),
BoardInfo("ppomppu4", "해외뽐뿌"),
BoardInfo("ppomppu8", "알리뽐뿌"),
BoardInfo("money", "재태크포럼")
)
),
CLIEN(
displayName = "클리앙",
boards = listOf(
BoardInfo("allsell", "사고팔고"),
BoardInfo("jirum", "알뜰구매")
)
),
RURIWEB(
displayName = "루리웹",
boards = listOf(
BoardInfo("1020", "핫딜/예판 유저"),
BoardInfo("600004", "핫딜/예판 업체")
)
),
COOLENJOY(
displayName = "쿨엔조이",
boards = listOf(
BoardInfo("jirum", "알뜰구매")
)
);
companion object {
fun fromName(name: String): SiteType? {
return entries.find { it.name.equals(name, ignoreCase = true) }
}
}
}
/**
* 게시판 정보
*/
data class BoardInfo(
val id: String,
val displayName: String
)
/**
* 키워드 매칭 모드
*/
enum class MatchMode {
CONTAINS, // 포함
EXACT, // 정확히 일치
REGEX // 정규식
}

View File

@@ -0,0 +1,138 @@
package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.ui.theme.Spacing
/**
* 애니메이션이 있는 아이콘 버튼
*/
@Composable
fun AnimatedIconButton(
onClick: () -> Unit,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
isActivated: Boolean = false
) {
val scale by animateFloatAsState(
targetValue = if (isActivated) 1.2f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "icon_scale"
)
IconButton(
onClick = onClick,
modifier = modifier.scale(scale)
) {
icon()
}
}
/**
* 페이드 인 애니메이션 박스
*/
@Composable
fun FadeInBox(
modifier: Modifier = Modifier,
delayMillis: Int = 0,
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(delayMillis.toLong())
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(
animationSpec = tween(300)
) + slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 }
),
modifier = modifier
) {
content()
}
}
/**
* 스케일 애니메이션 카드
*/
@Composable
fun ScaleAnimationCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "card_scale"
)
Card(
modifier = modifier
.scale(scale),
onClick = {
isPressed = true
onClick()
}
) {
content()
}
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(100)
isPressed = false
}
}
}
/**
* 로딩 오버레이
*/
@Composable
fun LoadingOverlay(
isLoading: Boolean,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(modifier = modifier) {
content()
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(Spacing.lg),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}

View File

@@ -0,0 +1,173 @@
package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.ui.theme.Spacing
import com.hotdeal.alarm.ui.theme.getSiteColor
import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DealItem(
deal: HotDeal,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isFavorite by remember { mutableStateOf(false) }
val context = LocalContext.current
val favoriteScale by animateFloatAsState(
targetValue = if (isFavorite) 1.3f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "favorite_scale"
)
val siteColor = getSiteColor(deal.siteType)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.md)
) {
// 상단: 사이트 뱃지 + 게시판 + 액션
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 사이트 뱃지 (색상으로 구분)
Surface(
shape = RoundedCornerShape(12.dp),
color = siteColor.copy(alpha = 0.15f),
modifier = Modifier.height(26.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
) {
// 색상 점
Box(
modifier = Modifier
.size(8.dp)
.background(siteColor, CircleShape)
)
Text(
text = deal.siteType?.displayName ?: deal.siteName,
style = MaterialTheme.typography.labelSmall,
color = siteColor
)
}
}
Spacer(modifier = Modifier.width(Spacing.sm))
// 게시판 이름 (ppomppu8 → 알리뽐뿌)
Text(
text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (deal.isKeywordMatch) {
Spacer(modifier = Modifier.width(Spacing.xs))
Icon(
imageVector = Icons.Default.Star,
contentDescription = "키워드 매칭",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
// 공유 버튼
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
// 즐겨찾기 버튼
IconButton(
onClick = { isFavorite = !isFavorite },
modifier = Modifier.size(32.dp).scale(favoriteScale)
) {
Icon(
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = if (isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (isFavorite)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
Spacer(modifier = Modifier.height(Spacing.sm))
// 제목
Text(
text = deal.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(Spacing.sm))
// 시간
Text(
text = formatTime(deal.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
private fun formatTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp
return when {
diff < 60_000 -> "방금 전"
diff < 3_600_000 -> "${diff / 60_000}분 전"
diff < 86_400_000 -> "${diff / 3_600_000}시간 전"
else -> "${diff / 86_400_000}일 전"
}
}

View File

@@ -0,0 +1,129 @@
package com.hotdeal.alarm.presentation.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.ui.theme.Spacing
/**
* 빈 상태 컴포넌트
*/
@Composable
fun EmptyState(
title: String,
message: String,
icon: ImageVector,
modifier: Modifier = Modifier,
action: @Composable (() -> Unit)? = null
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(Spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.outlineVariant
)
Spacer(modifier = Modifier.height(Spacing.md))
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Spacing.sm))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (action != null) {
Spacer(modifier = Modifier.height(Spacing.lg))
action()
}
}
}
/**
* 핫딜 없음 상태
*/
@Composable
fun NoDealsState(
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
EmptyState(
title = "수집된 핫딜이 없습니다",
message = "새로고침하여 최신 핫딜을 확인해보세요",
icon = Icons.Default.ShoppingCart,
modifier = modifier,
action = {
FilledTonalButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(Spacing.xs))
Text("새로고침")
}
}
)
}
/**
* 검색 결과 없음 상태
*/
@Composable
fun NoSearchResultState(
query: String,
modifier: Modifier = Modifier
) {
EmptyState(
title = "검색 결과가 없습니다",
message = "'$query'에 대한 결과를 찾을 수 없습니다",
icon = Icons.Default.Search,
modifier = modifier
)
}
/**
* 에러 상태
*/
@Composable
fun ErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
EmptyState(
title = "오류가 발생했습니다",
message = message,
icon = Icons.Default.ShoppingCart,
modifier = modifier,
action = {
FilledTonalButton(onClick = onRetry) {
Text("다시 시도")
}
}
)
}

View File

@@ -0,0 +1,31 @@
package com.hotdeal.alarm.presentation.components
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
@Composable
fun PermissionDialog(
title: String,
message: String,
onDismiss: () -> Unit,
onOpenSettings: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text("설정 열기")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("취소")
}
}
)
}
}

View File

@@ -0,0 +1,137 @@
package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.ui.theme.CornerRadius
import com.hotdeal.alarm.ui.theme.Spacing
/**
* Shimmer 로딩 스켈레톤
*/
@Composable
fun ShimmerEffect(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnim = transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1200,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
),
label = "shimmer_translate"
)
val shimmerColor = Color(0xFFE0E0E0)
val shimmerBackgroundColor = Color(0xFFF5F5F5)
val brush = Brush.linearGradient(
colors = listOf(
shimmerBackgroundColor,
shimmerColor,
shimmerBackgroundColor
),
start = Offset(translateAnim.value - 1000f, translateAnim.value - 1000f),
end = Offset(translateAnim.value, translateAnim.value)
)
Box(modifier = modifier) {
content()
Box(
modifier = Modifier
.matchParentSize()
.background(brush)
)
}
}
/**
* 핫딜 아이템 스켈레톤
*/
@Composable
fun DealItemSkeleton(
modifier: Modifier = Modifier
) {
ShimmerEffect {
Column(
modifier = modifier
.fillMaxWidth()
.padding(Spacing.md)
) {
// 사이트 칩 스켈레톤
Box(
modifier = Modifier
.width(80.dp)
.height(24.dp)
.clip(RoundedCornerShape(CornerRadius.small))
.background(Color.Gray)
)
Spacer(modifier = Modifier.height(Spacing.sm))
// 제목 스켈레톤
Box(
modifier = Modifier
.fillMaxWidth(0.8f)
.height(16.dp)
.clip(RoundedCornerShape(CornerRadius.small))
.background(Color.Gray)
)
Spacer(modifier = Modifier.height(Spacing.xs))
Box(
modifier = Modifier
.fillMaxWidth(0.6f)
.height(16.dp)
.clip(RoundedCornerShape(CornerRadius.small))
.background(Color.Gray)
)
Spacer(modifier = Modifier.height(Spacing.sm))
// 시간 스켈레톤
Box(
modifier = Modifier
.width(60.dp)
.height(12.dp)
.clip(RoundedCornerShape(CornerRadius.small))
.background(Color.Gray)
)
}
}
}
/**
* 리스트 스켈레톤
*/
@Composable
fun DealListSkeleton(
count: Int = 5
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(Spacing.md),
verticalArrangement = Arrangement.spacedBy(Spacing.sm)
) {
repeat(count) {
DealItemSkeleton()
}
}
}

View File

@@ -0,0 +1,227 @@
package com.hotdeal.alarm.presentation.deallist
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.presentation.components.*
import com.hotdeal.alarm.presentation.main.MainUiState
import com.hotdeal.alarm.presentation.main.MainViewModel
import com.hotdeal.alarm.ui.theme.Spacing
import com.hotdeal.alarm.ui.theme.getSiteColor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DealListScreen(viewModel: MainViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var searchText by remember { mutableStateOf("") }
var selectedSiteFilter by remember { mutableStateOf<SiteType?>(null) }
var showFilterMenu by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = {
Text(
text = "핫딜 목록",
style = MaterialTheme.typography.headlineSmall
)
},
actions = {
// 필터 버튼
IconButton(onClick = { showFilterMenu = !showFilterMenu }) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "필터"
)
}
// 새로고침 버튼
IconButton(onClick = { viewModel.refresh() }) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "새로고침"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
// 사이트 필터 칩들
AnimatedVisibility(
visible = showFilterMenu,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md, vertical = Spacing.sm)
) {
Text(
text = "사이트 필터",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = Spacing.sm)
)
// 전체 보기 칩 + 사이트별 필터 칩 (가로 스크롤)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(Spacing.xs)
) {
// 전체 보기 칩
FilterChip(
selected = selectedSiteFilter == null,
onClick = { selectedSiteFilter = null },
label = { Text("전체") }
)
// 사이트별 필터 칩
SiteType.entries.forEach { siteType ->
val color = getSiteColor(siteType)
FilterChip(
selected = selectedSiteFilter == siteType,
onClick = {
selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType
},
label = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(color, MaterialTheme.shapes.small)
)
Text(siteType.displayName)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = color.copy(alpha = 0.2f),
selectedLabelColor = color
)
)
}
}
}
}
// 검색창
OutlinedTextField(
value = searchText,
onValueChange = { searchText = it },
label = { Text("제목으로 검색") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md, vertical = Spacing.sm),
singleLine = true,
shape = MaterialTheme.shapes.medium
)
when (val state = uiState) {
is MainUiState.Loading -> {
DealListSkeleton(count = 5)
}
is MainUiState.Success -> {
// 필터링 적용
val filteredDeals = remember(state.deals, searchText, selectedSiteFilter) {
state.deals.filter { deal ->
// 검색어 필터
val matchesSearch = searchText.isBlank() ||
deal.title.contains(searchText, ignoreCase = true)
// 사이트 필터
val matchesSite = selectedSiteFilter == null ||
deal.siteType == selectedSiteFilter
matchesSearch && matchesSite
}
}
if (filteredDeals.isEmpty()) {
if (state.deals.isEmpty()) {
NoDealsState(onRefresh = { viewModel.refresh() })
} else {
val selectedFilter = selectedSiteFilter
val message = when {
selectedFilter != null -> "${selectedFilter.displayName}의 핫딜이 없습니다"
searchText.isNotBlank() -> "'$searchText'에 대한 검색 결과가 없습니다"
else -> "표시할 핫딜이 없습니다"
}
EmptyState(
title = "결과가 없습니다",
message = message,
icon = Icons.Default.Search
)
}
} else {
// 핫딜 개수 표시
Text(
text = "${filteredDeals.size}개의 핫딜",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs)
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(Spacing.md),
verticalArrangement = Arrangement.spacedBy(Spacing.sm)
) {
items(
items = filteredDeals,
key = { it.id }
) { deal ->
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
DealItem(
deal = deal,
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deal.url))
context.startActivity(intent)
}
)
}
}
}
}
}
is MainUiState.Error -> {
ErrorState(
message = state.message,
onRetry = { viewModel.refresh() }
)
}
}
}
}

View File

@@ -0,0 +1,41 @@
package com.hotdeal.alarm.presentation.main
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.hotdeal.alarm.ui.theme.HotDealTheme
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var workerScheduler: WorkerScheduler
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
workerScheduler.schedulePeriodicPolling(15)
setContent {
HotDealTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val viewModel: MainViewModel = hiltViewModel()
MainScreen(viewModel = viewModel)
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
package com.hotdeal.alarm.presentation.main
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.hotdeal.alarm.presentation.components.PermissionDialog
import com.hotdeal.alarm.presentation.deallist.DealListScreen
import com.hotdeal.alarm.presentation.settings.SettingsScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
navController: NavHostController = rememberNavController()
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPermissionDialog by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (!isGranted) {
showPermissionDialog = true
}
}
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
)
if (permission != PackageManager.PERMISSION_GRANTED) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
Scaffold(
bottomBar = {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
BottomNavItem.values().forEach { item ->
NavigationBarItem(
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }
)
}
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = Screen.DealList.route,
modifier = Modifier.padding(padding)
) {
composable(Screen.DealList.route) {
DealListScreen(viewModel = viewModel)
}
composable(Screen.Settings.route) {
SettingsScreen(viewModel = viewModel)
}
}
}
if (showPermissionDialog) {
PermissionDialog(
title = "알림 권한 필요",
message = "핫딜 알림을 받으려면 알림 권한이 필요합니다. 설정에서 권한을 허용해주세요.",
onDismiss = { showPermissionDialog = false },
onOpenSettings = { showPermissionDialog = false }
)
}
}
sealed class Screen(val route: String) {
object DealList : Screen("deal_list")
object Settings : Screen("settings")
}
enum class BottomNavItem(
val route: String,
val label: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector
) {
Deals("deal_list", "핫딜", Icons.Default.List),
Settings("settings", "설정", Icons.Default.Settings)
}

View File

@@ -0,0 +1,125 @@
package com.hotdeal.alarm.presentation.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hotdeal.alarm.data.local.db.dao.HotDealDao
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
private val hotDealDao: HotDealDao,
private val siteConfigDao: SiteConfigDao,
private val keywordDao: KeywordDao,
private val workerScheduler: WorkerScheduler
) : ViewModel() {
private val _uiState = MutableStateFlow<MainUiState>(MainUiState.Loading)
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
init {
initializeApp()
}
private fun initializeApp() {
viewModelScope.launch {
initializeDefaultSiteConfigs()
loadState()
}
}
private suspend fun initializeDefaultSiteConfigs() {
val existingConfigs = siteConfigDao.getAllConfigs()
if (existingConfigs.isEmpty()) {
val defaultConfigs = SiteType.entries.flatMap { site ->
site.boards.map { board ->
SiteConfigEntity(
siteBoardKey = "${site.name}_${board.id}",
siteName = site.name,
boardName = board.id,
displayName = "${site.displayName} - ${board.displayName}",
isEnabled = false
)
}
}
siteConfigDao.insertConfigs(defaultConfigs)
}
}
private suspend fun loadState() {
combine(
hotDealDao.observeAllDeals(),
siteConfigDao.observeAllConfigs(),
keywordDao.observeAllKeywords()
) { deals, configs, keywords ->
MainUiState.Success(
deals = deals.map { it.toDomain() },
siteConfigs = configs.map { it.toDomain() },
keywords = keywords.map { it.toDomain() }
)
}.catch { e ->
_uiState.value = MainUiState.Error(e.message ?: "Unknown error")
}.collect { state ->
_uiState.value = state
}
}
fun toggleSiteConfig(siteBoardKey: String, enabled: Boolean) {
viewModelScope.launch {
siteConfigDao.updateEnabled(siteBoardKey, enabled)
}
}
fun addKeyword(keyword: String) {
if (keyword.isBlank()) return
viewModelScope.launch {
keywordDao.insertKeyword(
com.hotdeal.alarm.data.local.db.entity.KeywordEntity(
keyword = keyword.trim(),
isEnabled = true
)
)
}
}
fun deleteKeyword(id: Long) {
viewModelScope.launch {
keywordDao.deleteKeywordById(id)
}
}
fun toggleKeyword(id: Long, enabled: Boolean) {
viewModelScope.launch {
keywordDao.updateEnabled(id, enabled)
}
}
fun refresh() {
workerScheduler.executeOnce()
}
fun startPolling(intervalMinutes: Long = 15) {
workerScheduler.schedulePeriodicPolling(intervalMinutes)
}
fun stopPolling() {
workerScheduler.cancelPolling()
}
}
sealed class MainUiState {
data object Loading : MainUiState()
data class Success(
val deals: List<com.hotdeal.alarm.domain.model.HotDeal>,
val siteConfigs: List<com.hotdeal.alarm.domain.model.SiteConfig>,
val keywords: List<com.hotdeal.alarm.domain.model.Keyword>
) : MainUiState()
data class Error(val message: String) : MainUiState()
}

View File

@@ -0,0 +1,436 @@
package com.hotdeal.alarm.presentation.settings
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.domain.model.Keyword
import com.hotdeal.alarm.domain.model.SiteConfig
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.presentation.components.PermissionDialog
import com.hotdeal.alarm.presentation.main.MainUiState
import com.hotdeal.alarm.presentation.main.MainViewModel
import com.hotdeal.alarm.util.PermissionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPermissionDialog by remember { mutableStateOf(false) }
var permissionDialogTitle by remember { mutableStateOf("") }
var permissionDialogMessage by remember { mutableStateOf("") }
var permissionDialogAction by remember { mutableStateOf({}) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (!isGranted) {
permissionDialogTitle = "알림 권한 필요"
permissionDialogMessage = "핫딜 알림을 받으려면 알림 권한이 필요합니다.\n설정에서 권한을 허용해주세요."
permissionDialogAction = {
PermissionHelper.openNotificationSettings(context)
}
showPermissionDialog = true
}
}
// 권한 상태 확인
val permissionStatus = PermissionHelper.checkAllPermissions(context)
val hasEnabledSites = (uiState as? MainUiState.Success)
?.siteConfigs?.any { it.isEnabled } ?: false
val hasKeywords = (uiState as? MainUiState.Success)
?.keywords?.isNotEmpty() ?: false
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 권한 상태 섹션
item {
PermissionStatusSection(
permissionStatus = permissionStatus,
hasEnabledSites = hasEnabledSites,
hasKeywords = hasKeywords,
onRequestNotificationPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
onRequestExactAlarmPermission = {
PermissionHelper.openExactAlarmSettings(context)
},
onOpenAppSettings = {
PermissionHelper.openAppSettings(context)
}
)
}
// 사이트 선택 섹션
item {
Text(
text = "사이트 선택",
style = MaterialTheme.typography.titleMedium
)
}
when (val state = uiState) {
is MainUiState.Success -> {
SiteType.entries.forEach { site ->
item {
SiteSection(
siteType = site,
configs = state.siteConfigs.filter { it.siteName == site.name },
onToggle = { key, enabled ->
viewModel.toggleSiteConfig(key, enabled)
}
)
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "키워드 설정",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "특정 키워드가 포함된 핫딜만 알림받으려면 키워드를 추가하세요",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
item {
var keywordText by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = keywordText,
onValueChange = { keywordText = it },
label = { Text("키워드 입력") },
modifier = Modifier.weight(1f),
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton(
onClick = {
if (keywordText.isNotBlank()) {
viewModel.addKeyword(keywordText)
keywordText = ""
}
}
) {
Icon(Icons.Default.Add, contentDescription = "추가")
}
}
}
items(state.keywords) { keyword ->
KeywordItem(
keyword = keyword,
onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) },
onDelete = { viewModel.deleteKeyword(keyword.id) }
)
}
if (state.keywords.isEmpty()) {
item {
Text(
text = "등록된 키워드가 없습니다",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
item {
CircularProgressIndicator()
}
}
}
}
if (showPermissionDialog) {
PermissionDialog(
title = permissionDialogTitle,
message = permissionDialogMessage,
onDismiss = { showPermissionDialog = false },
onOpenSettings = {
showPermissionDialog = false
permissionDialogAction()
}
)
}
}
@Composable
fun PermissionStatusSection(
permissionStatus: PermissionHelper.PermissionStatus,
hasEnabledSites: Boolean,
hasKeywords: Boolean,
onRequestNotificationPermission: () -> Unit,
onRequestExactAlarmPermission: () -> Unit,
onOpenAppSettings: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (permissionStatus.isAllGranted && hasEnabledSites) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
}
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "알림 설정 상태",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// 알림 권한 상태
PermissionItem(
icon = Icons.Default.Notifications,
title = "알림 권한",
isGranted = permissionStatus.hasNotificationPermission,
requiredVersion = "Android 13+",
onClick = onRequestNotificationPermission
)
// 정확한 알람 권한 상태
PermissionItem(
icon = Icons.Default.Alarm,
title = "정확한 알람 권한",
isGranted = permissionStatus.hasExactAlarmPermission,
requiredVersion = "Android 12+",
onClick = onRequestExactAlarmPermission
)
// 사이트 활성화 상태
PermissionItem(
icon = Icons.Default.Language,
title = "사이트 선택",
isGranted = hasEnabledSites,
description = if (!hasEnabledSites) "최소 1개 사이트 활성화 필요" else null,
onClick = null
)
// 키워드 상태 (선택사항)
if (!hasKeywords) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "키워드를 추가하면 해당 키워드가 포함된 핫딜만 알림받습니다",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 전체 상태 요약
if (!permissionStatus.isAllGranted || !hasEnabledSites) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small
)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "알림을 받으려면 모든 필수 권한을 허용하고 사이트를 선택하세요",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
} else {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "모든 설정이 완료되었습니다",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
@Composable
fun PermissionItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
isGranted: Boolean,
requiredVersion: String? = null,
description: String? = null,
onClick: (() -> Unit)?
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (isGranted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium
)
if (requiredVersion != null) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "($requiredVersion)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (onClick != null && !isGranted) {
FilledTonalButton(
onClick = onClick,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Text("설정", style = MaterialTheme.typography.labelMedium)
}
} else {
Icon(
imageVector = if (isGranted) Icons.Default.Check else Icons.Default.Close,
contentDescription = if (isGranted) "허용됨" else "거부됨",
tint = if (isGranted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp)
)
}
}
}
@Composable
fun SiteSection(
siteType: SiteType,
configs: List<SiteConfig>,
onToggle: (String, Boolean) -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = siteType.displayName,
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(8.dp))
configs.forEach { config ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = config.displayName.substringAfter(" - "),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(
checked = config.isEnabled,
onCheckedChange = { onToggle(config.siteBoardKey, it) }
)
}
}
}
}
}
@Composable
fun KeywordItem(
keyword: Keyword,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
ListItem(
headlineContent = { Text(keyword.keyword) },
trailingContent = {
Row {
Switch(
checked = keyword.isEnabled,
onCheckedChange = { onToggle() }
)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "삭제")
}
}
}
)
}

View File

@@ -0,0 +1,199 @@
package com.hotdeal.alarm.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.ForegroundInfo
import com.hotdeal.alarm.R
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.presentation.main.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* 알림 서비스
*/
@Singleton
class NotificationService @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
const val CHANNEL_URGENT = "hotdeal_urgent"
const val CHANNEL_NORMAL = "hotdeal_normal"
const val CHANNEL_BACKGROUND = "hotdeal_background"
const val NOTIFICATION_ID_FOREGROUND = 1000
const val NOTIFICATION_ID_NEW_DEAL = 1001
const val NOTIFICATION_ID_KEYWORD = 1002
}
private val notificationManager = NotificationManagerCompat.from(context)
init {
createNotificationChannels()
}
/**
* 알림 채널 생성
*/
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channels = listOf(
NotificationChannel(
CHANNEL_URGENT,
context.getString(R.string.notification_channel_urgent),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.notification_channel_urgent_desc)
},
NotificationChannel(
CHANNEL_NORMAL,
context.getString(R.string.notification_channel_normal),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = context.getString(R.string.notification_channel_normal_desc)
},
NotificationChannel(
CHANNEL_BACKGROUND,
context.getString(R.string.notification_channel_background),
NotificationManager.IMPORTANCE_LOW
).apply {
description = context.getString(R.string.notification_channel_background_desc)
}
)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannels(channels)
}
}
/**
* 포그라운드 서비스용 Notification 생성
*/
fun createForegroundInfo(): ForegroundInfo {
val notification = NotificationCompat.Builder(context, CHANNEL_BACKGROUND)
.setContentTitle("핫딜 모니터링 중")
.setContentText("새로운 핫딜을 확인하고 있습니다")
.setSmallIcon(android.R.drawable.ic_menu_search)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
return ForegroundInfo(NOTIFICATION_ID_FOREGROUND, notification)
}
/**
* 새 핫딜 알림
*/
fun showNewDealsNotification(deals: List<HotDeal>) {
if (!hasNotificationPermission()) return
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = if (deals.size == 1) {
NotificationCompat.Builder(context, CHANNEL_NORMAL)
.setContentTitle(context.getString(R.string.notification_new_deal))
.setContentText(deals.first().title)
.setSmallIcon(android.R.drawable.ic_menu_send)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
} else {
NotificationCompat.Builder(context, CHANNEL_NORMAL)
.setContentTitle(context.getString(R.string.notification_new_deal))
.setContentText("새로운 핫딜 ${deals.size}")
.setSmallIcon(android.R.drawable.ic_menu_send)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(deals.take(5).joinToString("\n") { "${it.title}" })
)
.build()
}
notificationManager.notify(NOTIFICATION_ID_NEW_DEAL, notification)
}
/**
* 키워드 매칭 알림 (긴급)
*/
fun showKeywordMatchNotification(deals: List<HotDeal>) {
if (!hasNotificationPermission()) return
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_URGENT)
.setContentTitle(context.getString(R.string.notification_keyword_match))
.setContentText(deals.first().title)
.setSmallIcon(android.R.drawable.ic_menu_send)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(deals.take(5).joinToString("\n") { "${it.title}" })
)
.build()
notificationManager.notify(NOTIFICATION_ID_KEYWORD, notification)
}
/**
* 알림 권한 확인
*/
private fun hasNotificationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
} else {
true
}
}
/**
* 알림 채널 활성화 여부 확인
*/
fun isNotificationChannelEnabled(channelId: String): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = notificationManager.getNotificationChannel(channelId)
return channel?.importance != NotificationManager.IMPORTANCE_NONE
}
return true
}
/**
* 모든 알림 채널이 활성화되어 있는지 확인
*/
fun areAllChannelsEnabled(): Boolean {
return isNotificationChannelEnabled(CHANNEL_URGENT) &&
isNotificationChannelEnabled(CHANNEL_NORMAL)
}
/**
* 알림 권한이 있는지 확인 (public)
*/
fun checkNotificationPermission(): Boolean {
return hasNotificationPermission()
}
}

View File

@@ -0,0 +1,34 @@
package com.hotdeal.alarm.ui.theme
import androidx.compose.ui.unit.dp
object Spacing {
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
val xxl = 48.dp
}
object CornerRadius {
val small = 8.dp
val medium = 12.dp
val large = 16.dp
val extraLarge = 24.dp
val full = 9999.dp
}
object Elevation {
val none = 0.dp
val small = 2.dp
val medium = 4.dp
val large = 8.dp
}
object IconSize {
val small = 16.dp
val medium = 24.dp
val large = 32.dp
val extraLarge = 48.dp
}

View File

@@ -0,0 +1,168 @@
package com.hotdeal.alarm.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.hotdeal.alarm.domain.model.SiteType
// Light Theme Colors
private val LightPrimary = Color(0xFF1976D2)
private val LightOnPrimary = Color(0xFFFFFFFF)
private val LightPrimaryContainer = Color(0xFFD1E4FF)
private val LightOnPrimaryContainer = Color(0xFF001D36)
private val LightSecondary = Color(0xFFFF9800)
private val LightOnSecondary = Color(0xFFFFFFFF)
private val LightSecondaryContainer = Color(0xFFFFE0B2)
private val LightOnSecondaryContainer = Color(0xFF261800)
private val LightTertiary = Color(0xFF536DFE)
private val LightOnTertiary = Color(0xFFFFFFFF)
private val LightBackground = Color(0xFFFAFAFA)
private val LightOnBackground = Color(0xFF1C1B1F)
private val LightSurface = Color(0xFFFFFFFF)
private val LightOnSurface = Color(0xFF1C1B1F)
private val LightError = Color(0xFFD32F2F)
private val LightOnError = Color(0xFFFFFFFF)
private val LightErrorContainer = Color(0xFFFFDAD6)
private val LightOnErrorContainer = Color(0xFF410002)
private val LightOutline = Color(0xFF79747E)
private val LightOutlineVariant = Color(0xFFCAC4D0)
// Dark Theme Colors
private val DarkPrimary = Color(0xFF90CAF9)
private val DarkOnPrimary = Color(0xFF003258)
private val DarkPrimaryContainer = Color(0xFF00497D)
private val DarkOnPrimaryContainer = Color(0xFFD1E4FF)
private val DarkSecondary = Color(0xFFFFB74D)
private val DarkOnSecondary = Color(0xFF442B00)
private val DarkSecondaryContainer = Color(0xFF624000)
private val DarkOnSecondaryContainer = Color(0xFFFFE0B2)
private val DarkTertiary = Color(0xFF8C9EFF)
private val DarkOnTertiary = Color(0xFF001A3F)
private val DarkBackground = Color(0xFF121212)
private val DarkOnBackground = Color(0xFFE0E0E0)
private val DarkSurface = Color(0xFF1E1E1E)
private val DarkOnSurface = Color(0xFFE0E0E0)
private val DarkError = Color(0xFFCF6679)
private val DarkOnError = Color(0xFF000000)
private val DarkErrorContainer = Color(0xFF93000A)
private val DarkOnErrorContainer = Color(0xFFFFDAD6)
private val DarkOutline = Color(0xFF938F99)
private val DarkOutlineVariant = Color(0xFF49454F)
// Site Colors
val PpomppuColor = Color(0xFFE91E63)
val ClienColor = Color(0xFF4CAF50)
val RuriwebColor = Color(0xFF2196F3)
val CoolenjoyColor = Color(0xFFFF5722)
/**
* 사이트별 색상 가져오기
*/
fun getSiteColor(siteType: SiteType?): Color {
return when (siteType) {
SiteType.PPOMPPU -> PpomppuColor
SiteType.CLIEN -> ClienColor
SiteType.RURIWEB -> RuriwebColor
SiteType.COOLENJOY -> CoolenjoyColor
null -> Color.Gray
}
}
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
secondaryContainer = LightSecondaryContainer,
onSecondaryContainer = LightOnSecondaryContainer,
tertiary = LightTertiary,
onTertiary = LightOnTertiary,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
error = LightError,
onError = LightOnError,
errorContainer = LightErrorContainer,
onErrorContainer = LightOnErrorContainer,
outline = LightOutline,
outlineVariant = LightOutlineVariant
)
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
onSecondaryContainer = DarkOnSecondaryContainer,
tertiary = DarkTertiary,
onTertiary = DarkOnTertiary,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
error = DarkError,
onError = DarkOnError,
errorContainer = DarkErrorContainer,
onErrorContainer = DarkOnErrorContainer,
outline = DarkOutline,
outlineVariant = DarkOutlineVariant
)
@Composable
fun HotDealTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}

View File

@@ -0,0 +1,124 @@
package com.hotdeal.alarm.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AppTypography = Typography(
// Display
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
// Headline
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
// Title
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
// Body
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
// Label
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,27 @@
package com.hotdeal.alarm.util
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.WorkManager
import com.hotdeal.alarm.worker.HotDealPollingWorker
/**
* 부팅 완료 시 자동 시작 리시버
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
// 부팅 완료 시 폴링 재시작
val workManager = WorkManager.getInstance(context)
workManager.enqueueUniquePeriodicWork(
HotDealPollingWorker.WORK_NAME,
androidx.work.ExistingPeriodicWorkPolicy.KEEP,
androidx.work.PeriodicWorkRequestBuilder<HotDealPollingWorker>(
15, java.util.concurrent.TimeUnit.MINUTES
).build()
)
}
}
}

View File

@@ -0,0 +1,41 @@
package com.hotdeal.alarm.util
/**
* 상수 정의
*/
object Constants {
// 사이트 URL
object Urls {
const val PPOMPPU_BASE = "https://www.ppomppu.co.kr/zboard/"
const val CLIEN_BASE = "https://www.clien.net"
const val RURIWEB_BASE = "https://bbs.ruliweb.com"
const val COOLENJOY_BASE = "https://coolenjoy.net"
const val QUASARZONE_BASE = "https://quasarzone.com"
}
// 폴링 간격 (분)
object PollingInterval {
const val MIN_1 = 1L
const val MIN_3 = 3L
const val MIN_5 = 5L
const val MIN_15 = 15L
const val DEFAULT = MIN_15
}
// 데이터 보관 기간 (일)
const val DATA_RETENTION_DAYS = 7
// 알림 ID
object NotificationId {
const val FOREGROUND = 1000
const val NEW_DEAL = 1001
const val KEYWORD_MATCH = 1002
}
// 채널 ID
object ChannelId {
const val URGENT = "hotdeal_urgent"
const val NORMAL = "hotdeal_normal"
const val BACKGROUND = "hotdeal_background"
}
}

View File

@@ -0,0 +1,109 @@
package com.hotdeal.alarm.util
import android.Manifest
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
/**
* 권한 관련 헬퍼 클래스
*/
object PermissionHelper {
/**
* 알림 권한이 있는지 확인
*/
fun hasNotificationPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
/**
* 정확한 알람 권한이 있는지 확인
*/
fun hasExactAlarmPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.canScheduleExactAlarms()
} else {
true
}
}
/**
* 정확한 알람 설정 화면 열기
*/
fun openExactAlarmSettings(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = Uri.parse("package:${context.packageName}")
}
context.startActivity(intent)
} catch (e: Exception) {
// 설정 화면을 열 수 없으면 앱 상세 설정으로 이동
openAppSettings(context)
}
}
}
/**
* 앱 설정 화면 열기
*/
fun openAppSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/**
* 알림 설정 화면 열기
*/
fun openNotificationSettings(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} else {
openAppSettings(context)
}
}
/**
* 권한 상태 요약
*/
data class PermissionStatus(
val hasNotificationPermission: Boolean,
val hasExactAlarmPermission: Boolean,
val isAllGranted: Boolean
)
/**
* 모든 필수 권한 상태 확인
*/
fun checkAllPermissions(context: Context): PermissionStatus {
val hasNotification = hasNotificationPermission(context)
val hasExactAlarm = hasExactAlarmPermission(context)
return PermissionStatus(
hasNotificationPermission = hasNotification,
hasExactAlarmPermission = hasExactAlarm,
isAllGranted = hasNotification && hasExactAlarm
)
}
}

View File

@@ -0,0 +1,53 @@
package com.hotdeal.alarm.util
import android.content.Context
import android.content.Intent
import com.hotdeal.alarm.domain.model.HotDeal
/**
* 공유 헬퍼
*/
object ShareHelper {
/**
* 단일 핫딜 공유
*/
fun shareDeal(context: Context, deal: HotDeal) {
val message = buildShareMessage(deal)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, deal.title)
putExtra(Intent.EXTRA_TEXT, message)
}
val chooserIntent = Intent.createChooser(intent, "핫딜 공유")
context.startActivity(chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
/**
* 여러 핫딜 공유
*/
fun shareDeals(context: Context, deals: List<HotDeal>) {
val message = deals.joinToString("\n\n") { buildShareMessage(it) }
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "핫딜 ${deals.size}")
putExtra(Intent.EXTRA_TEXT, message)
}
val chooserIntent = Intent.createChooser(intent, "핫딜 공유")
context.startActivity(chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
/**
* 공유 메시지 생성
*/
private fun buildShareMessage(deal: HotDeal): String {
val siteName = deal.siteType?.displayName ?: deal.siteName
return buildString {
append("[${siteName}] ${deal.title}\n")
append(deal.url)
deal.mallUrl?.let { url ->
append("\n쇼핑몰: $url")
}
}
}
}

View File

@@ -0,0 +1,74 @@
package com.hotdeal.alarm.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import com.hotdeal.alarm.R
import com.hotdeal.alarm.presentation.main.MainActivity
/**
* 핫딜 위젯 프로바이더
*/
class HotDealWidget : AppWidgetProvider() {
companion object {
const val ACTION_REFRESH = "com.hotdeal.alarm.widget.REFRESH"
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
appWidgetIds.forEach { appWidgetId ->
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
when (intent.action) {
ACTION_REFRESH -> {
val appWidgetManager = AppWidgetManager.getInstance(context)
val appWidgetIds = appWidgetManager.getAppWidgetIds(
intent.component ?: return
)
onUpdate(context, appWidgetManager, appWidgetIds)
}
}
}
private fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_hot_deal)
// 클릭으로 앱 열기
val pendingIntent = android.app.PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
// 새로고침 버튼
val refreshIntent = Intent(context, HotDealWidget::class.java).apply {
action = ACTION_REFRESH
}
val refreshPendingIntent = android.app.PendingIntent.getBroadcast(
context,
0,
refreshIntent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.btn_refresh, refreshPendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}

View File

@@ -0,0 +1,209 @@
package com.hotdeal.alarm.worker
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.hotdeal.alarm.data.local.db.entity.HotDealEntity
import com.hotdeal.alarm.data.local.db.entity.KeywordEntity
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
import com.hotdeal.alarm.data.remote.scraper.ScraperFactory
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.service.NotificationService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
/**
* 핫딜 폴<> Worker
*/
@HiltWorker
class HotDealPollingWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val scraperFactory: ScraperFactory,
private val notificationService: NotificationService,
private val dealDao: com.hotdeal.alarm.data.local.db.dao.HotDealDao,
private val siteConfigDao: com.hotdeal.alarm.data.local.db.dao.SiteConfigDao,
private val keywordDao: com.hotdeal.alarm.data.local.db.dao.KeywordDao
) : CoroutineWorker(context, workerParams) {
companion object {
const val WORK_NAME = "hot_deal_polling"
const val TAG = "HotDealPolling"
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "===== 핫딜 폴<> 시작 =====")
// 1. 활성화된 사이트 설정 조회
val enabledConfigs = siteConfigDao.getEnabledConfigs()
Log.d(TAG, "활성화된 사이트 설정: ${enabledConfigs.size}")
if (enabledConfigs.isEmpty()) {
Log.w(TAG, "활성화된 사이트 설정이 없습니다. 폴<>을 건<>뜁니다.")
return@withContext Result.success()
}
enabledConfigs.forEach { config ->
Log.d(TAG, " - 사이트: ${config.siteName}, 게시판: ${config.boardName}")
}
// 2. 활성화된 키워드 조회
val keywords = keywordDao.getEnabledKeywords()
Log.d(TAG, "활성화된 키워드: ${keywords.size}")
keywords.forEach { kw ->
Log.d(TAG, " - 키워드: ${kw.keyword}")
}
// 3. 각 사이트/게시판 스크래핑 (병렬)
Log.d(TAG, "스크래핑 시작...")
val allDeals = enabledConfigs.map { config ->
async {
scrapeSite(config)
}
}.awaitAll().flatten()
Log.d(TAG, "스크래핑 완료: 총 ${allDeals.size}개 핫딜")
if (allDeals.isEmpty()) {
Log.w(TAG, "스크래핑된 핫딜이 없습니다.")
// 스크래핑은 성공했지만 결과가 없는 경우도 success로 처리
return@withContext Result.success()
}
// 4. 새로운 핫딜 필터링 (URL과 ID 모두로 중복 체크)
val newDeals = filterNewDeals(allDeals)
Log.d(TAG, "새로운 핫딜: ${newDeals.size}")
if (newDeals.isEmpty()) {
Log.d(TAG, "새로운 핫딜이 없습니다.")
return@withContext Result.success()
}
newDeals.take(5).forEach { deal ->
Log.d(TAG, " 새 핫딜: ${deal.title.take(50)}...")
}
// 5. 키워드 매칭
val keywordMatchedDeals = filterByKeywords(newDeals, keywords)
Log.d(TAG, "키워드 매칭 핫딜: ${keywordMatchedDeals.size}")
// 6. DB 저장 (개별 삽입으로 변경하여 충돌 처리 개선)
var insertedCount = 0
newDeals.forEach { deal ->
try {
val entity = HotDealEntity.fromDomain(deal)
dealDao.insertDeal(entity)
insertedCount++
} catch (e: Exception) {
Log.w(TAG, "핫딜 저장 실패 (무시됨): ${deal.id}, 오류: ${e.message}")
}
}
Log.d(TAG, "DB 저장 완료: $insertedCount/${newDeals.size}개 저장됨")
// 7. 알림 발송
if (keywordMatchedDeals.isNotEmpty()) {
Log.d(TAG, "키워드 매칭 알림 발송...")
notificationService.showKeywordMatchNotification(keywordMatchedDeals)
dealDao.markAsKeywordMatch(keywordMatchedDeals.map { it.id })
} else {
Log.d(TAG, "새 핫딜 알림 발송...")
notificationService.showNewDealsNotification(newDeals.take(5))
}
// 8. 알림 발송 상태 업데이트
dealDao.markAsNotified(newDeals.map { it.id })
// 9. 오래된 데이터 정리 (7일 이상)
val threshold = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
dealDao.deleteOldDeals(threshold)
Log.d(TAG, "오래된 데이터 정리 완료")
Log.d(TAG, "===== 핫딜 폴<> 완료 =====")
Result.success()
} catch (e: Exception) {
Log.e(TAG, "<EFBFBD> 오류: ${e.message}", e)
if (runAttemptCount < 3) {
Result.retry()
} else {
Result.failure()
}
}
}
/**
* 포그라운드 서비스 정보 (빈번한 폴<>용)
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
return notificationService.createForegroundInfo()
}
/**
* 사이트 스크래핑
*/
private suspend fun scrapeSite(config: SiteConfigEntity): List<HotDeal> {
val scraper = scraperFactory.getScraper(config.siteName)
if (scraper == null) {
Log.w(TAG, "스크래퍼를 찾을 수 없음: ${config.siteName}")
return emptyList()
}
Log.d(TAG, "스크래핑: ${config.siteName}/${config.boardName}")
val result = scraper.scrape(config.boardName)
result.fold(
onSuccess = { deals ->
Log.d(TAG, " ${config.siteName}: ${deals.size}개 핫딜 파싱됨")
// 스크래핑 성공 시 lastScrapedAt 업데이트
try {
siteConfigDao.updateLastScrapedAt(config.siteBoardKey, System.currentTimeMillis())
} catch (e: Exception) {
Log.w(TAG, "lastScrapedAt 업데이트 실패: ${config.siteBoardKey}")
}
},
onFailure = { error ->
Log.e(TAG, " ${config.siteName} 스크래핑 실패: ${error.message}")
}
)
return result.getOrElse { emptyList() }
}
/**
* 새로운 핫딜 필터링
* URL과 ID 모두로 중복 체크
*/
private suspend fun filterNewDeals(deals: List<HotDeal>): List<HotDeal> {
return deals.filter { deal ->
// URL로 먼저 체크
val existingByUrl = dealDao.getDealByUrl(deal.url)
if (existingByUrl != null) return@filter false
// ID로도 체크 (추가 안전장치)
val existingById = dealDao.getDealById(deal.id)
if (existingById != null) return@filter false
true
}
}
/**
* 키워드 매칭 필터링
*/
private suspend fun filterByKeywords(deals: List<HotDeal>, keywords: List<KeywordEntity>): List<HotDeal> {
if (keywords.isEmpty()) return emptyList()
return deals.filter { deal ->
keywords.any { keyword ->
keyword.toDomain().matches(deal.title)
}
}
}
}

View File

@@ -0,0 +1,70 @@
package com.hotdeal.alarm.worker
import android.content.Context
import androidx.work.*
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WorkerScheduler @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val DEFAULT_INTERVAL_MINUTES = 15L
}
fun schedulePeriodicPolling(intervalMinutes: Long = DEFAULT_INTERVAL_MINUTES) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val workRequest = PeriodicWorkRequestBuilder<HotDealPollingWorker>(
intervalMinutes, TimeUnit.MINUTES,
intervalMinutes / 3, TimeUnit.MINUTES
)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.addTag(HotDealPollingWorker.TAG)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
HotDealPollingWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}
fun executeOnce() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<HotDealPollingWorker>()
.setConstraints(constraints)
.addTag(HotDealPollingWorker.TAG)
.build()
WorkManager.getInstance(context).enqueue(workRequest)
}
fun cancelPolling() {
WorkManager.getInstance(context).cancelUniqueWork(HotDealPollingWorker.WORK_NAME)
}
fun isPollingActive(): Boolean {
val workInfos = WorkManager.getInstance(context)
.getWorkInfosForUniqueWork(HotDealPollingWorker.WORK_NAME)
.get()
return workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
<path
android:fillColor="#1976D2"
android:pathData="M54,30L54,54L74,54A20,20 0,0 0,54 34L54,30A24,24 0,0 1,78 54L54,54L54,78A24,24 0,0 1,30 54A24,24 0,0 1,54 30Z"/>
<path
android:fillColor="#FF9800"
android:pathData="M54,38m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"/>
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF"/>
<corners android:radius="16dp"/>
<stroke
android:width="1dp"
android:color="#E0E0E0"/>
</shape>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp"
android:background="@drawable/widget_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/app_name"
android:textStyle="bold"
android:textSize="14sp"
android:textColor="@android:color/black"/>
<ImageButton
android:id="@+id/btn_refresh"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@android:drawable/ic_menu_rotate"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_refresh"/>
</LinearLayout>
<TextView
android:id="@+id/tv_deal_1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="12sp"
android:maxLines="2"
android:ellipsize="end"
android:textColor="@android:color/black"/>
<TextView
android:id="@+id/tv_deal_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="12sp"
android:maxLines="2"
android:ellipsize="end"
android:textColor="@android:color/black"/>
<TextView
android:id="@+id/tv_deal_3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="12sp"
android:maxLines="2"
android:ellipsize="end"
android:textColor="@android:color/black"/>
<TextView
android:id="@+id/tv_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/deal_list_empty"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:visibility="gone"/>
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark Mode Colors -->
<color name="primary">#FF90CAF9</color>
<color name="primary_dark">#FF64B5F6</color>
<color name="primary_light">#FFBBDEFB</color>
<color name="secondary">#FFFFB74D</color>
<color name="secondary_dark">#FFFFA726</color>
<color name="background">#FF121212</color>
<color name="surface">#FF1E1E1E</color>
<color name="error">#FFCF6679</color>
<color name="on_primary">#FF000000</color>
<color name="on_secondary">#FF000000</color>
<color name="on_background">#FFE0E0E0</color>
<color name="on_surface">#FFE0E0E0</color>
<color name="on_error">#FF000000</color>
<!-- Site Colors (Dark Mode) -->
<color name="ppomppu">#FFF48FB1</color>
<color name="clien">#FF81C784</color>
<color name="ruriweb">#FF64B5F6</color>
<color name="coolenjoy">#FFFFAB91</color>
<color name="quasarzone">#FFCE93D8</color>
</resources>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#FF1976D2</color>
<color name="primary_dark">#FF1565C0</color>
<color name="primary_light">#FF42A5F5</color>
<color name="secondary">#FFFF9800</color>
<color name="secondary_dark">#FFF57C00</color>
<color name="background">#FFFAFAFA</color>
<color name="surface">#FFFFFFFF</color>
<color name="error">#FFD32F2F</color>
<color name="on_primary">#FFFFFFFF</color>
<color name="on_secondary">#FF000000</color>
<color name="on_background">#FF1C1B1F</color>
<color name="on_surface">#FF1C1B1F</color>
<color name="on_error">#FFFFFFFF</color>
<!-- Site Colors -->
<color name="ppomppu">#FFE91E63</color>
<color name="clien">#FF4CAF50</color>
<color name="ruriweb">#FF2196F3</color>
<color name="coolenjoy">#FFFF5722</color>
<color name="quasarzone">#FF9C27B0</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1976D2</color>
</resources>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">핫딜 알람</string>
<!-- Navigation -->
<string name="nav_home"></string>
<string name="nav_settings">설정</string>
<string name="nav_deals">핫딜 목록</string>
<!-- Settings -->
<string name="settings_title">설정</string>
<string name="site_selection">사이트 선택</string>
<string name="keyword_settings">키워드 설정</string>
<string name="polling_interval">폴링 간격</string>
<!-- Notifications -->
<string name="notification_channel_name">핫딜 알림</string>
<string name="notification_channel_description">새로운 핫딜이 올라오면 알림을 보냅니다.</string>
<string name="notification_channel_urgent">긴급 핫딜</string>
<string name="notification_channel_urgent_desc">키워드에 매칭된 핫딜 알림</string>
<string name="notification_channel_normal">일반 핫딜</string>
<string name="notification_channel_normal_desc">새로운 핫딜 알림</string>
<string name="notification_channel_background">백그라운드</string>
<string name="notification_channel_background_desc">폴링 상태 알림</string>
<string name="notification_new_deal">새로운 핫딜</string>
<string name="notification_keyword_match">키워드 매칭!</string>
<!-- Widget -->
<string name="widget_title">최근 핫딜</string>
<!-- Deal List -->
<string name="deal_list_empty">수집된 핫딜이 없습니다</string>
<string name="deal_list_loading">불러오는 중…</string>
<string name="deal_list_error">데이터를 불러오는데 실패했습니다</string>
<!-- Actions -->
<string name="action_refresh">새로고침</string>
<string name="action_share">공유</string>
<string name="action_favorite">즐겨찾기</string>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HotDealAlarm" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/secondary</item>
<item name="android:statusBarColor">@color/primary_dark</item>
<item name="android:navigationBarColor">@color/background</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sample backup rules file
-->
<full-backup-content>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
</full-backup-content>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sample data extraction rules file
-->
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
</cloud-backup>
<device-transfer>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
</device-transfer>
</data-extraction-rules>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_hot_deal"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@drawable/ic_launcher_foreground"
android:description="@string/app_name">
</appwidget-provider>

View File

@@ -0,0 +1,102 @@
package com.hotdeal.alarm.data.local.db.entity
import org.junit.Assert.*
import org.junit.Test
import com.hotdeal.alarm.domain.model.HotDeal
/**
* HotDealEntity 테스트
*/
class HotDealEntityTest {
@Test
fun `toDomain should correctly map entity to domain model`() {
// Given
val entity = HotDealEntity(
id = "ppomppu_123",
siteName = "ppomppu",
boardName = "ppomppu",
title = "갤럭시 S24 핫딜",
url = "https://test.com",
mallUrl = "https://mall.com",
createdAt = 1000L,
isNotified = true,
isKeywordMatch = true
)
// When
val domain = entity.toDomain()
// Then
assertEquals(entity.id, domain.id)
assertEquals(entity.siteName, domain.siteName)
assertEquals(entity.boardName, domain.boardName)
assertEquals(entity.title, domain.title)
assertEquals(entity.url, domain.url)
assertEquals(entity.mallUrl, domain.mallUrl)
assertEquals(entity.createdAt, domain.createdAt)
assertEquals(entity.isNotified, domain.isNotified)
assertEquals(entity.isKeywordMatch, domain.isKeywordMatch)
}
@Test
fun `fromDomain should correctly map domain model to entity`() {
// Given
val domain = HotDeal(
id = "clien_456",
siteName = "clien",
boardName = "allsell",
title = "아이폰 15 핫딜",
url = "https://test.com",
mallUrl = null,
createdAt = 2000L,
isNotified = false,
isKeywordMatch = false
)
// When
val entity = HotDealEntity.fromDomain(domain)
// Then
assertEquals(domain.id, entity.id)
assertEquals(domain.siteName, entity.siteName)
assertEquals(domain.boardName, entity.boardName)
assertEquals(domain.title, entity.title)
assertEquals(domain.url, entity.url)
assertEquals(domain.mallUrl, entity.mallUrl)
assertEquals(domain.createdAt, entity.createdAt)
assertEquals(domain.isNotified, entity.isNotified)
assertEquals(domain.isKeywordMatch, entity.isKeywordMatch)
}
@Test
fun `round trip conversion should preserve data`() {
// Given
val original = HotDealEntity(
id = "test_789",
siteName = "ruriweb",
boardName = "1020",
title = "Test Deal",
url = "https://test.com",
mallUrl = "https://mall.com",
createdAt = 3000L,
isNotified = true,
isKeywordMatch = false
)
// When
val domain = original.toDomain()
val converted = HotDealEntity.fromDomain(domain)
// Then
assertEquals(original.id, converted.id)
assertEquals(original.siteName, converted.siteName)
assertEquals(original.boardName, converted.boardName)
assertEquals(original.title, converted.title)
assertEquals(original.url, converted.url)
assertEquals(original.mallUrl, converted.mallUrl)
assertEquals(original.createdAt, converted.createdAt)
assertEquals(original.isNotified, converted.isNotified)
assertEquals(original.isKeywordMatch, converted.isKeywordMatch)
}
}

View File

@@ -0,0 +1,74 @@
package com.hotdeal.alarm.data.local.db.entity
import org.junit.Assert.*
import org.junit.Test
import com.hotdeal.alarm.domain.model.Keyword
import com.hotdeal.alarm.domain.model.MatchMode
/**
* KeywordEntity 테스트
*/
class KeywordEntityTest {
@Test
fun `toDomain should correctly map entity to domain model`() {
// Given
val entity = KeywordEntity(
id = 1L,
keyword = "갤럭시",
isEnabled = true,
matchMode = "CONTAINS",
createdAt = 1000L
)
// When
val domain = entity.toDomain()
// Then
assertEquals(entity.id, domain.id)
assertEquals(entity.keyword, domain.keyword)
assertEquals(entity.isEnabled, domain.isEnabled)
assertEquals(MatchMode.CONTAINS, domain.matchMode)
assertEquals(entity.createdAt, domain.createdAt)
}
@Test
fun `toDomain should default to CONTAINS for invalid matchMode`() {
// Given
val entity = KeywordEntity(
id = 1L,
keyword = "test",
isEnabled = true,
matchMode = "INVALID",
createdAt = 1000L
)
// When
val domain = entity.toDomain()
// Then
assertEquals(MatchMode.CONTAINS, domain.matchMode)
}
@Test
fun `fromDomain should correctly map domain model to entity`() {
// Given
val domain = Keyword(
id = 2L,
keyword = "아이폰",
isEnabled = false,
matchMode = MatchMode.REGEX,
createdAt = 2000L
)
// When
val entity = KeywordEntity.fromDomain(domain)
// Then
assertEquals(domain.id, entity.id)
assertEquals(domain.keyword, entity.keyword)
assertEquals(domain.isEnabled, entity.isEnabled)
assertEquals(domain.matchMode.name, entity.matchMode)
assertEquals(domain.createdAt, entity.createdAt)
}
}

View File

@@ -0,0 +1,59 @@
package com.hotdeal.alarm.data.local.db.entity
import org.junit.Assert.*
import org.junit.Test
import com.hotdeal.alarm.domain.model.SiteConfig
/**
* SiteConfigEntity 테스트
*/
class SiteConfigEntityTest {
@Test
fun `toDomain should correctly map entity to domain model`() {
// Given
val entity = SiteConfigEntity(
siteBoardKey = "ppomppu_ppomppu",
siteName = "ppomppu",
boardName = "ppomppu",
displayName = "뽐뿌 - 뽐뿌게시판",
isEnabled = true,
lastScrapedAt = 1000L
)
// When
val domain = entity.toDomain()
// Then
assertEquals(entity.siteBoardKey, domain.siteBoardKey)
assertEquals(entity.siteName, domain.siteName)
assertEquals(entity.boardName, domain.boardName)
assertEquals(entity.displayName, domain.displayName)
assertEquals(entity.isEnabled, domain.isEnabled)
assertEquals(entity.lastScrapedAt, domain.lastScrapedAt)
}
@Test
fun `fromDomain should correctly map domain model to entity`() {
// Given
val domain = SiteConfig(
siteBoardKey = "clien_allsell",
siteName = "clien",
boardName = "allsell",
displayName = "클리앙 - 사고팔고",
isEnabled = false,
lastScrapedAt = null
)
// When
val entity = SiteConfigEntity.fromDomain(domain)
// Then
assertEquals(domain.siteBoardKey, entity.siteBoardKey)
assertEquals(domain.siteName, entity.siteName)
assertEquals(domain.boardName, entity.boardName)
assertEquals(domain.displayName, entity.displayName)
assertEquals(domain.isEnabled, entity.isEnabled)
assertEquals(domain.lastScrapedAt, entity.lastScrapedAt)
}
}

View File

@@ -0,0 +1,152 @@
package com.hotdeal.alarm.data.remote.scraper
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.concurrent.TimeUnit
/**
* 각 사이트 스크래퍼 테스트
* Robolectric을 사용하여 Android Log 클래스 지원
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ScraperTest {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@Test
fun testAllScrapers() = runBlocking {
println("\n" + "#".repeat(60))
println("# 핫딜 알람 파싱 테스트")
println("# 각 사이트에서 게시물 5개씩 추출")
println("#".repeat(60))
testPpomppu()
testClien()
testRuriweb()
testCoolenjoy()
println("\n" + "#".repeat(60))
println("# 테스트 완료")
println("#".repeat(60))
}
suspend fun testPpomppu() {
println("\n" + "=".repeat(60))
println("【뽐뿌 (ppomppu)】")
println("=".repeat(60))
val scraper = PpomppuScraper(client)
val result = scraper.scrape("ppomppu")
result.fold(
onSuccess = { deals ->
deals.take(5).forEachIndexed { index, deal ->
println("\n[게시물 ${index + 1}]")
println(" ID: ${deal.id}")
println(" 제목: ${deal.title}")
println(" URL: ${deal.url}")
}
if (deals.isEmpty()) {
println(" ⚠️ 파싱된 게시물이 없습니다.")
} else {
println("\n ✅ 총 ${deals.size}개 파싱 성공")
}
},
onFailure = { error ->
println(" ❌ 오류: ${error.message}")
}
)
}
suspend fun testClien() {
println("\n" + "=".repeat(60))
println("【클리앙 (clien)】")
println("=".repeat(60))
val scraper = ClienScraper(client)
val result = scraper.scrape("jirum")
result.fold(
onSuccess = { deals ->
deals.take(5).forEachIndexed { index, deal ->
println("\n[게시물 ${index + 1}]")
println(" ID: ${deal.id}")
println(" 제목: ${deal.title}")
println(" URL: ${deal.url}")
}
if (deals.isEmpty()) {
println(" ⚠️ 파싱된 게시물이 없습니다.")
} else {
println("\n ✅ 총 ${deals.size}개 파싱 성공")
}
},
onFailure = { error ->
println(" ❌ 오류: ${error.message}")
}
)
}
suspend fun testRuriweb() {
println("\n" + "=".repeat(60))
println("【루리웹 (ruriweb)】")
println("=".repeat(60))
val scraper = RuriwebScraper(client)
val result = scraper.scrape("1020")
result.fold(
onSuccess = { deals ->
deals.take(5).forEachIndexed { index, deal ->
println("\n[게시물 ${index + 1}]")
println(" ID: ${deal.id}")
println(" 제목: ${deal.title}")
println(" URL: ${deal.url}")
}
if (deals.isEmpty()) {
println(" ⚠️ 파싱된 게시물이 없습니다.")
} else {
println("\n ✅ 총 ${deals.size}개 파싱 성공")
}
},
onFailure = { error ->
println(" ❌ 오류: ${error.message}")
}
)
}
suspend fun testCoolenjoy() {
println("\n" + "=".repeat(60))
println("【쿨엔조이 (coolenjoy)】")
println("=".repeat(60))
val scraper = CoolenjoyScraper(client)
val result = scraper.scrape("jirum")
result.fold(
onSuccess = { deals ->
deals.take(5).forEachIndexed { index, deal ->
println("\n[게시물 ${index + 1}]")
println(" ID: ${deal.id}")
println(" 제목: ${deal.title}")
println(" URL: ${deal.url}")
}
if (deals.isEmpty()) {
println(" ⚠️ 파싱된 게시물이 없습니다.")
} else {
println("\n ✅ 총 ${deals.size}개 파싱 성공")
}
},
onFailure = { error ->
println(" ❌ 오류: ${error.message}")
}
)
}
}

View File

@@ -0,0 +1,79 @@
package com.hotdeal.alarm.domain.model
import org.junit.Assert.*
import org.junit.Test
/**
* HotDeal 도메인 모델 테스트
*/
class HotDealTest {
@Test
fun `generateId should create unique id from siteName and postId`() {
// Given
val siteName = "ppomppu"
val postId = "12345"
// When
val id = HotDeal.generateId(siteName, postId)
// Then
assertEquals("ppomppu_12345", id)
}
@Test
fun `siteType should return correct SiteType for valid siteName`() {
// Given
val deal = HotDeal(
id = "ppomppu_123",
siteName = "ppomppu",
boardName = "ppomppu",
title = "Test Deal",
url = "https://test.com",
createdAt = System.currentTimeMillis()
)
// When
val siteType = deal.siteType
// Then
assertEquals(SiteType.PPOMPPU, siteType)
}
@Test
fun `siteType should return null for invalid siteName`() {
// Given
val deal = HotDeal(
id = "invalid_123",
siteName = "invalid",
boardName = "test",
title = "Test Deal",
url = "https://test.com",
createdAt = System.currentTimeMillis()
)
// When
val siteType = deal.siteType
// Then
assertNull(siteType)
}
@Test
fun `HotDeal should have correct default values`() {
// Given & When
val deal = HotDeal(
id = "test_123",
siteName = "test",
boardName = "test",
title = "Test",
url = "https://test.com",
createdAt = 1000L
)
// Then
assertNull(deal.mallUrl)
assertFalse(deal.isNotified)
assertFalse(deal.isKeywordMatch)
}
}

View File

@@ -0,0 +1,127 @@
package com.hotdeal.alarm.domain.model
import org.junit.Assert.*
import org.junit.Test
/**
* Keyword 도메인 모델 테스트
*/
class KeywordTest {
@Test
fun `matches should return true when keyword is contained in title`() {
// Given
val keyword = Keyword(keyword = "갤럭시", matchMode = MatchMode.CONTAINS)
val title = "갤럭시 S24 울트라 핫딜"
// When
val result = keyword.matches(title)
// Then
assertTrue(result)
}
@Test
fun `matches should return false when keyword is not contained in title`() {
// Given
val keyword = Keyword(keyword = "아이폰", matchMode = MatchMode.CONTAINS)
val title = "갤럭시 S24 울트라 핫딜"
// When
val result = keyword.matches(title)
// Then
assertFalse(result)
}
@Test
fun `matches should be case insensitive`() {
// Given
val keyword = Keyword(keyword = "SAMSUNG", matchMode = MatchMode.CONTAINS)
val title = "samsung 갤럭시 핫딜"
// When
val result = keyword.matches(title)
// Then
assertTrue(result)
}
@Test
fun `matches should return false when keyword is disabled`() {
// Given
val keyword = Keyword(keyword = "갤럭시", isEnabled = false, matchMode = MatchMode.CONTAINS)
val title = "갤럭시 S24 울트라 핫딜"
// When
val result = keyword.matches(title)
// Then
assertFalse(result)
}
@Test
fun `matches should return false when keyword is blank`() {
// Given
val keyword = Keyword(keyword = "", matchMode = MatchMode.CONTAINS)
val title = "갤럭시 S24 울트라 핫딜"
// When
val result = keyword.matches(title)
// Then
assertFalse(result)
}
@Test
fun `matches should return true for exact match`() {
// Given
val keyword = Keyword(keyword = "갤럭시", matchMode = MatchMode.EXACT)
val title = "갤럭시"
// When
val result = keyword.matches(title)
// Then
assertTrue(result)
}
@Test
fun `matches should return false for partial match in exact mode`() {
// Given
val keyword = Keyword(keyword = "갤럭시", matchMode = MatchMode.EXACT)
val title = "갤럭시 S24"
// When
val result = keyword.matches(title)
// Then
assertFalse(result)
}
@Test
fun `matches should return true for regex match`() {
// Given
val keyword = Keyword(keyword = "갤럭시.*프로", matchMode = MatchMode.REGEX)
val title = "갤럭시 S24 프로 핫딜"
// When
val result = keyword.matches(title)
// Then
assertTrue(result)
}
@Test
fun `matches should return false for invalid regex`() {
// Given
val keyword = Keyword(keyword = "[invalid", matchMode = MatchMode.REGEX)
val title = "갤럭시 S24"
// When
val result = keyword.matches(title)
// Then
assertFalse(result)
}
}

View File

@@ -0,0 +1,83 @@
package com.hotdeal.alarm.domain.model
import org.junit.Assert.*
import org.junit.Test
/**
* SiteType 테스트
*/
class SiteTypeTest {
@Test
fun `fromName should return correct SiteType for valid name`() {
// Given
val name = "ppomppu"
// When
val result = SiteType.fromName(name)
// Then
assertEquals(SiteType.PPOMPPU, result)
}
@Test
fun `fromName should be case insensitive`() {
// Given
val name = "PPOMPPU"
// When
val result = SiteType.fromName(name)
// Then
assertEquals(SiteType.PPOMPPU, result)
}
@Test
fun `fromName should return null for invalid name`() {
// Given
val name = "invalid"
// When
val result = SiteType.fromName(name)
// Then
assertNull(result)
}
@Test
fun `PPOMPPU should have correct boards`() {
// Given
val site = SiteType.PPOMPPU
// Then
assertEquals("뽐뿌", site.displayName)
assertEquals(4, site.boards.size)
assertTrue(site.boards.any { it.id == "ppomppu" })
assertTrue(site.boards.any { it.id == "ppomppu4" })
assertTrue(site.boards.any { it.id == "ppomppu8" })
assertTrue(site.boards.any { it.id == "money" })
}
@Test
fun `CLIEN should have correct boards`() {
// Given
val site = SiteType.CLIEN
// Then
assertEquals("클리앙", site.displayName)
assertEquals(2, site.boards.size)
assertTrue(site.boards.any { it.id == "allsell" })
assertTrue(site.boards.any { it.id == "jirum" })
}
@Test
fun `all SiteTypes should have at least one board`() {
// When
val sites = SiteType.entries
// Then
sites.forEach { site ->
assertTrue("${site.name} should have at least one board", site.boards.isNotEmpty())
}
}
}

11
build.gradle.kts Normal file
View File

@@ -0,0 +1,11 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("com.google.dagger.hilt.android") version "2.50" apply false
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
}
tasks.register("clean", Delete::class) {
delete(rootProject.layout.buildDirectory)
}

15
gradle.properties Normal file
View File

@@ -0,0 +1,15 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
# Android settings
android.useAndroidX=true
android.nonTransitiveRClass=true
# Kotlin settings
kotlin.code.style=official
# Suppress SDK warning
android.suppressUnsupportedCompileSdk=35

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

169
gradlew vendored Executable file
View File

@@ -0,0 +1,169 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing / for empty app_path
APP_HOME=$(cd "$APP_HOME" && pwd -P)
app_path=$(readlink "$app_path")
do
:
done
APP_HOME=$(cd "${APP_HOME:-./}" && pwd -P)
APP_BASE_NAME=${0##*/}
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$(uname)" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MSYS* | MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in
max*)
MAX_FD=$(ulimit -H -n) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in
'' | soft) :;;
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$(cygpath --path --mixed "$APP_HOME")
CLASSPATH=$(cygpath --path --mixed "$CLASSPATH")
JAVACMD=$(cygpath --unix "$JAVACMD")
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
shift
case $arg in
-D*) arg=$arg ;;
--module-path) arg=$arg ;;
--add-opens) arg=$arg ;;
*) arg=$(cygpath --path --ignore --mixed "$arg") ;;
esac
set -- "$@" "$arg"
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# temporary arguments, e.g. -Dfoo=bar -Dbaz="qux qux"
# instead of -Dfoo=bar -Dbaz="qux qux"
# * shell script quoting is not yet supported
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n://1//, we process at most one arg per execution.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HotDealAlarm"
include(":app")