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:
86
.gitignore
vendored
Normal file
86
.gitignore
vendored
Normal 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
234
README.md
Normal 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
140
app/build.gradle.kts
Normal 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
18
app/proguard-rules.pro
vendored
Normal 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 {}
|
||||
105
app/src/androidTest/java/com/hotdeal/alarm/DealItemTest.kt
Normal file
105
app/src/androidTest/java/com/hotdeal/alarm/DealItemTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
108
app/src/androidTest/java/com/hotdeal/alarm/KeywordItemTest.kt
Normal file
108
app/src/androidTest/java/com/hotdeal/alarm/KeywordItemTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
80
app/src/main/AndroidManifest.xml
Normal file
80
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
24
app/src/main/java/com/hotdeal/alarm/HotDealApplication.kt
Normal file
24
app/src/main/java/com/hotdeal/alarm/HotDealApplication.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(",")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
@@ -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) { "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/com/hotdeal/alarm/di/DatabaseModule.kt
Normal file
55
app/src/main/java/com/hotdeal/alarm/di/DatabaseModule.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
80
app/src/main/java/com/hotdeal/alarm/di/NetworkModule.kt
Normal file
80
app/src/main/java/com/hotdeal/alarm/di/NetworkModule.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
46
app/src/main/java/com/hotdeal/alarm/domain/model/HotDeal.kt
Normal file
46
app/src/main/java/com/hotdeal/alarm/domain/model/HotDeal.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/com/hotdeal/alarm/domain/model/Keyword.kt
Normal file
31
app/src/main/java/com/hotdeal/alarm/domain/model/Keyword.kt
Normal 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 // 잘못된 정규식
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/src/main/java/com/hotdeal/alarm/domain/model/SiteType.kt
Normal file
62
app/src/main/java/com/hotdeal/alarm/domain/model/SiteType.kt
Normal 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 // 정규식
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}일 전"
|
||||
}
|
||||
}
|
||||
@@ -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("다시 시도")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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("취소")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 = "삭제")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/com/hotdeal/alarm/ui/theme/Dimensions.kt
Normal file
34
app/src/main/java/com/hotdeal/alarm/ui/theme/Dimensions.kt
Normal 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
|
||||
}
|
||||
168
app/src/main/java/com/hotdeal/alarm/ui/theme/Theme.kt
Normal file
168
app/src/main/java/com/hotdeal/alarm/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
124
app/src/main/java/com/hotdeal/alarm/ui/theme/Typography.kt
Normal file
124
app/src/main/java/com/hotdeal/alarm/ui/theme/Typography.kt
Normal 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
|
||||
)
|
||||
)
|
||||
27
app/src/main/java/com/hotdeal/alarm/util/BootReceiver.kt
Normal file
27
app/src/main/java/com/hotdeal/alarm/util/BootReceiver.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/src/main/java/com/hotdeal/alarm/util/Constants.kt
Normal file
41
app/src/main/java/com/hotdeal/alarm/util/Constants.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
109
app/src/main/java/com/hotdeal/alarm/util/PermissionHelper.kt
Normal file
109
app/src/main/java/com/hotdeal/alarm/util/PermissionHelper.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
53
app/src/main/java/com/hotdeal/alarm/util/ShareHelper.kt
Normal file
53
app/src/main/java/com/hotdeal/alarm/util/ShareHelper.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/src/main/java/com/hotdeal/alarm/widget/HotDealWidget.kt
Normal file
74
app/src/main/java/com/hotdeal/alarm/widget/HotDealWidget.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
16
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
16
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/widget_background.xml
Normal file
9
app/src/main/res/drawable/widget_background.xml
Normal 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>
|
||||
74
app/src/main/res/layout/widget_hot_deal.xml
Normal file
74
app/src/main/res/layout/widget_hot_deal.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
24
app/src/main/res/values-night/colors.xml
Normal file
24
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||
23
app/src/main/res/values/colors.xml
Normal file
23
app/src/main/res/values/colors.xml
Normal 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>
|
||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1976D2</color>
|
||||
</resources>
|
||||
40
app/src/main/res/values/strings.xml
Normal file
40
app/src/main/res/values/strings.xml
Normal 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>
|
||||
11
app/src/main/res/values/themes.xml
Normal file
11
app/src/main/res/values/themes.xml
Normal 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>
|
||||
8
app/src/main/res/xml/backup_rules.xml
Normal file
8
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
14
app/src/main/res/xml/data_extraction_rules.xml
Normal file
14
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
11
app/src/main/res/xml/widget_info.xml
Normal file
11
app/src/main/res/xml/widget_info.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
127
app/src/test/java/com/hotdeal/alarm/domain/model/KeywordTest.kt
Normal file
127
app/src/test/java/com/hotdeal/alarm/domain/model/KeywordTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
11
build.gradle.kts
Normal 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
15
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
169
gradlew
vendored
Executable 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
18
settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user