diff --git a/app/src/main/kotlin/com/ratik/uttam/UttamApp.kt b/app/src/main/kotlin/com/ratik/uttam/UttamApp.kt index 9324b1f..5d93261 100644 --- a/app/src/main/kotlin/com/ratik/uttam/UttamApp.kt +++ b/app/src/main/kotlin/com/ratik/uttam/UttamApp.kt @@ -3,11 +3,14 @@ package com.ratik.uttam import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager +import android.content.SharedPreferences import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.O import androidx.core.app.NotificationManagerCompat import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import com.ratik.uttam.bg.WallpaperRefreshScheduler +import com.ratik.uttam.data.dao.UserDao.Companion.HAS_ONBOARDED import com.ratik.uttam.logging.ReleaseTree import com.ratik.uttam.util.NotificationHelper.Companion.CHANNEL_ID import com.ratik.uttam.util.NotificationHelper.Companion.CHANNEL_NAME @@ -25,10 +28,17 @@ class UttamApp : Application(), Configuration.Provider { @Inject lateinit var notificationManager: NotificationManagerCompat + @Inject + lateinit var sharedPreferences: SharedPreferences + + @Inject + lateinit var refreshScheduler: WallpaperRefreshScheduler + override fun onCreate() { super.onCreate() initLogging() createNotificationChannel() + updateWallpaperRefreshSchedule() } private fun initLogging() { @@ -39,6 +49,12 @@ class UttamApp : Application(), Configuration.Provider { override val workManagerConfiguration: Configuration get() = Configuration.Builder().setWorkerFactory(workerFactory).build() + private fun updateWallpaperRefreshSchedule() { + if (sharedPreferences.getBoolean(HAS_ONBOARDED, false)) { + refreshScheduler.scheduleDailyRefresh() + } + } + private fun createNotificationChannel() { if (SDK_INT >= O) { val importance = NotificationManager.IMPORTANCE_DEFAULT diff --git a/app/src/main/kotlin/com/ratik/uttam/bg/RefreshWallpaperWorker.kt b/app/src/main/kotlin/com/ratik/uttam/bg/RefreshWallpaperWorker.kt index 9d2e50e..82f02c2 100644 --- a/app/src/main/kotlin/com/ratik/uttam/bg/RefreshWallpaperWorker.kt +++ b/app/src/main/kotlin/com/ratik/uttam/bg/RefreshWallpaperWorker.kt @@ -4,37 +4,48 @@ import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import com.ratik.uttam.core.DispatcherProvider +import com.ratik.uttam.data.exceptions.UnauthorizedException import com.ratik.uttam.domain.PhotoRepo +import com.ratik.uttam.domain.UserRepo +import com.ratik.uttam.domain.WallpaperSetter import com.ratik.uttam.util.NotificationHelper import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first import timber.log.Timber @HiltWorker internal class RefreshWallpaperWorker @AssistedInject constructor( - @Assisted val appContext: Context, + @Assisted appContext: Context, @Assisted params: WorkerParameters, private val photoRepo: PhotoRepo, - private val dispatcherProvider: DispatcherProvider, + private val userRepo: UserRepo, + private val wallpaperSetter: WallpaperSetter, private val notificationHelper: NotificationHelper, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { - return withContext(dispatcherProvider.io) { - try { - var workResult = Result.failure() - photoRepo.fetchRandomPhoto().collect { photo -> - notificationHelper.pushNewWallpaperNotification(appContext, photo) - workResult = Result.success() - } - workResult - } catch (exception: Exception) { - Timber.d("RATIK: Photo fetch error.") - Timber.e(exception.message) - Result.failure() + return try { + val photo = photoRepo.fetchRandomPhoto().first() + if (userRepo.shouldSetWallpaperAutomatically()) { + wallpaperSetter.setHomeScreen(photo.rawPhotoUri) + .onFailure { error -> Timber.w(error, "Unable to set the refreshed wallpaper") } } + notificationHelper.pushNewWallpaperNotification(applicationContext, photo) + Result.success() + } catch (exception: CancellationException) { + throw exception + } catch (exception: UnauthorizedException) { + Timber.e(exception, "Unsplash rejected the wallpaper refresh") + Result.failure() + } catch (exception: Exception) { + Timber.e(exception, "Wallpaper refresh failed") + if (runAttemptCount < MAX_RETRY_ATTEMPTS) Result.retry() else Result.failure() } } + + private companion object { + const val MAX_RETRY_ATTEMPTS = 3 + } } diff --git a/app/src/main/kotlin/com/ratik/uttam/bg/WallpaperRefreshScheduler.kt b/app/src/main/kotlin/com/ratik/uttam/bg/WallpaperRefreshScheduler.kt new file mode 100644 index 0000000..ab05b1d --- /dev/null +++ b/app/src/main/kotlin/com/ratik/uttam/bg/WallpaperRefreshScheduler.kt @@ -0,0 +1,64 @@ +package com.ratik.uttam.bg + +import android.content.Context +import android.content.SharedPreferences +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.Calendar +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WallpaperRefreshScheduler @Inject constructor( + private val context: Context, + private val sharedPreferences: SharedPreferences, +) { + fun scheduleDailyRefresh() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val workRequest = + PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .setInitialDelay(calculateDelayUntilMorning(), TimeUnit.MILLISECONDS) + .build() + + val policy = + if (sharedPreferences.getInt(SCHEDULE_VERSION_KEY, 0) < SCHEDULE_VERSION) { + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE + } else { + ExistingPeriodicWorkPolicy.UPDATE + } + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + policy, + workRequest, + ) + sharedPreferences.edit().putInt(SCHEDULE_VERSION_KEY, SCHEDULE_VERSION).apply() + } + + private fun calculateDelayUntilMorning(): Long { + val now = Calendar.getInstance() + val nextRun = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, REFRESH_HOUR) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + if (!after(now)) add(Calendar.DAY_OF_YEAR, 1) + } + return nextRun.timeInMillis - now.timeInMillis + } + + private companion object { + const val UNIQUE_WORK_NAME = "RatikUttamRefresh" + const val REFRESH_HOUR = 7 + const val SCHEDULE_VERSION_KEY = "wallpaperRefreshScheduleVersion" + const val SCHEDULE_VERSION = 1 + } +} diff --git a/app/src/main/kotlin/com/ratik/uttam/data/dao/PhotoDao.kt b/app/src/main/kotlin/com/ratik/uttam/data/dao/PhotoDao.kt index 4004a4c..cd59d2b 100644 --- a/app/src/main/kotlin/com/ratik/uttam/data/dao/PhotoDao.kt +++ b/app/src/main/kotlin/com/ratik/uttam/data/dao/PhotoDao.kt @@ -3,6 +3,10 @@ package com.ratik.uttam.data.dao import android.content.SharedPreferences import com.ratik.uttam.domain.model.Photo import com.ratik.uttam.domain.model.Photographer +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged import javax.inject.Inject // TODO: Clean up this class after Kotlin port is complete @@ -21,6 +25,15 @@ class PhotoDao @Inject constructor(private val sharedPreferences: SharedPreferen check(editor.commit()) { "Could not persist wallpaper details" } } + fun observePhoto(): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key in PHOTO_KEYS) trySend(getPhoto()) + } + sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + trySend(getPhoto()) + awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) } + }.distinctUntilChanged() + fun getPhoto(): Photo? { val id = sharedPreferences.getString("id", "") val rawPhotoUri = sharedPreferences.getString("rawPhotoUri", "") @@ -48,4 +61,17 @@ class PhotoDao @Inject constructor(private val sharedPreferences: SharedPreferen ) } } + + private companion object { + val PHOTO_KEYS = setOf( + "id", + "rawPhotoUri", + "regularPhotoUri", + "thumbPhotoUri", + "shareUrl", + "photographerName", + "photographerUsername", + "photographerProfileUrl", + ) + } } diff --git a/app/src/main/kotlin/com/ratik/uttam/domain/PhotoRepo.kt b/app/src/main/kotlin/com/ratik/uttam/domain/PhotoRepo.kt index 9d861ff..06ce0a0 100644 --- a/app/src/main/kotlin/com/ratik/uttam/domain/PhotoRepo.kt +++ b/app/src/main/kotlin/com/ratik/uttam/domain/PhotoRepo.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import timber.log.Timber import java.io.File import java.util.UUID @@ -172,15 +173,11 @@ internal class PhotoRepo @Inject constructor( ) } - suspend fun getCurrentPhoto(): Flow = - flow { - val photo = photoDao.getPhoto() - if (photo != null && photo.filesExist()) { - emit(photo) - } else { - throw PhotoNotFoundException() + fun getCurrentPhoto(): Flow = + photoDao.observePhoto() + .map { photo -> + if (photo != null && photo.filesExist()) photo else throw PhotoNotFoundException() } - } .flowOn(dispatcherProvider.io) private fun Photo.filesExist(): Boolean = diff --git a/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingScreen.kt index 2faf898..7af560b 100644 --- a/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingScreen.kt @@ -42,13 +42,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberPermissionState import com.ratik.uttam.R -import com.ratik.uttam.bg.RefreshWallpaperWorker import com.ratik.uttam.core.Ignored import com.ratik.uttam.core.contract.ViewEvent.Navigate import com.ratik.uttam.ui.components.UttamText @@ -77,9 +73,6 @@ import com.ratik.uttam.ui.theme.Dimens.SpacingXXXXXSmall import com.ratik.uttam.ui.theme.OnboardingBackground import com.ratik.uttam.ui.theme.SetSystemBarColors import kotlinx.coroutines.launch -import java.util.Calendar -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeUnit.MILLISECONDS @SuppressLint("InlinedApi") @Composable @@ -151,8 +144,6 @@ internal fun OnboardingScreen( .align(CenterEnd) .padding(vertical = SpacingXXSmall) .clickable { - enqueueDailyRefreshRequest(context) - viewModel.onViewAction( FinishOnboarding( deviceHeight = displayMetrics.heightPixels, @@ -252,39 +243,3 @@ private fun getOnboardingMessage(context: Context, onboardingStep: OnboardingSte DONE -> context.getString(R.string.all_done_text) } } - -private fun enqueueDailyRefreshRequest(context: Context) { - val repeatInterval = TimeUnit.HOURS.toMillis(2) - val initialDelay = TimeUnit.HOURS.toMillis(2) - - val workRequest = - PeriodicWorkRequest.Builder(RefreshWallpaperWorker::class.java, repeatInterval, MILLISECONDS) - .setInitialDelay(initialDelay, MILLISECONDS) - .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - "RatikUttamRefresh", - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - workRequest, - ) -} - -private fun calculateInitialDelayToMorning(): Long { - val now = Calendar.getInstance() - - // Set the target time to 7 AM today - val target = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 7) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - // If the target time is before now, set it to 7 AM tomorrow - if (target.before(now)) { - target.add(Calendar.DAY_OF_YEAR, 1) - } - - // Calculate the delay in milliseconds - return target.timeInMillis - now.timeInMillis -} diff --git a/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingViewModel.kt index 35aefbd..2d745de 100644 --- a/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/com/ratik/uttam/ui/feature/onboarding/OnboardingViewModel.kt @@ -1,5 +1,6 @@ package com.ratik.uttam.ui.feature.onboarding +import com.ratik.uttam.bg.WallpaperRefreshScheduler import com.ratik.uttam.core.BaseViewModel import com.ratik.uttam.core.DispatcherProvider import com.ratik.uttam.core.Ignored @@ -21,6 +22,7 @@ internal class OnboardingViewModel @Inject constructor( dispatcherProvider: DispatcherProvider, private val userRepo: UserRepo, private val photoRepo: PhotoRepo, + private val refreshScheduler: WallpaperRefreshScheduler, ) : BaseViewModel( OnboardingState.initialState, @@ -51,6 +53,7 @@ internal class OnboardingViewModel @Inject constructor( onStart = { updateState { currentState -> currentState.copy(isLoading = true) } }, onEach = { userRepo.setHasOnboarded() + refreshScheduler.scheduleDailyRefresh() dispatchViewEvent(Navigate(Home)) }, onError = { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3dd945..12ba7ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ hilt = "2.60.1" ksp = "2.3.7" # Android X -work-manager = "2.9.1" +work-manager = "2.11.2" hilt-work = "1.3.0" splashscreen = "1.0.1"