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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ android {
dependencies {
// core
implementation(projects.core.common)
implementation(projects.core.work)

// presentation
implementation(projects.presentation.main)
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/hilingual/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import coil3.ImageLoader
import coil3.SingletonImageLoader
import com.hilingual.core.work.scheduler.HilingualWorkManagerConfigurator
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
Expand All @@ -30,12 +31,16 @@ class App : Application(), SingletonImageLoader.Factory {
@Inject
lateinit var imageLoader: Lazy<ImageLoader>

@Inject
lateinit var workConfigurator: HilingualWorkManagerConfigurator

override fun onCreate() {
super.onCreate()
SingletonImageLoader.setSafe { imageLoader.get() }

setDayMode()
initTimber()
initWorkManager()
}

override fun newImageLoader(context: Context): ImageLoader = imageLoader.get()
Expand All @@ -47,4 +52,8 @@ class App : Application(), SingletonImageLoader.Factory {
private fun initTimber() {
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
}

private fun initWorkManager() {
workConfigurator.initialize()
}
}
15 changes: 15 additions & 0 deletions core/designsystem/src/main/res/drawable/ic_notification.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M341.55,271.58L341.55,146.08L393.22,146.08L393.22,271.58L341.55,271.58Z"
android:fillColor="@android:color/white"/>
<path
android:pathData="M244.43,364.49V267.02L162.39,289.52V364.49H105.32V145.82H162.39V237.74L244.43,214.96V145.82H301.49V364.49H244.43Z"
android:fillColor="@android:color/white"/>
<path
android:pathData="M366.84,294.88C372.45,294.88 377.15,298.74 378.39,303.92C380.1,302.97 382.08,302.42 384.19,302.42C390.74,302.42 396.06,307.69 396.06,314.18C396.06,316.07 395.61,317.85 394.81,319.43C398.95,321.27 401.84,325.38 401.84,330.17C401.84,334.78 399.16,338.77 395.26,340.7C395.77,342.02 396.06,343.45 396.06,344.95C396.06,351.45 390.74,356.71 384.19,356.71C382.11,356.71 380.15,356.18 378.45,355.24C377.31,360.56 372.55,364.55 366.84,364.55C361.19,364.55 356.46,360.63 355.27,355.39C353.63,356.23 351.77,356.71 349.8,356.71C343.24,356.71 337.93,351.45 337.93,344.95C337.93,343.45 338.22,342.02 338.73,340.7C334.83,338.77 332.15,334.78 332.15,330.17C332.15,325.38 335.04,321.27 339.18,319.43C338.38,317.85 337.93,316.07 337.93,314.18C337.93,307.69 343.24,302.42 349.8,302.42C351.8,302.42 353.68,302.91 355.33,303.78C356.62,298.67 361.29,294.88 366.84,294.88Z"
android:fillColor="@android:color/white"/>
</vector>
1 change: 1 addition & 0 deletions core/notification/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
31 changes: 31 additions & 0 deletions core/notification/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2025 The Hilingual Project
*
* 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.
*/
import com.hilingual.buildlogic.setNamespace

plugins {
alias(libs.plugins.hilingual.android.library)
alias(libs.plugins.hilingual.hilt)
}

android {
setNamespace("core.notification")
}

dependencies {
implementation(projects.core.designsystem)
implementation(libs.androidx.core.ktx)
implementation(libs.timber)
}
4 changes: 4 additions & 0 deletions core/notification/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.hilingual.core.notification

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import com.hilingual.core.designsystem.R
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class HilingualNotificationManager @Inject constructor(
@ApplicationContext private val context: Context
) {

private val notificationManager: NotificationManager? = context.getSystemService()

init {
createNotificationChannels()
}

private fun createNotificationChannels() {
val dailyChannel = NotificationChannel(
CHANNEL_ID_DAILY,
"일간 알림",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "하루를 정리하는 알림"
}

val weeklyChannel = NotificationChannel(
CHANNEL_ID_WEEKLY,
"주간 알림",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "한 주를 정리하는 알림"
}

notificationManager?.createNotificationChannels(listOf(dailyChannel, weeklyChannel))
}

fun sendDailyNotification() {
sendNotification(
channelId = CHANNEL_ID_DAILY,
notificationId = NOTIFICATION_ID_DAILY,
title = "하루를 정리해 볼 시간 ✏️",
message = "오늘 하루를 돌아보며 떠오르는 생각들을 자유롭게 적어보세요."
)
}

fun sendWeeklyNotification() {
sendNotification(
channelId = CHANNEL_ID_WEEKLY,
notificationId = NOTIFICATION_ID_WEEKLY,
title = "한 주를 정리해보는 시간 ✍️",
message = "특별한 주제가 없어도 괜찮아요. 지금 생각나는 걸 써보세요."
)
}

private fun sendNotification(
channelId: String,
notificationId: Int,
title: String,
message: String
) {
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}

val pendingIntent = launchIntent?.let {
PendingIntent.getActivity(
context,
0,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}

val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)

if (pendingIntent != null) {
builder.setContentIntent(pendingIntent)
} else {
Timber.e("Launch Intent is null. Cannot set ContentIntent.")
}

try {
notificationManager?.notify(notificationId, builder.build())
} catch (e: SecurityException) {
Timber.e(e, "Failed to send notification due to permission issues.")
}
}

companion object {
private const val CHANNEL_ID_DAILY = "channel_daily_notification"
private const val CHANNEL_ID_WEEKLY = "channel_weekly_notification"

private const val NOTIFICATION_ID_DAILY = 1001
private const val NOTIFICATION_ID_WEEKLY = 1002
}
}
1 change: 1 addition & 0 deletions core/work/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
34 changes: 34 additions & 0 deletions core/work/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2025 The Hilingual Project
*
* 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.
*/
import com.hilingual.buildlogic.setNamespace

plugins {
alias(libs.plugins.hilingual.android.library)
alias(libs.plugins.hilingual.hilt)
}

android {
setNamespace("core.work")
}

dependencies {
implementation(projects.core.notification)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.startup)
implementation(libs.hilt.work)
ksp(libs.hilt.compiler.androidx)
implementation(libs.timber)
}
17 changes: 17 additions & 0 deletions core/work/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<!--
HiltWorkerFactory를 사용한 수동 초기화를 위해
기본 WorkManager Initializer를 비활성화합니다.
-->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.hilingual.core.work.scheduler

import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.hilingual.core.work.worker.DailyNotificationWorker
import com.hilingual.core.work.worker.WeeklyNotificationWorker
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Calendar
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class HilingualWorkManagerConfigurator @Inject constructor(
@ApplicationContext private val context: Context,
private val workerFactory: HiltWorkerFactory
) {

fun initialize() {
val config = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
WorkManager.initialize(context, config)

scheduleWorks()
}

private fun scheduleWorks() {
val workManager = WorkManager.getInstance(context)
scheduleDailyWork(workManager)
scheduleWeeklyWork(workManager)
}

private fun scheduleDailyWork(workManager: WorkManager) {
val workRequest = PeriodicWorkRequestBuilder<DailyNotificationWorker>(
repeatInterval = 1,
repeatIntervalTimeUnit = TimeUnit.DAYS
).setInitialDelay(calculateInitialDelay(targetHour = 22), TimeUnit.MILLISECONDS)
.build()

workManager.enqueueUniquePeriodicWork(
"DailyNotification",
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}

private fun scheduleWeeklyWork(workManager: WorkManager) {
val workRequest = PeriodicWorkRequestBuilder<WeeklyNotificationWorker>(
repeatInterval = 7,
repeatIntervalTimeUnit = TimeUnit.DAYS
).setInitialDelay(
calculateInitialDelay(targetDay = Calendar.SUNDAY, targetHour = 19),
TimeUnit.MILLISECONDS
)
.build()

workManager.enqueueUniquePeriodicWork(
"WeeklyNotification",
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}

private fun calculateInitialDelay(targetHour: Int): Long {
val now = Calendar.getInstance()
val target = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, targetHour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}

if (now.after(target)) target.add(Calendar.DAY_OF_YEAR, 1)

return target.timeInMillis - now.timeInMillis
}

private fun calculateInitialDelay(targetDay: Int, targetHour: Int): Long {
val now = Calendar.getInstance()
val target = Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, targetDay)
set(Calendar.HOUR_OF_DAY, targetHour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}

if (now.after(target)) target.add(Calendar.WEEK_OF_YEAR, 1)

return target.timeInMillis - now.timeInMillis
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.hilingual.core.work.worker

import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.hilingual.core.notification.HilingualNotificationManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.Calendar

@HiltWorker
class DailyNotificationWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val notificationManager: HilingualNotificationManager
) : CoroutineWorker(appContext, workerParams) {

override suspend fun doWork(): Result {
val today = Calendar.getInstance().get(Calendar.DAY_OF_WEEK)
if (today == Calendar.SUNDAY) Result.success()

notificationManager.sendDailyNotification()
return Result.success()
}
}
Loading