diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed3b93dcf1..c2a85aacdd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1738,6 +1738,10 @@ android:name=".receivers.UrlShortenerActionReceiver" android:exported="false" /> + + = Build.VERSION_CODES.R }, object : Feature( id = "Lockdown mode", diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index c6cbab35d1..10d21f9f7d 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -199,9 +199,10 @@ fun initPermissionRegistry() { // AOD Wallpaper feature PermissionRegistry.register("STORAGE", R.string.feat_aod_wallpaper_title) - // Screenshot share cleanup feature - PermissionRegistry.register("ACCESSIBILITY", R.string.feat_screenshot_share_cleanup_title) - PermissionRegistry.register("STORAGE", R.string.feat_screenshot_share_cleanup_title) + // Screenshots feature + PermissionRegistry.register("ACCESSIBILITY", R.string.feat_screenshots_title) + PermissionRegistry.register("STORAGE", R.string.feat_screenshots_title) + PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_screenshots_title) // Pixel Search features PermissionRegistry.register("STORAGE", R.string.pixel_search_results_files_title) diff --git a/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt b/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt new file mode 100644 index 0000000000..249d7976ef --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: ScreenshotCleanupActionReceiver.kt + * Description: Handles the Keep and Trash now actions on the screenshot cleanup notification. + */ + +package com.sameerasw.essentials.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.widget.Toast +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ScreenshotCleanupActionReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + val mediaId = intent.getLongExtra(EXTRA_MEDIA_ID, -1L) + if (mediaId < 0) return + val days = intent.getIntExtra(EXTRA_DAYS, 7) + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + when (intent.action) { + ACTION_KEEP -> { + ScreenshotCleaner.cancel(context, mediaId) + ScreenshotCleanupNotifier.showResult(context, mediaId, R.string.screenshot_kept, days, NOT_TRASHED) + } + + ACTION_TRASH_NOW -> { + val size = ScreenshotCleaner.trashSized(context, mediaId, keepStarred = false) + if (size == null) { + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.screenshot_trash_failed, Toast.LENGTH_SHORT).show() + } + } else { + ScreenshotCleaner.cancel(context, mediaId) + ScreenshotCleanupNotifier.showResult(context, mediaId, R.string.screenshot_moved_to_trash, days, size) + } + } + + ACTION_UNDO -> { + val trashedBytes = intent.getLongExtra(EXTRA_TRASHED_BYTES, NOT_TRASHED) + val restored = trashedBytes == NOT_TRASHED || ScreenshotCleaner.restore(context, mediaId, trashedBytes) + if (restored && SettingsRepository(context).isScreenshotAutoCleanEnabled()) { + ScreenshotCleaner.schedule(context, mediaId, days * MINUTES_PER_DAY, ScreenshotCleaner.TAG_AUTO_CLEAN) + ScreenshotCleanupNotifier.show(context, mediaId, days) + } else { + ScreenshotCleanupNotifier.dismiss(context, mediaId) + } + } + } + } finally { + pending.finish() + } + } + } + + companion object { + const val ACTION_KEEP = "com.sameerasw.essentials.action.KEEP_SCREENSHOT" + const val ACTION_TRASH_NOW = "com.sameerasw.essentials.action.TRASH_SCREENSHOT" + const val ACTION_UNDO = "com.sameerasw.essentials.action.UNDO_SCREENSHOT_ACTION" + const val EXTRA_MEDIA_ID = "extra_media_id" + const val EXTRA_DAYS = "extra_days" + const val EXTRA_TRASHED_BYTES = "extra_trashed_bytes" + private const val NOT_TRASHED = -1L + private const val MINUTES_PER_DAY = 24 * 60L + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt b/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt new file mode 100644 index 0000000000..bb7a0f6bfe --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: ScreenshotTrashWorker.kt + * Description: Moves a scheduled screenshot to the trash when its cleanup delay runs out. + */ + +package com.sameerasw.essentials.services + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier + +class ScreenshotTrashWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val mediaId = inputData.getLong(KEY_MEDIA_ID, -1L) + if (mediaId < 0) return Result.failure() + ScreenshotCleaner.trash(applicationContext, mediaId, inputData.getBoolean(KEY_KEEP_STARRED, true)) + ScreenshotCleanupNotifier.dismiss(applicationContext, mediaId) + return Result.success() + } + + companion object { + const val KEY_MEDIA_ID = "media_id" + const val KEY_KEEP_STARRED = "keep_starred" + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt new file mode 100644 index 0000000000..73b889a1a6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: ScreenshotAutoCleanHandler.kt + * Description: Schedules every new screenshot for the trash and posts a notification to keep it. + */ + +package com.sameerasw.essentials.services.handlers + +import android.content.Context +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ScreenshotAutoCleanHandler( + private val context: Context, + private val scope: CoroutineScope, +) { + private val settings = SettingsRepository(context) + private val mutex = Mutex() + + private val handler = Handler(Looper.getMainLooper()) + + private val scan = Runnable { scope.launch(Dispatchers.IO) { scheduleNewScreenshots() } } + + private val observer = + object : ContentObserver(handler) { + override fun onChange(selfChange: Boolean) { + handler.removeCallbacks(scan) + handler.postDelayed(scan, SCAN_DEBOUNCE_MS) + } + } + + fun register() { + handler.post(scan) + context.contentResolver.registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, observer) + } + + fun unregister() { + handler.removeCallbacks(scan) + context.contentResolver.unregisterContentObserver(observer) + } + + private suspend fun scheduleNewScreenshots() { + if (!settings.isScreenshotAutoCleanEnabled()) return + mutex.withLock { + val days = settings.getScreenshotAutoCleanDays() + val since = System.currentTimeMillis() / 1000 - NEW_SCREENSHOT_WINDOW_SECONDS + ScreenshotCleaner.screenshotIdsAddedSince(context, settings.getScreenshotAutoCleanLastId(), since).forEach { id -> + ScreenshotCleaner.schedule(context, id, days * MINUTES_PER_DAY, ScreenshotCleaner.TAG_AUTO_CLEAN) + ScreenshotCleanupNotifier.show(context, id, days) + settings.setScreenshotAutoCleanLastId(id) + } + } + } + + companion object { + private const val MINUTES_PER_DAY = 24 * 60L + private const val NEW_SCREENSHOT_WINDOW_SECONDS = 6 * 60 * 60L + private const val SCAN_DEBOUNCE_MS = 1_000L + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt index e48aaccb9b..9107cbe3f8 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt @@ -4,23 +4,20 @@ * * Feature Module: Background Services & Receivers * File: ScreenshotShareCleanupHandler.kt - * Description: Moves a screenshot to the trash after it is shared from the screenshot preview. + * Description: Moves a screenshot to the trash after it is shared from the screenshot preview, right away or after a delay. */ package com.sameerasw.essentials.services.handlers import android.accessibilityservice.AccessibilityService import android.content.ComponentName -import android.content.ContentUris -import android.content.ContentValues import android.content.res.Resources -import android.net.Uri -import android.os.Environment -import android.provider.MediaStore import android.view.accessibility.AccessibilityEvent import android.widget.Toast import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier import com.sameerasw.essentials.utils.ScreenshotShareTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -34,7 +31,7 @@ class ScreenshotShareCleanupHandler( ) { private val settings = SettingsRepository(service) private val tracker by lazy { ScreenshotShareTracker(SHARE_SHEET, systemImageEditorPackage() ?: MARKUP) } - private var screenshotUri: Uri? = null + private var screenshotId: Long? = null private val shareDescription by lazy { systemUiString("screenshot_share_description") ?: "Share screenshot" } @@ -46,8 +43,8 @@ class ScreenshotShareCleanupHandler( if (event.packageName == SYSTEM_UI && event.contentDescription?.startsWith(shareDescription) == true) { val now = System.currentTimeMillis() tracker.onShareTapped(now, foregroundPackage) - screenshotUri = null - scope.launch(Dispatchers.IO) { screenshotUri = findScreenshot(now) } + screenshotId = null + scope.launch(Dispatchers.IO) { screenshotId = findScreenshot(now) } } } @@ -55,53 +52,41 @@ class ScreenshotShareCleanupHandler( if (packageName == null) return val sharedAt = tracker.sharedAt if (tracker.onForegroundPackage(packageName, System.currentTimeMillis())) { - trashScreenshot(screenshotUri, sharedAt) + trashScreenshot(screenshotId, sharedAt) } } fun onScreenOff() = tracker.onScreenOff() private fun trashScreenshot( - capturedUri: Uri?, + capturedId: Long?, sharedAt: Long, ) { scope.launch { - delay(TRASH_DELAY_MS) - val trashed = + val delayMinutes = settings.getScreenshotShareCleanupDelayMinutes() + if (delayMinutes <= 0) delay(TRASH_DELAY_MS) + val outcome = withContext(Dispatchers.IO) { - val uri = capturedUri ?: findScreenshot(sharedAt) ?: return@withContext false - runCatching { - val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_TRASHED, 1) } - service.contentResolver.update(uri, values, null) > 0 - }.getOrDefault(false) + val mediaId = capturedId ?: findScreenshot(sharedAt) ?: return@withContext null + ScreenshotCleanupNotifier.dismiss(service, mediaId) + if (delayMinutes > 0) { + ScreenshotCleaner.schedule(service, mediaId, delayMinutes.toLong(), ScreenshotCleaner.TAG_SHARED) + return@withContext R.string.screenshot_share_cleanup_scheduled + } + ScreenshotCleaner.cancel(service, mediaId) + if (ScreenshotCleaner.trash(service, mediaId, keepStarred = false)) R.string.screenshot_share_cleanup_trashed else null } - if (trashed) { - Toast.makeText(service, R.string.screenshot_share_cleanup_trashed, Toast.LENGTH_SHORT).show() - } + outcome?.let { Toast.makeText(service, it, Toast.LENGTH_SHORT).show() } } } - private fun findScreenshot(sharedAt: Long): Uri? { - val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DATE_ADDED} BETWEEN ? AND ?" + private fun findScreenshot(sharedAt: Long): Long? { val sharedAtSeconds = sharedAt / 1000 - val args = - arrayOf( - SCREENSHOTS_PATH, - (sharedAtSeconds - SCREENSHOT_LOOKBACK_SECONDS).toString(), - (sharedAtSeconds + SCREENSHOT_LOOKAHEAD_SECONDS).toString(), - ) - return service.contentResolver - .query( - collection, - arrayOf(MediaStore.MediaColumns._ID), - selection, - args, - "${MediaStore.MediaColumns.DATE_ADDED} DESC", - )?.use { cursor -> - if (cursor.moveToFirst()) ContentUris.withAppendedId(collection, cursor.getLong(0)) else null - } + return ScreenshotCleaner.findScreenshotAddedBetween( + service, + sharedAtSeconds - SCREENSHOT_LOOKBACK_SECONDS, + sharedAtSeconds + SCREENSHOT_LOOKAHEAD_SECONDS, + ) } private fun systemUiString(name: String): String? = @@ -122,7 +107,6 @@ class ScreenshotShareCleanupHandler( private const val SYSTEM_UI = "com.android.systemui" private const val SHARE_SHEET = "com.android.intentresolver" private const val MARKUP = "com.google.android.markup" - private val SCREENSHOTS_PATH = "${Environment.DIRECTORY_PICTURES}/${Environment.DIRECTORY_SCREENSHOTS}/" private const val SCREENSHOT_LOOKBACK_SECONDS = 120L private const val SCREENSHOT_LOOKAHEAD_SECONDS = 5L private const val TRASH_DELAY_MS = 60_000L diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index e7b75d4860..2f0ef76e36 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -46,6 +46,7 @@ import com.sameerasw.essentials.services.handlers.NotificationLightingHandler import com.sameerasw.essentials.services.handlers.OmniGestureOverlayHandler import com.sameerasw.essentials.services.handlers.FaceUnlockBrightnessHandler import com.sameerasw.essentials.services.handlers.PocketModeHandler +import com.sameerasw.essentials.services.handlers.ScreenshotAutoCleanHandler import com.sameerasw.essentials.services.handlers.ScreenshotShareCleanupHandler import com.sameerasw.essentials.services.handlers.AppVolumeOverlayHandler import com.sameerasw.essentials.services.handlers.StatusBarIconHandler @@ -90,6 +91,7 @@ class ScreenOffAccessibilityService : lateinit var islandOverlayHandler: IslandCoordinator private lateinit var statusGlanceHandler: StatusGlanceHandler private lateinit var screenshotShareCleanupHandler: ScreenshotShareCleanupHandler + private lateinit var screenshotAutoCleanHandler: ScreenshotAutoCleanHandler private lateinit var lockscreenShortcutsHandler: LockscreenShortcutsHandler private lateinit var appVolumeOverlayHandler: AppVolumeOverlayHandler @@ -343,9 +345,11 @@ class ScreenOffAccessibilityService : lockscreenShortcutsHandler = LockscreenShortcutsHandler(this, flashlightHandler) appVolumeOverlayHandler = AppVolumeOverlayHandler(this) screenshotShareCleanupHandler = ScreenshotShareCleanupHandler(this, serviceScope) + screenshotAutoCleanHandler = ScreenshotAutoCleanHandler(this, serviceScope) flashlightHandler.register() statusBarIconHandler.register() + screenshotAutoCleanHandler.register() smartPixelsHandler.init() duoOverlayHandler.init() statusGlanceHandler.init() @@ -562,6 +566,7 @@ class ScreenOffAccessibilityService : lockscreenShortcutsHandler.onDestroy() appVolumeOverlayHandler.onDestroy() statusBarIconHandler.unregister() + screenshotAutoCleanHandler.unregister() stopInputEventListener() cancelPocketFlashlightTurnOff() if (isProximityRegistered) { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 9df16ed07c..062292c610 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -67,6 +67,7 @@ import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.features.battery.BatteriesSettingsUI import com.sameerasw.essentials.ui.features.consciousgate.CONSCIOUS_GATE_FEATURE_ID import com.sameerasw.essentials.ui.features.security.AppLockSettingsUI +import com.sameerasw.essentials.ui.features.security.ScreenshotsSettingsUI import com.sameerasw.essentials.ui.features.system.AlwaysOnDisplaySettingsUI import com.sameerasw.essentials.ui.features.system.BACK_GESTURE_FEATURE_ID import com.sameerasw.essentials.ui.features.system.BackGestureSettingsUI @@ -381,6 +382,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Notification Sync" -> !isNotificationListenerEnabled "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled + "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted @@ -785,7 +787,7 @@ class FeatureSettingsActivity : AppCompatActivity() { listOf( "Lockdown mode", "Face unlock brightness", - "Screenshot share cleanup", + "Screenshots", ), ) @@ -840,6 +842,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Hilight", "Hilight notifications", "Hilight live progress" -> !isNotificationListenerEnabled || !isShizukuPermissionGranted "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled + "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted @@ -1175,6 +1178,14 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } + "Screenshots" -> { + ScreenshotsSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + highlightSetting = highlightSetting, + ) + } + "Pocket mode" -> { PocketModeSettingsUI( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt new file mode 100644 index 0000000000..3dca043f9a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Security + * File: ScreenshotsSettingsUI.kt + * Description: Settings for cleaning up shared and old screenshots. + */ + +package com.sameerasw.essentials.ui.features.security + +import android.text.format.Formatter +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.ui.modifiers.highlight +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUIHelper +import com.sameerasw.essentials.viewmodels.MainViewModel + +private val SHARE_DELAY_MINUTES = listOf(0, 60, 24 * 60) +private val AUTO_CLEAN_DAYS = listOf(1, 7, 30) + +@Composable +fun ScreenshotsSettingsUI( + viewModel: MainViewModel, + modifier: Modifier = Modifier, + highlightSetting: String? = null, +) { + val context = LocalContext.current + val view = LocalView.current + var requestingPermissionsFor by remember { mutableStateOf?>(null) } + + fun missingPermissions(needsNotifications: Boolean): List = + buildList { + if (!viewModel.isAccessibilityEnabled.value) add("ACCESSIBILITY") + if (!viewModel.isStoragePermissionGranted.value) add("STORAGE") + if (needsNotifications && !viewModel.isPostNotificationsEnabled.value) add("POST_NOTIFICATIONS") + } + + requestingPermissionsFor?.let { permKeys -> + PermissionsBottomSheet( + onDismissRequest = { + requestingPermissionsFor = null + viewModel.check(context) + }, + featureTitle = stringResource(R.string.feat_screenshots_title), + permissions = PermissionUIHelper.getPermissionItems(permKeys, context, viewModel), + ) + } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + ) { + SectionTitle(R.string.screenshots_section_after_sharing) + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_share_24, + title = stringResource(R.string.feat_screenshot_share_cleanup_title), + description = stringResource(R.string.feat_screenshot_share_cleanup_desc), + isChecked = viewModel.isScreenshotShareCleanupEnabled.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val missing = missingPermissions(needsNotifications = false) + if (checked && missing.isNotEmpty()) { + requestingPermissionsFor = missing + } else { + viewModel.setScreenshotShareCleanupEnabled(checked) + } + }, + modifier = Modifier.highlight(highlightSetting == "screenshot_share_cleanup_enabled"), + ) + + AnimatedVisibility( + visible = viewModel.isScreenshotShareCleanupEnabled.value, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PickerRow( + title = R.string.screenshot_share_cleanup_delay_title, + highlighted = highlightSetting == "screenshot_share_cleanup_delay", + ) { + SegmentedPicker( + items = SHARE_DELAY_MINUTES, + selectedItem = viewModel.screenshotShareCleanupDelayMinutes.intValue, + onItemSelected = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setScreenshotShareCleanupDelayMinutes(it) + }, + labelProvider = { minutes -> + when (minutes) { + 0 -> context.getString(R.string.screenshot_delay_immediately) + 60 -> context.getString(R.string.screenshot_delay_1_hour) + else -> context.getString(R.string.screenshot_delay_1_day) + } + }, + title = R.string.screenshot_share_cleanup_delay_title, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + SectionTitle(R.string.screenshots_section_auto_clean) + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_timer_24, + title = stringResource(R.string.screenshot_auto_clean_title), + description = stringResource(R.string.screenshot_auto_clean_note), + isChecked = viewModel.isScreenshotAutoCleanEnabled.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val missing = missingPermissions(needsNotifications = true) + if (checked && missing.isNotEmpty()) { + requestingPermissionsFor = missing + } else { + viewModel.setScreenshotAutoCleanEnabled(checked) + } + }, + modifier = Modifier.highlight(highlightSetting == "screenshot_auto_clean_enabled"), + ) + + AnimatedVisibility( + visible = viewModel.isScreenshotAutoCleanEnabled.value, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PickerRow( + title = R.string.screenshot_auto_clean_days_title, + highlighted = highlightSetting == "screenshot_auto_clean_days", + ) { + SegmentedPicker( + items = AUTO_CLEAN_DAYS, + selectedItem = viewModel.screenshotAutoCleanDays.intValue, + onItemSelected = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setScreenshotAutoCleanDays(it) + }, + labelProvider = { days -> context.resources.getQuantityString(R.plurals.screenshot_days, days, days) }, + title = R.string.screenshot_auto_clean_days_title, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + SectionTitle(R.string.screenshots_section_stats) + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_delete_24, + title = stringResource(R.string.screenshots_cleaned_count_title), + description = viewModel.screenshotsCleanedCount.intValue.toString(), + showToggle = false, + ) + IconToggleItem( + iconRes = R.drawable.rounded_save_24, + title = stringResource(R.string.screenshots_storage_saved_title), + description = Formatter.formatShortFileSize(context, viewModel.screenshotsCleanedBytes.longValue), + showToggle = false, + ) + } + } +} + +@Composable +private fun SectionTitle(title: Int) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), + ) +} + +@Composable +private fun PickerRow( + title: Int, + highlighted: Boolean, + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .padding(top = 12.dp) + .highlight(highlighted), + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + content() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt new file mode 100644 index 0000000000..a765759c9f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - Screenshots + * File: ScreenshotCleaner.kt + * Description: Finds screenshots in MediaStore, moves them to the trash and schedules delayed cleanups. + */ + +package com.sameerasw.essentials.utils + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.services.ScreenshotTrashWorker +import java.util.concurrent.TimeUnit + +object ScreenshotCleaner { + const val TAG_SHARED = "screenshot_cleanup_shared" + const val TAG_AUTO_CLEAN = "screenshot_cleanup_auto" + + private val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + private val SCREENSHOTS_PATH = "${Environment.DIRECTORY_PICTURES}/${Environment.DIRECTORY_SCREENSHOTS}/" + + fun findScreenshotAddedBetween( + context: Context, + fromSeconds: Long, + toSeconds: Long, + ): Long? = + context.contentResolver + .query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DATE_ADDED} BETWEEN ? AND ?", + arrayOf(SCREENSHOTS_PATH, fromSeconds.toString(), toSeconds.toString()), + "${MediaStore.MediaColumns.DATE_ADDED} DESC", + )?.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + + fun screenshotIdsAddedSince( + context: Context, + lastSeenId: Long, + sinceSeconds: Long, + ): List = + context.contentResolver + .query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns._ID} > ? AND ${MediaStore.MediaColumns.DATE_ADDED} >= ?", + arrayOf(SCREENSHOTS_PATH, lastSeenId.toString(), sinceSeconds.toString()), + "${MediaStore.MediaColumns._ID} ASC", + )?.use { cursor -> + buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } + } ?: emptyList() + + fun latestScreenshotId(context: Context): Long = + context.contentResolver + .query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ?", + arrayOf(SCREENSHOTS_PATH), + "${MediaStore.MediaColumns._ID} DESC", + )?.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else 0L } ?: 0L + + fun trash( + context: Context, + mediaId: Long, + keepStarred: Boolean = true, + ): Boolean = trashSized(context, mediaId, keepStarred) != null + + fun trashSized( + context: Context, + mediaId: Long, + keepStarred: Boolean = true, + ): Long? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + val uri = ContentUris.withAppendedId(collection, mediaId) + val size = + context.contentResolver + .query(uri, arrayOf(MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.IS_FAVORITE), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst() && !(keepStarred && cursor.getInt(1) == 1)) cursor.getLong(0) else null } + ?: return null + val trashed = + runCatching { + val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_TRASHED, 1) } + context.contentResolver.update(uri, values, null) > 0 + }.getOrDefault(false) + if (!trashed) return null + SettingsRepository(context).recordScreenshotCleaned(size) + return size + } + + fun restore( + context: Context, + mediaId: Long, + bytes: Long, + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + val uri = ContentUris.withAppendedId(collection, mediaId) + val extras = Bundle().apply { putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) } + val restored = + runCatching { + val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_TRASHED, 0) } + context.contentResolver.update(uri, values, extras) > 0 + }.getOrDefault(false) + if (restored && bytes > 0) SettingsRepository(context).recordScreenshotRestored(bytes) + return restored + } + + fun schedule( + context: Context, + mediaId: Long, + delayMinutes: Long, + tag: String, + ) { + val keepStarred = tag != TAG_SHARED + val request = + OneTimeWorkRequestBuilder() + .setInitialDelay(delayMinutes, TimeUnit.MINUTES) + .setInputData(workDataOf(ScreenshotTrashWorker.KEY_MEDIA_ID to mediaId, ScreenshotTrashWorker.KEY_KEEP_STARRED to keepStarred)) + .addTag(tag) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(workName(mediaId, tag), ExistingWorkPolicy.REPLACE, request) + } + + fun cancel( + context: Context, + mediaId: Long, + ) { + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(workName(mediaId, TAG_SHARED)) + workManager.cancelUniqueWork(workName(mediaId, TAG_AUTO_CLEAN)) + } + + fun cancelAll( + context: Context, + tag: String, + ) { + WorkManager.getInstance(context).cancelAllWorkByTag(tag) + } + + private fun workName( + mediaId: Long, + tag: String, + ) = "screenshot_trash_${tag}_$mediaId" +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt new file mode 100644 index 0000000000..21b54a84f5 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - Screenshots + * File: ScreenshotCleanupNotifier.kt + * Description: Quiet notification for a newly scheduled screenshot with Keep and Trash now actions. + */ + +package com.sameerasw.essentials.utils + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.os.Build +import android.provider.MediaStore +import android.util.Size +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.sameerasw.essentials.R +import com.sameerasw.essentials.receivers.ScreenshotCleanupActionReceiver + +object ScreenshotCleanupNotifier { + private const val CHANNEL_ID = "screenshot_cleanup" + private const val RESULT_CHANNEL_ID = "screenshot_cleanup_result" + private const val NOTIFICATION_TAG = "screenshot_cleanup" + private const val GROUP_KEY = "com.sameerasw.essentials.SCREENSHOT_CLEANUP" + private const val TIMEOUT_MILLIS = 60 * 60 * 1000L + private const val RESULT_TIMEOUT_MILLIS = 5_000L + + fun show( + context: Context, + mediaId: Long, + days: Int, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + createChannels(context) + + val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) + val openIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "image/*") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val thumbnail = loadThumbnail(context, mediaId) + + val builder = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.rounded_screenshot_region_24) + .setContentTitle(context.resources.getQuantityString(R.plurals.screenshot_auto_clean_notification_title, days, days)) + .setContentText(context.getString(R.string.screenshot_auto_clean_notification_text)) + .setContentIntent( + PendingIntent.getActivity(context, requestCode(mediaId, 0), openIntent, PendingIntent.FLAG_IMMUTABLE), + ).addAction( + R.drawable.rounded_bookmark_24, + context.getString(R.string.screenshot_action_keep), + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_KEEP, 1, days), + ).addAction( + R.drawable.rounded_delete_24, + context.getString(R.string.screenshot_action_trash_now), + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_TRASH_NOW, 2, days), + ).setSilent(true) + .setAutoCancel(true) + .setGroup(GROUP_KEY) + .setTimeoutAfter(TIMEOUT_MILLIS) + .setPriority(NotificationCompat.PRIORITY_LOW) + if (thumbnail != null) { + builder + .setLargeIcon(thumbnail) + .setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?)) + } + NotificationManagerCompat.from(context).notify(NOTIFICATION_TAG, mediaId.toInt(), builder.build()) + } + + fun showResult( + context: Context, + mediaId: Long, + message: Int, + days: Int, + trashedBytes: Long, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + createChannels(context) + val undo = + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_UNDO, 3, days) { putExtra(ScreenshotCleanupActionReceiver.EXTRA_TRASHED_BYTES, trashedBytes) } + val notification = + NotificationCompat + .Builder(context, RESULT_CHANNEL_ID) + .setSmallIcon(R.drawable.rounded_screenshot_region_24) + .setContentTitle(context.getString(message)) + .addAction(R.drawable.ic_undo, context.getString(R.string.screenshot_action_undo), undo) + .setSilent(true) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setTimeoutAfter(RESULT_TIMEOUT_MILLIS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + NotificationManagerCompat.from(context).notify(NOTIFICATION_TAG, mediaId.toInt(), notification) + } + + fun dismiss( + context: Context, + mediaId: Long, + ) { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_TAG, mediaId.toInt()) + } + + private fun createChannels(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.screenshot_cleanup_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) }, + ) + manager.createNotificationChannel( + NotificationChannel( + RESULT_CHANNEL_ID, + context.getString(R.string.screenshot_cleanup_result_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) }, + ) + } + + private fun actionIntent( + context: Context, + mediaId: Long, + action: String, + slot: Int, + days: Int, + extras: Intent.() -> Unit = {}, + ): PendingIntent { + val intent = + Intent(context, ScreenshotCleanupActionReceiver::class.java).apply { + this.action = action + putExtra(ScreenshotCleanupActionReceiver.EXTRA_MEDIA_ID, mediaId) + putExtra(ScreenshotCleanupActionReceiver.EXTRA_DAYS, days) + extras() + } + return PendingIntent.getBroadcast( + context, + requestCode(mediaId, slot), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun requestCode( + mediaId: Long, + slot: Int, + ) = (mediaId * 4 + slot).toInt() + + private fun loadThumbnail( + context: Context, + mediaId: Long, + ): Bitmap? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) + return runCatching { context.contentResolver.loadThumbnail(uri, Size(720, 720), null) }.getOrNull() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 70c165ecf7..f4d353b747 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -87,6 +87,7 @@ import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils +import com.sameerasw.essentials.utils.ScreenshotCleaner import com.sameerasw.essentials.utils.PrivilegedMode import com.sameerasw.essentials.utils.ShellUtils import com.sameerasw.essentials.utils.ShizukuUtils @@ -403,6 +404,11 @@ class MainViewModel : ViewModel() { val isPocketModeEnabled = mutableStateOf(false) val isFaceUnlockBrightnessEnabled = mutableStateOf(false) val isScreenshotShareCleanupEnabled = mutableStateOf(false) + val screenshotShareCleanupDelayMinutes = mutableIntStateOf(0) + val isScreenshotAutoCleanEnabled = mutableStateOf(false) + val screenshotAutoCleanDays = mutableIntStateOf(7) + val screenshotsCleanedCount = mutableIntStateOf(0) + val screenshotsCleanedBytes = mutableLongStateOf(0L) val faceUnlockMaxBrightness = mutableIntStateOf(100) val faceUnlockAmbientThreshold = mutableFloatStateOf(10f) val isFaceUnlockTriggerUnlock = mutableStateOf(true) @@ -1404,6 +1410,21 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_SCREENSHOT_SHARE_CLEANUP_ENABLED -> isScreenshotShareCleanupEnabled.value = settingsRepository.isScreenshotShareCleanupEnabled() + SettingsRepository.KEY_SCREENSHOT_SHARE_CLEANUP_DELAY -> + screenshotShareCleanupDelayMinutes.intValue = settingsRepository.getScreenshotShareCleanupDelayMinutes() + + SettingsRepository.KEY_SCREENSHOT_AUTO_CLEAN_ENABLED -> + isScreenshotAutoCleanEnabled.value = settingsRepository.isScreenshotAutoCleanEnabled() + + SettingsRepository.KEY_SCREENSHOT_AUTO_CLEAN_DAYS -> + screenshotAutoCleanDays.intValue = settingsRepository.getScreenshotAutoCleanDays() + + SettingsRepository.KEY_SCREENSHOTS_CLEANED_COUNT -> + screenshotsCleanedCount.intValue = settingsRepository.getScreenshotsCleanedCount() + + SettingsRepository.KEY_SCREENSHOTS_CLEANED_BYTES -> + screenshotsCleanedBytes.longValue = settingsRepository.getScreenshotsCleanedBytes() + SettingsRepository.KEY_FACE_UNLOCK_MAX_BRIGHTNESS -> faceUnlockMaxBrightness.intValue = settingsRepository.getFaceUnlockMaxBrightness() @@ -2848,6 +2869,11 @@ class MainViewModel : ViewModel() { settingsRepository.getBoolean(SettingsRepository.KEY_POCKET_MODE_ENABLED) isFaceUnlockBrightnessEnabled.value = settingsRepository.isFaceUnlockBrightnessEnabled() isScreenshotShareCleanupEnabled.value = settingsRepository.isScreenshotShareCleanupEnabled() + screenshotShareCleanupDelayMinutes.intValue = settingsRepository.getScreenshotShareCleanupDelayMinutes() + isScreenshotAutoCleanEnabled.value = settingsRepository.isScreenshotAutoCleanEnabled() + screenshotAutoCleanDays.intValue = settingsRepository.getScreenshotAutoCleanDays() + screenshotsCleanedCount.intValue = settingsRepository.getScreenshotsCleanedCount() + screenshotsCleanedBytes.longValue = settingsRepository.getScreenshotsCleanedBytes() faceUnlockMaxBrightness.intValue = settingsRepository.getFaceUnlockMaxBrightness() faceUnlockAmbientThreshold.floatValue = settingsRepository.getFaceUnlockAmbientThreshold() isFaceUnlockTriggerUnlock.value = settingsRepository.isFaceUnlockTriggerUnlockEnabled() @@ -9655,6 +9681,34 @@ class MainViewModel : ViewModel() { fun setScreenshotShareCleanupEnabled(enabled: Boolean) { settingsRepository.setScreenshotShareCleanupEnabled(enabled) isScreenshotShareCleanupEnabled.value = enabled + if (!enabled) appContext?.let { ScreenshotCleaner.cancelAll(it, ScreenshotCleaner.TAG_SHARED) } + } + + fun setScreenshotShareCleanupDelayMinutes(minutes: Int) { + settingsRepository.setScreenshotShareCleanupDelayMinutes(minutes) + screenshotShareCleanupDelayMinutes.intValue = minutes + } + + fun setScreenshotAutoCleanEnabled(enabled: Boolean) { + isScreenshotAutoCleanEnabled.value = enabled + val context = appContext + if (context == null) { + settingsRepository.setScreenshotAutoCleanEnabled(enabled) + return + } + viewModelScope.launch(Dispatchers.IO) { + if (enabled) { + settingsRepository.setScreenshotAutoCleanLastId(ScreenshotCleaner.latestScreenshotId(context)) + } else { + ScreenshotCleaner.cancelAll(context, ScreenshotCleaner.TAG_AUTO_CLEAN) + } + settingsRepository.setScreenshotAutoCleanEnabled(enabled) + } + } + + fun setScreenshotAutoCleanDays(days: Int) { + settingsRepository.setScreenshotAutoCleanDays(days) + screenshotAutoCleanDays.intValue = days } fun setFaceUnlockAmbientThreshold(value: Float) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9bf0029611..7bc5f12cda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3373,8 +3373,40 @@ Bump brightness in low light Delete shared screenshots Trash screenshots after sharing - After you share a screenshot from the screenshot preview, it is moved to the trash a minute after you leave the app you shared it to. Trashed screenshots can be restored from Files or Photos for 30 days. Shared screenshot moved to trash + Shared screenshot will be moved to trash later + Screenshots + Clean up shared and old screenshots + Keeps your gallery free of one-off screenshots. Shared screenshots can be trashed right after sharing or a little later, and every new screenshot can be trashed automatically after a few days unless you keep it from its notification or star it in Photos. Trashed screenshots can be restored from Files or Photos for 30 days. + After sharing + Auto-clean + Cleaned so far + Trash after sharing + Immediately + 1 hour + 1 day + Auto-clean screenshots + Trash new screenshots after + Each new screenshot gets a quiet notification to keep it. Starred screenshots are always kept. + Screenshots cleaned + Storage saved + Tap Keep or star it in Photos to save it + Keep + Trash now + Screenshot kept + Screenshot moved to trash + Couldn\'t move the screenshot to trash + Undo + Screenshot cleanup results + Screenshot cleanup + + %d day + %d days + + + Screenshot will be trashed in %d day + Screenshot will be trashed in %d days + Illuminate Ambient threshold Now: %1$s