Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,8 @@ dependencies {
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.timber)

// Hilt work
implementation(libs.hilt.ext.work)
ksp(libs.hilt.ext.compiler)
}
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".ChacApp"
Expand All @@ -33,6 +34,18 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<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>
</application>

</manifest>
14 changes: 13 additions & 1 deletion app/src/main/java/com/chac/ChacApp.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
package com.chac

import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject

@HiltAndroidApp
class ChacApp : Application() {
class ChacApp : Application(), Configuration.Provider {

@Inject
lateinit var workerFactory: HiltWorkerFactory

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()

override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Expand Down
3 changes: 3 additions & 0 deletions core/resources/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@
<string name="clustering_empty_message">사진을 올리면 앨범을 착 정리할게요.</string>
<string name="clustering_permission_message">앨범을 생성하려면\n사진 접근 권한이 필요해요.</string>
<string name="clustering_permission_action">설정으로 이동</string>

<string name="clustering_worker_fail_title">클러스터링 실패</string>
<string name="clustering_worker_progress_title">클러스터링 중</string>
</resources>
5 changes: 5 additions & 0 deletions data/album/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,10 @@ android {

dependencies {
implementation(projects.domain.album)
implementation(projects.core.resources)
implementation(libs.commons.math3)
implementation(libs.androidx.work.ktx)
implementation(libs.hilt.ext.common)
implementation(libs.hilt.ext.work)
ksp(libs.hilt.ext.compiler)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.chac.data.album.media

import com.chac.data.album.media.clustering.worker.ClusteringWorkerSchedulerImpl
import com.chac.domain.album.media.ClusteringWorkScheduler
import com.chac.domain.album.media.MediaRepository
import dagger.Binds
import dagger.Module
Expand All @@ -13,4 +15,8 @@ internal interface MediaModule {
@Binds
@Singleton
fun bindMediaRepository(mediaRepository: MediaRepositoryImpl): MediaRepository

@Binds
@Singleton
fun bindClusteringWorkerScheduler(clusteringWorkScheduler: ClusteringWorkerSchedulerImpl): ClusteringWorkScheduler
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.chac.data.album.media.clustering.worker

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.chac.core.resources.R.*
import com.chac.domain.album.media.MediaRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import timber.log.Timber


@HiltWorker
class ClusteringWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val workerParams: WorkerParameters,
private val mediaRepository: MediaRepository,
) : CoroutineWorker(context, workerParams) {
private val notificationManager by lazy {
context.getSystemService(NotificationManager::class.java)
}

override suspend fun doWork(): Result {
initNotification()

return startClustering()
}

override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
getNotificationId(),
createNotificationBuilder().build(),
)
}

private fun initNotification() {
createNotificationChannel()
}

private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(channel)
}

private fun createNotificationBuilder(): NotificationCompat.Builder {
return NotificationCompat.Builder(context, CHANNEL_ID)
.setColor(
ContextCompat.getColor(
context,
android.R.color.darker_gray,
),
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setSmallIcon(android.R.mipmap.sym_def_app_icon)
.setOnlyAlertOnce(true)
}

private fun createProgressNotificationBuilder(): NotificationCompat.Builder {
return createNotificationBuilder()
.setAutoCancel(false)
.setContentTitle(context.getString(string.clustering_worker_progress_title))
.setOngoing(true)
}

private fun showFailUploadNotification() {
val notification = createNotificationBuilder()
.setAutoCancel(false)
.setContentTitle(context.getString(string.clustering_worker_fail_title))
.build()
notificationManager.cancel(getNotificationId())
notificationManager.notify(getNotificationId(), notification)
}

private suspend fun startClustering(): Result = withContext(Dispatchers.IO) {
notificationManager.notify(getNotificationId(), createProgressNotificationBuilder().build())
runCatching {
// TODO 여기에 클러스터링 업뎃해주는 코드가 들어가야함
mediaRepository.getMedia()
delay(3000)
}.onFailure {
handleError(it)
}
notificationManager.cancel(getNotificationId())
return@withContext Result.success()
}

private fun handleError(throwable: Throwable) {
Timber.e("ClusteringWorker handleError - ${throwable.message}")
showFailUploadNotification()
}

private fun getNotificationId(): Int {
return 1071724654
}

companion object {
private const val CHANNEL_ID = "channel_id::clustering_worker"
private const val CHANNEL_NAME = "ClusteringWorker"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.chac.data.album.media.clustering.worker

import android.content.Context
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import com.chac.domain.album.media.ClusteringWorkScheduler
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

internal class ClusteringWorkerSchedulerImpl @Inject constructor(
@ApplicationContext private val context: Context,
) : ClusteringWorkScheduler {
override fun scheduleClustering() {
val workerManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequestBuilder<ClusteringWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setInputData(
Data.Builder().build(),
)
.build()
workerManager.enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.KEEP,
workRequest,
)
}

companion object {
private const val WORK_NAME = "clustering_work"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.chac.domain.album.media

interface ClusteringWorkScheduler {
fun scheduleClustering()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.chac.domain.album.media

import javax.inject.Inject

class StartClusteringUseCase @Inject constructor(
private val clusteringWorkScheduler: ClusteringWorkScheduler
) {
operator fun invoke() {
clusteringWorkScheduler.scheduleClustering()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.chac.feature.album.clustering

import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -66,12 +69,23 @@ fun ClusteringRoute(
onPermanentlyDenied = { viewModel.onPermissionChanged(false) },
)

// 알림 권한 요청 런처
val requestPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted ->
// TODO 여기다가 알림 권한 on off에 따른 ui 처리해두면됨
}
)


LaunchedEffect(Unit) {
val hasPermission = MediaWithLocationPermissionUtil.checkPermission(context)
viewModel.onPermissionChanged(hasPermission)
if (!hasPermission) {
permission.launchMediaWithLocationPermission()
}

requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}

DisposableEffect(lifecycleOwner, context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.chac.feature.album.clustering
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.chac.domain.album.media.GetClusteredMediaStreamUseCase
import com.chac.domain.album.media.StartClusteringUseCase
import com.chac.feature.album.clustering.model.ClusteringUiState
import com.chac.feature.album.clustering.model.toUiModel
import com.chac.feature.album.model.ClusterUiModel
Expand All @@ -18,6 +19,7 @@ import javax.inject.Inject
@HiltViewModel
class ClusteringViewModel @Inject constructor(
private val getClusteredMediaStreamUseCase: GetClusteredMediaStreamUseCase,
private val startClusteringUseCase: StartClusteringUseCase
) : ViewModel() {
/** 클러스터링 화면의 상태 */
private val _uiState = MutableStateFlow<ClusteringUiState>(ClusteringUiState.PermissionChecking)
Expand Down Expand Up @@ -55,6 +57,7 @@ class ClusteringViewModel @Inject constructor(
* 클러스터 스트림 수집을 수집하고 상태를 갱신한다.
*/
private fun refreshClusters() {
startClusteringUseCase()
clusterCollectJob = viewModelScope.launch {
try {
_uiState.value = ClusteringUiState.Loading(emptyList())
Expand Down
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ kotlinxCoroutinesCore = "1.10.2"
coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.2"
work = "2.11.0"

# Jetpack Compose
androidxComposeBom = "2025.12.01"
Expand All @@ -24,6 +25,7 @@ kotlinxSerializationJson = "1.9.0"
# Dependency Injection - Hilt
hilt = "2.57.2"
hiltNavigationCompose = "1.3.0"
hiltExt = "1.3.0"

# Room
room = "2.8.4"
Expand Down Expand Up @@ -53,6 +55,9 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleRuntimeKtx" }

# Androdix Work
androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }

# Jetpack Compose
compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
Expand All @@ -79,6 +84,9 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" }
hilt-ext-common = { group = "androidx.hilt", name = "hilt-common", version.ref = "hiltExt" }
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }

# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
Expand Down