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
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,10 @@
android:name=".receivers.UrlShortenerActionReceiver"
android:exported="false" />

<receiver
android:name=".receivers.ScreenshotCleanupActionReceiver"
android:exported="false" />

<service
android:name=".appfunctions.EssentialsAppFunctionService"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,12 @@ class SettingsRepository(
const val KEY_POCKET_MODE_ENABLED = "pocket_mode_enabled"
const val KEY_FACE_UNLOCK_BRIGHTNESS_ENABLED = "face_unlock_brightness_enabled"
const val KEY_SCREENSHOT_SHARE_CLEANUP_ENABLED = "screenshot_share_cleanup_enabled"
const val KEY_SCREENSHOT_SHARE_CLEANUP_DELAY = "screenshot_share_cleanup_delay"
const val KEY_SCREENSHOT_AUTO_CLEAN_ENABLED = "screenshot_auto_clean_enabled"
const val KEY_SCREENSHOT_AUTO_CLEAN_DAYS = "screenshot_auto_clean_days"
const val KEY_SCREENSHOT_AUTO_CLEAN_LAST_ID = "screenshot_auto_clean_last_id"
const val KEY_SCREENSHOTS_CLEANED_COUNT = "screenshots_cleaned_count"
const val KEY_SCREENSHOTS_CLEANED_BYTES = "screenshots_cleaned_bytes"
const val KEY_FACE_UNLOCK_MAX_BRIGHTNESS = "face_unlock_max_brightness"
const val KEY_FACE_UNLOCK_TRIGGER_UNLOCK = "face_unlock_trigger_unlock"
const val KEY_FACE_UNLOCK_AUTO_ILLUMINATE = "face_unlock_auto_illuminate"
Expand Down Expand Up @@ -4321,6 +4327,28 @@ class SettingsRepository(

fun isScreenshotShareCleanupEnabled(): Boolean = getBoolean(KEY_SCREENSHOT_SHARE_CLEANUP_ENABLED, false)
fun setScreenshotShareCleanupEnabled(enabled: Boolean) = putBoolean(KEY_SCREENSHOT_SHARE_CLEANUP_ENABLED, enabled)
fun getScreenshotShareCleanupDelayMinutes(): Int = getInt(KEY_SCREENSHOT_SHARE_CLEANUP_DELAY, 0)
fun setScreenshotShareCleanupDelayMinutes(minutes: Int) = putInt(KEY_SCREENSHOT_SHARE_CLEANUP_DELAY, minutes)
fun isScreenshotAutoCleanEnabled(): Boolean = getBoolean(KEY_SCREENSHOT_AUTO_CLEAN_ENABLED, false)
fun setScreenshotAutoCleanEnabled(enabled: Boolean) = putBoolean(KEY_SCREENSHOT_AUTO_CLEAN_ENABLED, enabled)
fun getScreenshotAutoCleanDays(): Int = getInt(KEY_SCREENSHOT_AUTO_CLEAN_DAYS, 7)
fun setScreenshotAutoCleanDays(days: Int) = putInt(KEY_SCREENSHOT_AUTO_CLEAN_DAYS, days)
fun getScreenshotAutoCleanLastId(): Long = getLong(KEY_SCREENSHOT_AUTO_CLEAN_LAST_ID, 0L)
fun setScreenshotAutoCleanLastId(id: Long) = putLong(KEY_SCREENSHOT_AUTO_CLEAN_LAST_ID, id)
fun getScreenshotsCleanedCount(): Int = getInt(KEY_SCREENSHOTS_CLEANED_COUNT, 0)
fun getScreenshotsCleanedBytes(): Long = getLong(KEY_SCREENSHOTS_CLEANED_BYTES, 0L)

fun recordScreenshotCleaned(bytes: Long) =
synchronized(SettingsRepository::class.java) {
putInt(KEY_SCREENSHOTS_CLEANED_COUNT, getScreenshotsCleanedCount() + 1)
putLong(KEY_SCREENSHOTS_CLEANED_BYTES, getScreenshotsCleanedBytes() + bytes)
}

fun recordScreenshotRestored(bytes: Long) =
synchronized(SettingsRepository::class.java) {
putInt(KEY_SCREENSHOTS_CLEANED_COUNT, (getScreenshotsCleanedCount() - 1).coerceAtLeast(0))
putLong(KEY_SCREENSHOTS_CLEANED_BYTES, (getScreenshotsCleanedBytes() - bytes).coerceAtLeast(0L))
}

fun getFaceUnlockMaxBrightness(): Int = getInt(KEY_FACE_UNLOCK_MAX_BRIGHTNESS, 100)
fun setFaceUnlockMaxBrightness(value: Int) = putInt(KEY_FACE_UNLOCK_MAX_BRIGHTNESS, value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.sameerasw.essentials.ui.features.display.ISLAND_PLACEMENT_FEATURE_ID
import com.sameerasw.essentials.ui.features.display.ISLAND_VISUALS_FEATURE_ID
import android.content.Context
import android.content.Intent
import android.os.Build
import com.sameerasw.essentials.EssentialsApp
import com.sameerasw.essentials.FeatureSettingsActivity
import com.sameerasw.essentials.R
Expand Down Expand Up @@ -2955,30 +2956,50 @@ object FeatureRegistry {
) = viewModel.setFaceUnlockBrightnessEnabled(enabled)
},
object : Feature(
id = "Screenshot share cleanup",
title = R.string.feat_screenshot_share_cleanup_title,
id = "Screenshots",
title = R.string.feat_screenshots_title,
iconRes = R.drawable.rounded_screenshot_region_24,
category = R.string.cat_tools,
description = R.string.feat_screenshot_share_cleanup_desc,
aboutDescription = R.string.about_desc_screenshot_share_cleanup,
description = R.string.feat_screenshots_desc,
aboutDescription = R.string.about_desc_screenshots,
permissionKeys = listOf("ACCESSIBILITY", "STORAGE"),
hasMoreSettings = false,
showToggle = false,
searchableSettings =
listOf(
SearchSetting(
R.string.feat_screenshot_share_cleanup_title,
R.string.feat_screenshots_title,
"screenshot_share_cleanup_enabled",
),
SearchSetting(
R.string.screenshot_share_cleanup_delay_title,
R.string.feat_screenshots_title,
"screenshot_share_cleanup_delay",
),
SearchSetting(
R.string.screenshot_auto_clean_title,
R.string.feat_screenshots_title,
"screenshot_auto_clean_enabled",
),
SearchSetting(
R.string.screenshot_auto_clean_days_title,
R.string.feat_screenshots_title,
"screenshot_auto_clean_days",
),
),
parentFeatureId = "Security",
) {
override fun isEnabled(viewModel: MainViewModel) = viewModel.isScreenshotShareCleanupEnabled.value

override fun isToggleEnabled(
viewModel: MainViewModel,
context: Context,
) = viewModel.isAccessibilityEnabled.value && viewModel.isStoragePermissionGranted.value
override fun isEnabled(viewModel: MainViewModel) =
viewModel.isScreenshotShareCleanupEnabled.value || viewModel.isScreenshotAutoCleanEnabled.value

override fun onToggle(
viewModel: MainViewModel,
context: Context,
enabled: Boolean,
) = viewModel.setScreenshotShareCleanupEnabled(enabled)
) {}

override fun isDeviceSupported(context: Context) = DeviceUtils.isGoogleDevice()
override fun isDeviceSupported(context: Context) =
DeviceUtils.isGoogleDevice() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
},
object : Feature(
id = "Lockdown mode",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading