refactor: Prune cached media using a worker (#529)

Previous code would prune any cached media every time `MainActivity` was
created, causing extra IO just as the user is trying to use the app.

Re-implement as a WorkManager worker so it can run when the device is
idle, without interfering with the responsiveness of the device.
This commit is contained in:
Nik Clayton 2024-03-14 13:31:18 +01:00 committed by GitHub
parent 6f8a3f20a9
commit d66d9d32a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 90 additions and 40 deletions

View File

@ -114,7 +114,6 @@ import app.pachli.pager.MainPagerAdapter
import app.pachli.updatecheck.UpdateCheck
import app.pachli.usecase.DeveloperToolsUseCase
import app.pachli.usecase.LogoutUsecase
import app.pachli.util.deleteStaleCachedMedia
import app.pachli.util.getDimension
import app.pachli.util.unsafeLazy
import app.pachli.util.updateShortcut
@ -383,11 +382,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
}
}
externalScope.launch {
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Pachli"))
}
selectedEmojiPack = sharedPreferencesRepository.getString(EMOJI_PREFERENCE, "")
onBackPressedDispatcher.addCallback(

View File

@ -37,6 +37,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.util.LocaleManager
import app.pachli.util.setAppNightMode
import app.pachli.worker.PruneCacheWorker
import app.pachli.worker.PruneCachedMediaWorker
import app.pachli.worker.PruneLogEntryEntityWorker
import dagger.hilt.android.HiltAndroidApp
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
@ -133,6 +134,16 @@ class PachliApplication : Application() {
ExistingPeriodicWorkPolicy.KEEP,
pruneLogEntryEntityWorker,
)
// Delete old cached media every ~ 12 hours when the device is idle
val pruneCachedMediaWorker = PeriodicWorkRequestBuilder<PruneCachedMediaWorker>(12, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
.build()
workManager.enqueueUniquePeriodicWork(
PruneCachedMediaWorker.PERIODIC_WORK_TAG,
ExistingPeriodicWorkPolicy.KEEP,
pruneCachedMediaWorker,
)
}
private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) {

View File

@ -24,22 +24,18 @@ import android.graphics.Matrix
import android.net.Uri
import android.provider.OpenableColumns
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* Helper methods for obtaining and resizing media files
*/
private const val MEDIA_TEMP_PREFIX = "Pachli_Share_Media"
const val MEDIA_TEMP_PREFIX = "Pachli_Share_Media"
const val MEDIA_SIZE_UNKNOWN = -1L
/**
@ -167,35 +163,6 @@ fun getImageOrientation(uri: Uri, contentResolver: ContentResolver): Int {
}
}
suspend fun deleteStaleCachedMedia(mediaDirectory: File?) = withContext(Dispatchers.IO) {
if (mediaDirectory == null || !mediaDirectory.exists()) {
// Nothing to do
return@withContext
}
val twentyfourHoursAgo = Calendar.getInstance()
twentyfourHoursAgo.add(Calendar.HOUR, -24)
val unixTime = twentyfourHoursAgo.timeInMillis
val files = mediaDirectory.listFiles { file ->
unixTime > file.lastModified() && file.name.contains(
MEDIA_TEMP_PREFIX,
)
}
if (files == null || files.isEmpty()) {
// Nothing to do
return@withContext
}
for (file in files) {
try {
file.delete()
} catch (se: SecurityException) {
Timber.e("Error removing stale cached media")
}
}
}
fun getTemporaryMediaFilename(extension: String): String {
return "${MEDIA_TEMP_PREFIX}_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.$extension"
}

View File

@ -0,0 +1,78 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.worker
import android.app.Notification
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import app.pachli.R
import app.pachli.components.notifications.NOTIFICATION_ID_PRUNE_CACHE
import app.pachli.components.notifications.createWorkerNotification
import app.pachli.util.MEDIA_TEMP_PREFIX
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.time.Instant
import kotlin.time.Duration.Companion.hours
import timber.log.Timber
/** Prune old media that was cached for sharing */
@HiltWorker
class PruneCachedMediaWorker @AssistedInject constructor(
@Assisted val appContext: Context,
@Assisted workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
val notification: Notification = createWorkerNotification(applicationContext, R.string.notification_prune_cache)
override suspend fun doWork(): Result {
val mediaDirectory = appContext.getExternalFilesDir("Pachli")
if (mediaDirectory == null) {
Timber.d("Skipping prune, shared storage is not available")
return Result.success()
}
if (!mediaDirectory.exists()) {
Timber.d("Skipping prune, media directory does not exist: %s", mediaDirectory.absolutePath)
return Result.success()
}
val now = Instant.now()
val cutoffInMs = now.minusSeconds(OLDEST_ENTRY.inWholeSeconds).toEpochMilli()
mediaDirectory.listFiles { file ->
file.lastModified() < cutoffInMs && file.name.startsWith(MEDIA_TEMP_PREFIX)
}?.forEach { file ->
try {
file.delete()
} catch (se: SecurityException) {
Timber.e(se, "Error removing %s", file.name)
}
}
return Result.success()
}
override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification)
companion object {
private val OLDEST_ENTRY = 24.hours
const val PERIODIC_WORK_TAG = "PruneCachedMediaWorker"
}
}