From 8b2e01bca2972630164352b88a4e0343116edb2a Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Thu, 9 Jul 2026 21:52:05 +0530 Subject: [PATCH 01/11] =?UTF-8?q?Lower=20minSdk=20to=2029=20(Android=2010)?= =?UTF-8?q?=20=E2=80=94=20Issue=20#2\n\n-=20minSdk=2034=20->=2029=20in=20a?= =?UTF-8?q?pp/build.gradle.kts\n-=20Manifest:=20add=20READ=5FEXTERNAL=5FST?= =?UTF-8?q?ORAGE=20/=20WRITE=5FEXTERNAL=5FSTORAGE=20(maxSdk=2032);=20keep?= =?UTF-8?q?=20specialUse=20(tolerated=20on=20older=20platforms)\n-=20Scree?= =?UTF-8?q?nshotDetectionService:=20only=20pass=20FOREGROUND=5FSERVICE=5FT?= =?UTF-8?q?YPE=5FSPECIAL=5FUSE=20on=20API=2034+=20(was=20>=3D=20Q,=20which?= =?UTF-8?q?=20crashed=20on=2029-33)\n-=20New=20StoragePermissions=20helper?= =?UTF-8?q?:=20SDK-aware=20storage-permission=20check=20+=20request=20(fix?= =?UTF-8?q?es=20permanent=20'Permissions=20Required'=20trap=20on=2029-32)\?= =?UTF-8?q?n-=20ScreenshotCleanupWorker:=20gate=20silent=20background=20de?= =?UTF-8?q?letion=20to=20API=2030+=20(All-Files);=20manual/notification=20?= =?UTF-8?q?deletes=20still=20work=20on=2029+\n-=20HomeScreen:=20use=20Stor?= =?UTF-8?q?agePermissions=20helper;=20isAllFilesManager=20honest=20on=20 ssJanitor

ssJanitor

-

Minimal Android 14+ screenshot management utility

+

Minimal Android 10+ screenshot management utility

Kotlin · Jetpack Compose · Material 3

@@ -68,7 +68,7 @@ ssJanitor monitors newly created screenshots, lets you archive or delete them th 1. Open the project in Android Studio. 2. Sync Gradle (uses version catalog at `gradle/libs.versions.toml`). -3. Build and run on a device running **Android 14+** (min SDK 34). +3. Build and run on a device running **Android 10+** (min SDK 29). Automatic background cleanup requires Android 11+ (All-Files access); detection and manual deletes work on Android 10+. No API keys, no cloud services, no configuration required. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8575378..42e35ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,7 @@ android { defaultConfig { applicationId = "dev.sj010.ssjanitor" - minSdk = 34 + minSdk = 29 targetSdk = 36 versionCode = 8 versionName = "1.1.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d519828..cbfbc9b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ xmlns:tools="http://schemas.android.com/tools"> + + diff --git a/app/src/main/java/dev/sj010/ssjanitor/core/permissions/StoragePermissions.kt b/app/src/main/java/dev/sj010/ssjanitor/core/permissions/StoragePermissions.kt new file mode 100644 index 0000000..7809f59 --- /dev/null +++ b/app/src/main/java/dev/sj010/ssjanitor/core/permissions/StoragePermissions.kt @@ -0,0 +1,30 @@ +package dev.sj010.ssjanitor.core.permissions + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat + +/** + * Centralizes the storage-permission decision so the runtime *check* and the + * *request* can never diverge (they previously both hardcoded + * [Manifest.permission.READ_MEDIA_IMAGES], which does not exist below API 33 + * and left API 29-32 devices stuck in a permanent "Permissions Required" state). + * + * - API 33+ (TIRAMISU): [Manifest.permission.READ_MEDIA_IMAGES] + * - API 29-32: [Manifest.permission.READ_EXTERNAL_STORAGE] + */ +object StoragePermissions { + + fun requiredStoragePermission(): String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + + fun hasStoragePermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, requiredStoragePermission()) == + PackageManager.PERMISSION_GRANTED +} diff --git a/app/src/main/java/dev/sj010/ssjanitor/service/ScreenshotDetectionService.kt b/app/src/main/java/dev/sj010/ssjanitor/service/ScreenshotDetectionService.kt index a6de44e..cbbc0de 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/service/ScreenshotDetectionService.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/service/ScreenshotDetectionService.kt @@ -25,7 +25,7 @@ class ScreenshotDetectionService : Service() { startForeground( AppConstants.NOTIFICATION_SERVICE_ID, nm.createForegroundServiceNotification(), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 ) detector.startDetector() diff --git a/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt index 9b92798..34e0b93 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import dev.sj010.ssjanitor.core.permissions.StoragePermissions import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts @@ -72,19 +73,19 @@ fun HomeScreen( } var hasStoragePermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.READ_MEDIA_IMAGES - ) == PackageManager.PERMISSION_GRANTED - ) + mutableStateOf(StoragePermissions.hasStoragePermission(context)) } var isAllFilesManager by remember { mutableStateOf( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { android.os.Environment.isExternalStorageManager() - } else true + } else { + // All-Files access does not exist below Android 11 (API 30), and + // automatic background cleanup is intentionally disabled there (see + // ScreenshotCleanupWorker), so there is nothing to request. + true + } ) } @@ -243,7 +244,7 @@ fun HomeScreen( list.add(Manifest.permission.POST_NOTIFICATIONS) } if (!hasStoragePermission) { - list.add(Manifest.permission.READ_MEDIA_IMAGES) + list.add(StoragePermissions.requiredStoragePermission()) } if (list.isNotEmpty()) { permissionLauncher.launch(list.toTypedArray()) diff --git a/app/src/main/java/dev/sj010/ssjanitor/worker/ScreenshotCleanupWorker.kt b/app/src/main/java/dev/sj010/ssjanitor/worker/ScreenshotCleanupWorker.kt index 9d2d9ab..1f74239 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/worker/ScreenshotCleanupWorker.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/worker/ScreenshotCleanupWorker.kt @@ -1,6 +1,7 @@ package dev.sj010.ssjanitor.worker import android.content.Context +import android.os.Build import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dev.sj010.ssjanitor.SsJanitorApp @@ -20,18 +21,25 @@ class ScreenshotCleanupWorker( return try { val archived = repository.getArchivedForCleanup() if (archived.isNotEmpty()) { - val nm = ScreenshotNotificationManager(applicationContext) - val deleted = repository.deleteScreenshotsDirectly( - applicationContext, - archived.map { it.uri } - ) - if (deleted.isNotEmpty()) { - repository.markAsDeleted(deleted) - nm.showAutoCleanupNotification(deleted.size) - } - val failed = archived.size - deleted.size - if (failed > 0) { - nm.showCleanupNotification(failed) + // Silent (user-consent-free) deletion requires All-Files access, + // which only exists on Android 11+ (API 30). On API 29 there is no + // mechanism for background deletion, so we intentionally skip the + // worker there — manual/notification deletes via createDeleteRequest + // still work on every supported level. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val nm = ScreenshotNotificationManager(applicationContext) + val deleted = repository.deleteScreenshotsDirectly( + applicationContext, + archived.map { it.uri } + ) + if (deleted.isNotEmpty()) { + repository.markAsDeleted(deleted) + nm.showAutoCleanupNotification(deleted.size) + } + val failed = archived.size - deleted.size + if (failed > 0) { + nm.showCleanupNotification(failed) + } } } val app = applicationContext as SsJanitorApp diff --git a/docs/development.md b/docs/development.md index fcda577..72eba8e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,8 +18,8 @@ ## Android Version Support -- **Android 14+** (min SDK 34) -- Older versions intentionally unsupported to simplify storage handling, permission management, background execution, and maintenance. +- **Android 10+** (min SDK 29) +- Automatic background cleanup requires Android 11+ (All-Files access). On Android 10, detection and manual/notification deletes work, but silent background deletion is unavailable (All-Files access does not exist below API 30). ## MVP Scope (v1.0) @@ -77,7 +77,7 @@ The app should feel like a native Android utility. | Notification actions | ❌ Not tested | | Cleanup reliability | ❌ Not tested | | Battery impact | ❌ Not tested | -| Android 14 behavior | ✅ Verified | +| Android 10+ behavior (API 29-36) | ⚠️ Verify on emulator matrix | | Process death recovery | ❌ Not tested | ## Building diff --git a/docs/features.md b/docs/features.md index 9dedb29..2e1f9a9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -4,7 +4,7 @@ Detect newly created screenshots using `MediaStore` and `ContentObserver`. -- Supports Android 14+ scoped storage model. +- Supports Android 10+ scoped storage model. Detection works on Android 10+; automatic background cleanup requires Android 11+ (All-Files access). - Event-driven architecture — no continuous polling. - **URI-based detection** — queries by content URI ID instead of bulk-scanning latest images, minimizing read overhead. - **Cold-start handling** — `performInitialScan()` catches screenshots taken during app startup; `scanLatestScreenshots()` fallback handles edge cases where `onChange` fires before MediaStore creates the row. diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 1f4109e..9ffe3e3 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Minimal Android 14+ screenshot management utility — archive, keep, or delete screenshots with a single tap. \ No newline at end of file +Minimal Android 10+ screenshot management utility — archive, keep, or delete screenshots with a single tap. \ No newline at end of file From 2bde01a7610e93182446bad6fcc6862fc451646b Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Thu, 9 Jul 2026 22:11:59 +0530 Subject: [PATCH 02/11] Fix Android CI lint failures after lowering minSdk to 29\n\nLowering minSdk to 29 exposed 10 NewApi lint errors (previously hidden at minSdk 34):\n- MainActivity: guard high-refresh-rate block (getDisplay/supportedModes/preferredDisplayModeId need API 30)\n- ScreenshotRepository: MediaStore.createDeleteRequest Collection overload is API 30; add API-29 fallback using direct delete + RecoverableSecurityException consent\n- avd_auto_delete.xml: system_accent1_* colors only exist on API 31+; move original to drawable-v31/ and add an API-29-safe hex default\n- themes.xml: windowSplashScreenBackground is compat-lib backed; suppress the lint false-positive with tools:targetApi=31\n\nVerified: ./gradlew lint test assembleDebug BUILD SUCCESSFUL (0 errors). --- .../java/dev/sj010/ssjanitor/MainActivity.kt | 18 +-- .../data/repository/ScreenshotRepository.kt | 21 +++- .../main/res/drawable-v31/avd_auto_delete.xml | 116 ++++++++++++++++++ app/src/main/res/drawable/avd_auto_delete.xml | 14 +-- app/src/main/res/values/themes.xml | 6 +- 5 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 app/src/main/res/drawable-v31/avd_auto_delete.xml diff --git a/app/src/main/java/dev/sj010/ssjanitor/MainActivity.kt b/app/src/main/java/dev/sj010/ssjanitor/MainActivity.kt index 55c4f39..c46890e 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/MainActivity.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/MainActivity.kt @@ -1,6 +1,7 @@ package dev.sj010.ssjanitor import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -35,13 +36,16 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) - // Optimize for high refresh rate (120Hz) - val modes = display?.supportedModes - val maxRefreshRateMode = modes?.maxByOrNull { it.refreshRate } - if (maxRefreshRateMode != null && (maxRefreshRateMode.refreshRate > 60f)) { - val layoutParams = window.attributes - layoutParams.preferredDisplayModeId = maxRefreshRateMode.modeId - window.attributes = layoutParams + // Optimize for high refresh rate (120Hz). supportedModes / preferredDisplayModeId + // and Activity#getDisplay() all require API 30 (R). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val modes = display?.supportedModes + val maxRefreshRateMode = modes?.maxByOrNull { it.refreshRate } + if (maxRefreshRateMode != null && (maxRefreshRateMode.refreshRate > 60f)) { + val layoutParams = window.attributes + layoutParams.preferredDisplayModeId = maxRefreshRateMode.modeId + window.attributes = layoutParams + } } enableEdgeToEdge() diff --git a/app/src/main/java/dev/sj010/ssjanitor/data/repository/ScreenshotRepository.kt b/app/src/main/java/dev/sj010/ssjanitor/data/repository/ScreenshotRepository.kt index ee30698..bba1548 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/data/repository/ScreenshotRepository.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/data/repository/ScreenshotRepository.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import android.app.RecoverableSecurityException import android.content.IntentSender import android.provider.MediaStore @@ -102,8 +103,24 @@ class ScreenshotRepository(private val screenshotDao: ScreenshotDao) { try { val uris = existingUris.map { Uri.parse(it) } - val pendingIntent = MediaStore.createDeleteRequest(context.contentResolver, uris) - DeleteResult.RequiresPermission(pendingIntent.intentSender) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val pendingIntent = MediaStore.createDeleteRequest(context.contentResolver, uris) + return@withContext DeleteResult.RequiresPermission(pendingIntent.intentSender) + } + // API 29 has no bulk delete request (the Collection overload is API 30). + // Attempt a direct delete and surface the framework's user-consent intent + // when required (RecoverableSecurityException, added in API 29). + val deleted = mutableListOf() + for ((uriString, uri) in existingUris.zip(uris)) { + try { + val rows = context.contentResolver.delete(uri, null, null) + if (rows > 0) deleted.add(uriString) + } catch (e: RecoverableSecurityException) { + return@withContext DeleteResult.RequiresPermission(e.userAction.actionIntent.intentSender) + } + } + if (deleted.isNotEmpty()) markAsDeleted(deleted) + return@withContext DeleteResult.Success } catch (e: Exception) { Log.e(TAG, "Failed to create delete request for screenshots: $existingUris", e) DeleteResult.Failed(e) diff --git a/app/src/main/res/drawable-v31/avd_auto_delete.xml b/app/src/main/res/drawable-v31/avd_auto_delete.xml new file mode 100644 index 0000000..17058c3 --- /dev/null +++ b/app/src/main/res/drawable-v31/avd_auto_delete.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avd_auto_delete.xml b/app/src/main/res/drawable/avd_auto_delete.xml index 17058c3..79bd6cc 100644 --- a/app/src/main/res/drawable/avd_auto_delete.xml +++ b/app/src/main/res/drawable/avd_auto_delete.xml @@ -37,20 +37,20 @@ android:pivotY="4"> @@ -101,14 +101,14 @@ + android:valueFrom="#4DD0C4" + android:valueTo="#26A69A" /> + android:valueFrom="#26A69A" + android:valueTo="#4DD0C4" /> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 27e2edc..e4146a7 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ - + - From 37a2156c7af9ebb673385fd914396bcee17ac794 Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Thu, 9 Jul 2026 22:57:11 +0530 Subject: [PATCH 03/11] Add Build APK workflow for downloadable test artifacts (Issue #2) --- .github/workflows/build-apk.yml | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/build-apk.yml diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..e42da31 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,40 @@ +name: Build APK + +# Lets anyone download a sideloadable APK directly from the pipeline. +# Triggers on manual dispatch, on pushes to the feature branch, and on PRs. +# Builds a debug-signed APK (no keystore needed in CI) and uploads it as an artifact. + +on: + workflow_dispatch: + push: + branches: [ "feature/issue-2-lower-minsdk-api29" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build-apk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "17" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build debug APK + run: ./gradlew assembleDebug --build-cache --parallel + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: app-debug-apk + path: app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error From b16e86fa32fc9f4749d7203b32e9ec0c21611187 Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Thu, 9 Jul 2026 22:58:48 +0530 Subject: [PATCH 04/11] Scope Build APK workflow to feature/** and fix/** branches --- .github/workflows/build-apk.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index e42da31..af31387 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -7,9 +7,9 @@ name: Build APK on: workflow_dispatch: push: - branches: [ "feature/issue-2-lower-minsdk-api29" ] - pull_request: - branches: [ "main" ] + branches: + - "feature/**" + - "fix/**" permissions: contents: read From 7b8132e710a163f095c56c7849bf9ff3c8c9fa38 Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Thu, 9 Jul 2026 22:59:34 +0530 Subject: [PATCH 05/11] Also run Build APK on pull_request to main --- .github/workflows/build-apk.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index af31387..7df72dd 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -10,6 +10,9 @@ on: branches: - "feature/**" - "fix/**" + pull_request: + branches: + - "main" permissions: contents: read From 492f5d39d1a498ada2dc813082949e2201040862 Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Thu, 9 Jul 2026 23:17:57 +0530 Subject: [PATCH 06/11] Automate pre-release test-APK flow (Closes #5) --- .github/workflows/build-apk.yml | 40 +++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 7df72dd..705f1e6 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -1,8 +1,10 @@ name: Build APK -# Lets anyone download a sideloadable APK directly from the pipeline. -# Triggers on manual dispatch, on pushes to the feature branch, and on PRs. -# Builds a debug-signed APK (no keystore needed in CI) and uploads it as an artifact. +# Produces a downloadable, installable test APK for every feature/fix branch push +# and every PR to main, as standard CI/CD practice (see issue #5). +# - Uploads the APK as a pipeline artifact (fast download from the run). +# - Publishes it as an automated pre-release keyed to the source branch/PR, +# updated in place so old test builds don't accumulate. on: workflow_dispatch: @@ -15,7 +17,7 @@ on: - "main" permissions: - contents: read + contents: write # needed to create/update pre-release tags jobs: build-apk: @@ -35,9 +37,39 @@ jobs: - name: Build debug APK run: ./gradlew assembleDebug --build-cache --parallel + - name: Determine pre-release tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "tag=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + else + SLUG=$(echo "${GITHUB_REF_NAME}" | tr '/' '-' | tr '_' '-') + echo "tag=test-${SLUG}" >> "$GITHUB_OUTPUT" + fi + - name: Upload APK artifact uses: actions/upload-artifact@v4 with: name: app-debug-apk path: app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error + + - name: Publish pre-release test APK + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag.outputs.tag }} + name: Test build ${{ steps.tag.outputs.tag }} + prerelease: true + allowUpdates: true + removeArtifacts: true + artifacts: app/build/outputs/apk/debug/app-debug.apk + token: ${{ secrets.GITHUB_TOKEN }} + body: | + Automated **debug-signed** test APK for `${{ github.ref_name }}` (${{ github.sha }}). + + Install with "Unknown sources" enabled. CI has no keystore, so this build is + debug-signed (same code as release; sideload-only). A release-signed variant + can be added later via repo secrets if desired. + + Also downloadable as a pipeline artifact from the same run. From a761baa4449d9f89b388b82e3f99154c397c786d Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Thu, 9 Jul 2026 23:52:26 +0530 Subject: [PATCH 07/11] Fix splash icon Material You color on API 31+\n\nThe splash icon used system_accent1_100/_300 (tones 100/300 are near-white),\nso against the icon background it read as colorless and did not visibly follow\nthe wallpaper. Drive the icon from saturated dynamic Material You tones\n(system_accent1_200 fill, pulsing to system_accent1_500) and use the public,\nwallpaper-derived android:attr/colorBackground for the icon circle so the\nsplash follows Material You on Android 12+. The drawable-v31 version is used\non API 31+; the default hex drawable remains the <31 fallback.\n\nVerified: ./gradlew lint assembleRelease BUILD SUCCESSFUL (0 errors). --- .../main/res/drawable-v31/avd_auto_delete.xml | 32 ++++--------------- app/src/main/res/values/themes.xml | 4 +-- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/app/src/main/res/drawable-v31/avd_auto_delete.xml b/app/src/main/res/drawable-v31/avd_auto_delete.xml index 17058c3..f72584e 100644 --- a/app/src/main/res/drawable-v31/avd_auto_delete.xml +++ b/app/src/main/res/drawable-v31/avd_auto_delete.xml @@ -8,18 +8,6 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - @@ -94,21 +77,20 @@ - + android:valueFrom="@android:color/system_accent1_200" + android:valueTo="@android:color/system_accent1_500" /> + android:valueFrom="@android:color/system_accent1_500" + android:valueTo="@android:color/system_accent1_200" /> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e4146a7..d82df8c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -17,8 +17,8 @@ @drawable/avd_auto_delete - - ?attr/colorPrimary + + ?android:attr/colorBackground 400 @style/Theme.ssJanitor.Main From ae4f6891957e3cfdef903aa5e820d5424800cadd Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Sat, 11 Jul 2026 01:24:36 +0530 Subject: [PATCH 08/11] =?UTF-8?q?Fix=20pre-cleanup=20reminder=20not=20show?= =?UTF-8?q?ing=20=E2=80=94=20schedule=20via=20exact=20AlarmManager\n\nThe?= =?UTF-8?q?=20reminder=20was=20a=20periodic=20WorkManager=20job=20whose=20?= =?UTF-8?q?initial=20delay=20clamped=20to=200\nwhen=20cleanup=20was=20<30?= =?UTF-8?q?=20min=20away=20and=20whose=20inexact=20periodic=20scheduling?= =?UTF-8?q?=20let=20the\nheads-up=20be=20deferred/coalesced=20(worse=20aft?= =?UTF-8?q?er=20minSdk=20lowered=20to=2029).\n\n-=20Add=20CleanupReminderR?= =?UTF-8?q?eceiver=20(broadcast)=20that=20shows=20the=20heads-up=20when=20?= =?UTF-8?q?there\n=20=20are=20archived=20screenshots=20pending=20and=20re-?= =?UTF-8?q?arms=20the=20next=20day's=20alarm.\n-=20CleanupScheduler.setRem?= =?UTF-8?q?inderAlarm=20uses=20AlarmManager.setExactAndAllowWhileIdle\n=20?= =?UTF-8?q?=20(RTC=5FWAKEUP)=20with=20a=20setAndAllowWhileIdle=20fallback?= =?UTF-8?q?=20on=20API=2031+=20without\n=20=20SCHEDULE=5FEXACT=5FALARM;=20?= =?UTF-8?q?computeReminderTimeMillis=20clamps=20sub-lead-time=20cleanups\n?= =?UTF-8?q?=20=20to=20now=20instead=20of=20colliding=20with=20cleanup.\n-?= =?UTF-8?q?=20BootReceiver=20re-arms=20the=20reminder=20alarm=20after=20re?= =?UTF-8?q?boot.\n-=20Manifest:=20register=20CleanupReminderReceiver,=20ad?= =?UTF-8?q?d=20SCHEDULE=5FEXACT=5FALARM.\n-=20Remove=20obsolete=20CleanupR?= =?UTF-8?q?eminderWorker=20+=20unused=20WORK=5FREMINDER=5FNAME.\n-=20Add?= =?UTF-8?q?=20CleanupSchedulerReminderTest=20for=20the=20reminder=20timing?= =?UTF-8?q?=20math.\n\nVerified:=20./gradlew=20lint=20test=20assembleDebug?= =?UTF-8?q?=20BUILD=20SUCCESSFUL.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 11 ++ .../ssjanitor/core/constants/AppConstants.kt | 1 - .../sj010/ssjanitor/receiver/BootReceiver.kt | 3 + .../receiver/CleanupReminderReceiver.kt | 88 +++++++++++++ .../ssjanitor/worker/CleanupReminderWorker.kt | 35 ------ .../ssjanitor/worker/CleanupScheduler.kt | 117 +++++++++++++++--- .../ssjanitor/CleanupSchedulerReminderTest.kt | 69 +++++++++++ 7 files changed, 269 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt delete mode 100644 app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt create mode 100644 app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cbfbc9b..7aebbd6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt b/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt index 5f7a168..300a9a1 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt @@ -35,7 +35,6 @@ object AppConstants { // Unique work names const val WORK_CLEANUP_NAME = "ScreenshotCleanupWork" - const val WORK_REMINDER_NAME = "ScreenshotCleanupReminderWork" // Default scheduled cleanup time (local timezone): 11:30 PM const val DEFAULT_CLEANUP_HOUR = 23 diff --git a/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt b/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt index 0a8a0c0..44c2246 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import dev.sj010.ssjanitor.SsJanitorApp +import dev.sj010.ssjanitor.worker.CleanupScheduler class BootReceiver : BroadcastReceiver() { @@ -13,6 +14,8 @@ class BootReceiver : BroadcastReceiver() { val app = context.applicationContext as SsJanitorApp if (app.settingsRepository.isJanitorEnabled()) { app.startDetectionService() + // Re-arm the pre-cleanup reminder alarm (Alarms do not survive reboot). + CleanupScheduler.setReminderAlarm(context) } pendingResult.finish() } diff --git a/app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt b/app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt new file mode 100644 index 0000000..2a633be --- /dev/null +++ b/app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt @@ -0,0 +1,88 @@ +package dev.sj010.ssjanitor.receiver + +import android.Manifest +import android.app.AlarmManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.content.ContextCompat +import dev.sj010.ssjanitor.SsJanitorApp +import dev.sj010.ssjanitor.core.constants.AppConstants +import dev.sj010.ssjanitor.data.db.AppDatabase +import dev.sj010.ssjanitor.data.repository.ScreenshotRepository +import dev.sj010.ssjanitor.notifications.ScreenshotNotificationManager +import dev.sj010.ssjanitor.worker.CleanupScheduler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +/** + * Fires the pre-cleanup heads-up reminder [PRE_CLEANUP_REMINDER_MINUTES] before + * the scheduled cleanup, and re-arms the next day's reminder. Scheduling is done + * via [AlarmManager.setExactAndAllowWhileIdle] (falling back to + * [AlarmManager.setAndAllowWhileIdle] when exact alarms are not permitted) so the + * heads-up reliably appears at the intended clock time instead of being batched + * by WorkManager's inexact periodic scheduling. + * + * It only notifies when there are archived screenshots actually pending deletion, + * mirroring [dev.sj010.ssjanitor.worker.ScreenshotCleanupWorker]'s no-op-when-empty + * behavior. + */ +class CleanupReminderReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + try { + Log.d(TAG, "Cleanup reminder receiver fired") + + val database = AppDatabase.getDatabase(context) + val repository = ScreenshotRepository(database.screenshotDao()) + + val pending = runBlocking(Dispatchers.IO) { + repository.getArchivedForCleanup() + } + + if (pending.isNotEmpty()) { + val nm = ScreenshotNotificationManager(context) + nm.showCleanupReminderNotification(pending.size) + } else { + Log.d(TAG, "No archived screenshots pending; skipping reminder notification") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to show cleanup reminder", e) + } finally { + // Re-arm the next day's reminder regardless of whether we notified, + // so the daily heads-up keeps recurring. + CleanupScheduler.setReminderAlarm(context) + } + } + + companion object { + private const val TAG = "CleanupReminderReceiver" + const val ACTION_CLEANUP_REMINDER = + "dev.sj010.ssjanitor.ACTION_CLEANUP_REMINDER" + + /** True when exact-alarm use is permitted (always on < API 31). */ + fun canScheduleExactAlarms(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + am.canScheduleExactAlarms() + } else { + true + } + } + + /** Whether the runtime POST_NOTIFICATIONS permission is held (API 33+). */ + fun hasNotificationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + } +} diff --git a/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt b/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt deleted file mode 100644 index 2c7c9e4..0000000 --- a/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.sj010.ssjanitor.worker - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import dev.sj010.ssjanitor.data.db.AppDatabase -import dev.sj010.ssjanitor.data.repository.ScreenshotRepository -import dev.sj010.ssjanitor.notifications.ScreenshotNotificationManager - -/** - * Fires ~30 minutes before the scheduled cleanup to give the user a heads-up. - * It only notifies when there are archived screenshots actually pending deletion, - * mirroring [ScreenshotCleanupWorker]'s no-op-when-empty behavior. - */ -class CleanupReminderWorker( - appContext: Context, - workerParams: WorkerParameters -) : CoroutineWorker(appContext, workerParams) { - - override suspend fun doWork(): Result { - return try { - val database = AppDatabase.getDatabase(applicationContext) - val repository = ScreenshotRepository(database.screenshotDao()) - - val pending = repository.getArchivedForCleanup() - if (pending.isNotEmpty()) { - val nm = ScreenshotNotificationManager(applicationContext) - nm.showCleanupReminderNotification(pending.size) - } - Result.success() - } catch (e: Exception) { - Result.retry() - } - } -} diff --git a/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt b/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt index d49c6ed..f500e11 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt @@ -1,6 +1,10 @@ package dev.sj010.ssjanitor.worker +import android.app.AlarmManager import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy @@ -8,6 +12,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkRequest import dev.sj010.ssjanitor.core.constants.AppConstants +import dev.sj010.ssjanitor.receiver.CleanupReminderReceiver import java.util.Calendar import java.util.concurrent.TimeUnit @@ -15,10 +20,15 @@ import java.util.concurrent.TimeUnit * Centralizes scheduling of the daily cleanup worker and the pre-cleanup * heads-up reminder so that both stay aligned across reschedules and reboots. * - * All times are resolved in the device's local timezone via [computeDelayMillis]. + * All times are resolved in the device's local timezone. The reminder is + * scheduled with [AlarmManager] (exact, while-idle) rather than a periodic + * WorkManager job, because a precise "N minutes before the cleanup" heads-up + * must not be deferred/batched by WorkManager's inexact periodic scheduling. */ object CleanupScheduler { + private const val TAG = "CleanupScheduler" + /** Millis from now until the next occurrence of [hour]:[minute] in local time. */ fun computeDelayMillis(hour: Int, minute: Int): Long { val now = Calendar.getInstance() @@ -33,6 +43,82 @@ object CleanupScheduler { return target.timeInMillis - now.timeInMillis } + /** + * Absolute clock time (local tz) of the next pre-cleanup reminder, i.e. + * [PRE_CLEANUP_REMINDER_MINUTES] before the next scheduled cleanup. + * If the cleanup is less than the lead time away, the reminder is due now. + */ + fun computeReminderTimeMillis(hour: Int, minute: Int): Long { + val cleanupTime = Calendar.getInstance().apply { + timeInMillis = System.currentTimeMillis() + computeDelayMillis(hour, minute) + } + val leadMillis = TimeUnit.MINUTES.toMillis(AppConstants.PRE_CLEANUP_REMINDER_MINUTES.toLong()) + return (cleanupTime.timeInMillis - leadMillis).coerceAtLeast(System.currentTimeMillis()) + } + + /** PendingIntent that triggers [CleanupReminderReceiver] (immutable, updateable). */ + fun reminderPendingIntent(context: Context): android.app.PendingIntent { + val intent = Intent(context, CleanupReminderReceiver::class.java).apply { + action = CleanupReminderReceiver.ACTION_CLEANUP_REMINDER + } + return android.app.PendingIntent.getBroadcast( + context, + REMINDER_PENDING_INTENT_REQUEST_CODE, + intent, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE + ) + } + + /** + * Schedules the next pre-cleanup reminder via [AlarmManager] using the + * persisted cleanup time. Uses [AlarmManager.setExactAndAllowWhileIdle] for a + * precise, doze-defying wake-up; falls back to [AlarmManager.setAndAllowWhileIdle] + * when exact alarms are not permitted (API 31+ without SCHEDULE_EXACT_ALARM). + */ + fun setReminderAlarm(context: Context) { + val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val hour = cleanupHour(context) + val minute = cleanupMinute(context) + val triggerAt = computeReminderTimeMillis(hour, minute) + val pi = reminderPendingIntent(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + CleanupReminderReceiver.canScheduleExactAlarms(context) + ) { + am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi) + } else { + // Exact alarms not permitted: best-effort inexact fallback so the + // reminder still arrives (possibly slightly delayed). + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi) + } + } else { + @Suppress("DEPRECATION") + am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi) + } + Log.d( + TAG, + "Reminder alarm set for ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()).format(java.util.Date(triggerAt))}" + ) + } + + /** Cancels any pending pre-cleanup reminder alarm. */ + fun cancelReminderAlarm(context: Context) { + val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + am.cancel(reminderPendingIntent(context)) + } + + private fun cleanupHour(context: Context): Int = + context.getSharedPreferences(AppConstants.PREF_NAME, Context.MODE_PRIVATE) + .getInt(AppConstants.PREF_CLEANUP_HOUR, AppConstants.DEFAULT_CLEANUP_HOUR) + + private fun cleanupMinute(context: Context): Int = + context.getSharedPreferences(AppConstants.PREF_NAME, Context.MODE_PRIVATE) + .getInt(AppConstants.PREF_CLEANUP_MINUTE, AppConstants.DEFAULT_CLEANUP_MINUTE) + + private const val REMINDER_PENDING_INTENT_REQUEST_CODE = 2004 + + fun scheduleCleanup( context: Context, delayMillis: Long, @@ -61,29 +147,22 @@ object CleanupScheduler { } /** - * Schedules a daily reminder [PRE_CLEANUP_REMINDER_MINUTES] before the cleanup. - * The reminder intentionally has no battery/storage constraints so the warning - * reliably appears on time. If the cleanup is scheduled less than the reminder - * lead time away, the reminder fires as soon as possible (delay clamped to 0). + * Schedules the pre-cleanup reminder for the next cleanup time. The reminder is + * fired [PRE_CLEANUP_REMINDER_MINUTES] before the cleanup via [AlarmManager] + * (see [setReminderAlarm]); it intentionally has no battery/storage constraints + * so the heads-up reliably appears on time. If the cleanup is scheduled less than + * the reminder lead time away, the reminder is due immediately. */ fun scheduleReminder( context: Context, cleanupDelayMillis: Long, policy: ExistingPeriodicWorkPolicy ) { - val reminderDelay = maxOf( - 0L, - cleanupDelayMillis - TimeUnit.MINUTES.toMillis(AppConstants.PRE_CLEANUP_REMINDER_MINUTES.toLong()) - ) - - val reminderRequest = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) - .setInitialDelay(reminderDelay, TimeUnit.MILLISECONDS) - .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - AppConstants.WORK_REMINDER_NAME, - policy, - reminderRequest - ) + // cleanupDelayMillis is ignored for the alarm: the alarm is derived directly + // from the persisted cleanup hour/minute so it stays correct across reboots. + // `policy` is retained for API-compatibility but the alarm semantics make the + // previous KEEP/CANCEL_AND_REENQUEUE distinction moot (the PendingIntent is + // replaced in place by FLAG_UPDATE_CURRENT). + setReminderAlarm(context) } } diff --git a/app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt b/app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt new file mode 100644 index 0000000..1625621 --- /dev/null +++ b/app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt @@ -0,0 +1,69 @@ +package dev.sj010.ssjanitor + +import dev.sj010.ssjanitor.core.constants.AppConstants +import dev.sj010.ssjanitor.worker.CleanupScheduler +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Calendar +import java.util.concurrent.TimeUnit + +/** + * Verifies the pre-cleanup reminder timing math that [CleanupScheduler] derives + * from the persisted cleanup hour/minute. This is the logic that previously + * clamped to 0 under WorkManager and made the reminder unreliable. + * + * Because [CleanupScheduler] resolves "now" from the real clock internally, these + * tests derive the expected cleanup instant from the implementation's own + * [CleanupScheduler.computeDelayMillis] (which uses the same clock) rather than + * reconstructing the calendar by hand. + */ +class CleanupSchedulerReminderTest { + + private val lead = TimeUnit.MINUTES.toMillis(AppConstants.PRE_CLEANUP_REMINDER_MINUTES.toLong()) + + @Test + fun reminder_isLeadTimeBefore_cleanup() { + val now = System.currentTimeMillis() + // Pick a cleanup time comfortably > 30 min away from `now` (tomorrow 09:15). + val cleanup = now + CleanupScheduler.computeDelayMillis(9, 15) + val actual = CleanupScheduler.computeReminderTimeMillis(9, 15) + + assertTrue( + "reminder should be exactly the lead time before cleanup " + + "(delta=${cleanup - actual - lead})", + kotlin.math.abs((cleanup - actual) - lead) < 1000 + ) + } + + @Test + fun reminder_dueNow_whenCleanupLessThanLeadTimeAway() { + val now = System.currentTimeMillis() + // A cleanup 28 min after `now` is < the 30 min lead, so computeReminderTimeMillis + // clamps the reminder to "now". Convert that delay into an hour/minute pair. + val cleanupDelay = TimeUnit.MINUTES.toMillis(28) + val cal = Calendar.getInstance().apply { timeInMillis = now + cleanupDelay } + val hour = cal.get(Calendar.HOUR_OF_DAY) + val minute = cal.get(Calendar.MINUTE) + + val actual = CleanupScheduler.computeReminderTimeMillis(hour, minute) + assertTrue( + "reminder with < lead-time cleanup should be due now " + + "(actual=$actual, now=${now - 1000})", + actual >= now - 1000 + ) + } + + @Test + fun reminder_isInTheFuture_whenCleanupAlreadyPassedToday() { + val now = System.currentTimeMillis() + // 09:15 today is almost certainly in the past for this test run; next + // occurrence is tomorrow, so the reminder must be in the future. + val cleanup = now + CleanupScheduler.computeDelayMillis(9, 15) + val actual = CleanupScheduler.computeReminderTimeMillis(9, 15) + assertTrue( + "reminder for a passed cleanup time should be in the future " + + "(actual=$actual, now=$now)", + actual > now + ) + } +} From 3fba622985a8e9f0686d6cd703427dce092771dc Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Sat, 11 Jul 2026 01:30:55 +0530 Subject: [PATCH 09/11] CI: only publish test APK pre-release for PRs (not branch pushes)\n\n- Remove the push trigger so branch pushes no longer create a test-\n pre-release (only pull_request to main does, keyed to pr-).\n- Simplify the tag step to always use pr-.\n- Rename workflow file build-apk.yml -> build-pr-test-apk.yml and update\n its name/comments to reflect PR-only scope. --- .../{build-apk.yml => build-pr-test-apk.yml} | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) rename .github/workflows/{build-apk.yml => build-pr-test-apk.yml} (69%) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-pr-test-apk.yml similarity index 69% rename from .github/workflows/build-apk.yml rename to .github/workflows/build-pr-test-apk.yml index 705f1e6..0610e18 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-pr-test-apk.yml @@ -1,17 +1,15 @@ -name: Build APK +name: Build PR Test APK -# Produces a downloadable, installable test APK for every feature/fix branch push -# and every PR to main, as standard CI/CD practice (see issue #5). +# Produces a downloadable, installable test APK for every pull request to main, +# as standard CI/CD practice (see issue #5). # - Uploads the APK as a pipeline artifact (fast download from the run). -# - Publishes it as an automated pre-release keyed to the source branch/PR, +# - Publishes it as an automated pre-release keyed to the PR number, # updated in place so old test builds don't accumulate. +# +# Branch pushes do NOT create a release (only PRs do). on: workflow_dispatch: - push: - branches: - - "feature/**" - - "fix/**" pull_request: branches: - "main" @@ -20,7 +18,7 @@ permissions: contents: write # needed to create/update pre-release tags jobs: - build-apk: + build-pr-apk: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -40,13 +38,7 @@ jobs: - name: Determine pre-release tag id: tag shell: bash - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "tag=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" - else - SLUG=$(echo "${GITHUB_REF_NAME}" | tr '/' '-' | tr '_' '-') - echo "tag=test-${SLUG}" >> "$GITHUB_OUTPUT" - fi + run: echo "tag=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" - name: Upload APK artifact uses: actions/upload-artifact@v4 @@ -66,10 +58,11 @@ jobs: artifacts: app/build/outputs/apk/debug/app-debug.apk token: ${{ secrets.GITHUB_TOKEN }} body: | - Automated **debug-signed** test APK for `${{ github.ref_name }}` (${{ github.sha }}). + Automated **debug-signed** test APK for PR #${{ github.event.pull_request.number }} (`${{ github.ref_name }}`, ${{ github.sha }}). Install with "Unknown sources" enabled. CI has no keystore, so this build is debug-signed (same code as release; sideload-only). A release-signed variant can be added later via repo secrets if desired. Also downloadable as a pipeline artifact from the same run. + From c117a2b4240a7163fcc5a80a6806f0ac826e64db Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Sat, 11 Jul 2026 02:03:08 +0530 Subject: [PATCH 10/11] =?UTF-8?q?Revert=20"Fix=20pre-cleanup=20reminder=20?= =?UTF-8?q?not=20showing=20=E2=80=94=20schedule=20via=20exact=20AlarmManag?= =?UTF-8?q?er\n\nThe=20reminder=20was=20a=20periodic=20WorkManager=20job?= =?UTF-8?q?=20whose=20initial=20delay=20clamped=20to=200\nwhen=20cleanup?= =?UTF-8?q?=20was=20<30=20min=20away=20and=20whose=20inexact=20periodic=20?= =?UTF-8?q?scheduling=20let=20the\nheads-up=20be=20deferred/coalesced=20(w?= =?UTF-8?q?orse=20after=20minSdk=20lowered=20to=2029).\n\n-=20Add=20Cleanu?= =?UTF-8?q?pReminderReceiver=20(broadcast)=20that=20shows=20the=20heads-up?= =?UTF-8?q?=20when=20there\n=20=20are=20archived=20screenshots=20pending?= =?UTF-8?q?=20and=20re-arms=20the=20next=20day's=20alarm.\n-=20CleanupSche?= =?UTF-8?q?duler.setReminderAlarm=20uses=20AlarmManager.setExactAndAllowWh?= =?UTF-8?q?ileIdle\n=20=20(RTC=5FWAKEUP)=20with=20a=20setAndAllowWhileIdle?= =?UTF-8?q?=20fallback=20on=20API=2031+=20without\n=20=20SCHEDULE=5FEXACT?= =?UTF-8?q?=5FALARM;=20computeReminderTimeMillis=20clamps=20sub-lead-time?= =?UTF-8?q?=20cleanups\n=20=20to=20now=20instead=20of=20colliding=20with?= =?UTF-8?q?=20cleanup.\n-=20BootReceiver=20re-arms=20the=20reminder=20alar?= =?UTF-8?q?m=20after=20reboot.\n-=20Manifest:=20register=20CleanupReminder?= =?UTF-8?q?Receiver,=20add=20SCHEDULE=5FEXACT=5FALARM.\n-=20Remove=20obsol?= =?UTF-8?q?ete=20CleanupReminderWorker=20+=20unused=20WORK=5FREMINDER=5FNA?= =?UTF-8?q?ME.\n-=20Add=20CleanupSchedulerReminderTest=20for=20the=20remin?= =?UTF-8?q?der=20timing=20math.\n\nVerified:=20./gradlew=20lint=20test=20a?= =?UTF-8?q?ssembleDebug=20BUILD=20SUCCESSFUL."?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ae4f6891957e3cfdef903aa5e820d5424800cadd. --- app/src/main/AndroidManifest.xml | 11 -- .../ssjanitor/core/constants/AppConstants.kt | 1 + .../sj010/ssjanitor/receiver/BootReceiver.kt | 3 - .../receiver/CleanupReminderReceiver.kt | 88 ------------- .../ssjanitor/worker/CleanupReminderWorker.kt | 35 ++++++ .../ssjanitor/worker/CleanupScheduler.kt | 117 +++--------------- .../ssjanitor/CleanupSchedulerReminderTest.kt | 69 ----------- 7 files changed, 55 insertions(+), 269 deletions(-) delete mode 100644 app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt create mode 100644 app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt delete mode 100644 app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7aebbd6..cbfbc9b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,9 +14,6 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt b/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt index 300a9a1..5f7a168 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/core/constants/AppConstants.kt @@ -35,6 +35,7 @@ object AppConstants { // Unique work names const val WORK_CLEANUP_NAME = "ScreenshotCleanupWork" + const val WORK_REMINDER_NAME = "ScreenshotCleanupReminderWork" // Default scheduled cleanup time (local timezone): 11:30 PM const val DEFAULT_CLEANUP_HOUR = 23 diff --git a/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt b/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt index 44c2246..0a8a0c0 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/receiver/BootReceiver.kt @@ -4,7 +4,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import dev.sj010.ssjanitor.SsJanitorApp -import dev.sj010.ssjanitor.worker.CleanupScheduler class BootReceiver : BroadcastReceiver() { @@ -14,8 +13,6 @@ class BootReceiver : BroadcastReceiver() { val app = context.applicationContext as SsJanitorApp if (app.settingsRepository.isJanitorEnabled()) { app.startDetectionService() - // Re-arm the pre-cleanup reminder alarm (Alarms do not survive reboot). - CleanupScheduler.setReminderAlarm(context) } pendingResult.finish() } diff --git a/app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt b/app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt deleted file mode 100644 index 2a633be..0000000 --- a/app/src/main/java/dev/sj010/ssjanitor/receiver/CleanupReminderReceiver.kt +++ /dev/null @@ -1,88 +0,0 @@ -package dev.sj010.ssjanitor.receiver - -import android.Manifest -import android.app.AlarmManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.util.Log -import androidx.core.content.ContextCompat -import dev.sj010.ssjanitor.SsJanitorApp -import dev.sj010.ssjanitor.core.constants.AppConstants -import dev.sj010.ssjanitor.data.db.AppDatabase -import dev.sj010.ssjanitor.data.repository.ScreenshotRepository -import dev.sj010.ssjanitor.notifications.ScreenshotNotificationManager -import dev.sj010.ssjanitor.worker.CleanupScheduler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking - -/** - * Fires the pre-cleanup heads-up reminder [PRE_CLEANUP_REMINDER_MINUTES] before - * the scheduled cleanup, and re-arms the next day's reminder. Scheduling is done - * via [AlarmManager.setExactAndAllowWhileIdle] (falling back to - * [AlarmManager.setAndAllowWhileIdle] when exact alarms are not permitted) so the - * heads-up reliably appears at the intended clock time instead of being batched - * by WorkManager's inexact periodic scheduling. - * - * It only notifies when there are archived screenshots actually pending deletion, - * mirroring [dev.sj010.ssjanitor.worker.ScreenshotCleanupWorker]'s no-op-when-empty - * behavior. - */ -class CleanupReminderReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - try { - Log.d(TAG, "Cleanup reminder receiver fired") - - val database = AppDatabase.getDatabase(context) - val repository = ScreenshotRepository(database.screenshotDao()) - - val pending = runBlocking(Dispatchers.IO) { - repository.getArchivedForCleanup() - } - - if (pending.isNotEmpty()) { - val nm = ScreenshotNotificationManager(context) - nm.showCleanupReminderNotification(pending.size) - } else { - Log.d(TAG, "No archived screenshots pending; skipping reminder notification") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to show cleanup reminder", e) - } finally { - // Re-arm the next day's reminder regardless of whether we notified, - // so the daily heads-up keeps recurring. - CleanupScheduler.setReminderAlarm(context) - } - } - - companion object { - private const val TAG = "CleanupReminderReceiver" - const val ACTION_CLEANUP_REMINDER = - "dev.sj010.ssjanitor.ACTION_CLEANUP_REMINDER" - - /** True when exact-alarm use is permitted (always on < API 31). */ - fun canScheduleExactAlarms(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - am.canScheduleExactAlarms() - } else { - true - } - } - - /** Whether the runtime POST_NOTIFICATIONS permission is held (API 33+). */ - fun hasNotificationPermission(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - } else { - true - } - } - } -} diff --git a/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt b/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt new file mode 100644 index 0000000..2c7c9e4 --- /dev/null +++ b/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupReminderWorker.kt @@ -0,0 +1,35 @@ +package dev.sj010.ssjanitor.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.sj010.ssjanitor.data.db.AppDatabase +import dev.sj010.ssjanitor.data.repository.ScreenshotRepository +import dev.sj010.ssjanitor.notifications.ScreenshotNotificationManager + +/** + * Fires ~30 minutes before the scheduled cleanup to give the user a heads-up. + * It only notifies when there are archived screenshots actually pending deletion, + * mirroring [ScreenshotCleanupWorker]'s no-op-when-empty behavior. + */ +class CleanupReminderWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + return try { + val database = AppDatabase.getDatabase(applicationContext) + val repository = ScreenshotRepository(database.screenshotDao()) + + val pending = repository.getArchivedForCleanup() + if (pending.isNotEmpty()) { + val nm = ScreenshotNotificationManager(applicationContext) + nm.showCleanupReminderNotification(pending.size) + } + Result.success() + } catch (e: Exception) { + Result.retry() + } + } +} diff --git a/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt b/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt index f500e11..d49c6ed 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/worker/CleanupScheduler.kt @@ -1,10 +1,6 @@ package dev.sj010.ssjanitor.worker -import android.app.AlarmManager import android.content.Context -import android.content.Intent -import android.os.Build -import android.util.Log import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy @@ -12,7 +8,6 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkRequest import dev.sj010.ssjanitor.core.constants.AppConstants -import dev.sj010.ssjanitor.receiver.CleanupReminderReceiver import java.util.Calendar import java.util.concurrent.TimeUnit @@ -20,15 +15,10 @@ import java.util.concurrent.TimeUnit * Centralizes scheduling of the daily cleanup worker and the pre-cleanup * heads-up reminder so that both stay aligned across reschedules and reboots. * - * All times are resolved in the device's local timezone. The reminder is - * scheduled with [AlarmManager] (exact, while-idle) rather than a periodic - * WorkManager job, because a precise "N minutes before the cleanup" heads-up - * must not be deferred/batched by WorkManager's inexact periodic scheduling. + * All times are resolved in the device's local timezone via [computeDelayMillis]. */ object CleanupScheduler { - private const val TAG = "CleanupScheduler" - /** Millis from now until the next occurrence of [hour]:[minute] in local time. */ fun computeDelayMillis(hour: Int, minute: Int): Long { val now = Calendar.getInstance() @@ -43,82 +33,6 @@ object CleanupScheduler { return target.timeInMillis - now.timeInMillis } - /** - * Absolute clock time (local tz) of the next pre-cleanup reminder, i.e. - * [PRE_CLEANUP_REMINDER_MINUTES] before the next scheduled cleanup. - * If the cleanup is less than the lead time away, the reminder is due now. - */ - fun computeReminderTimeMillis(hour: Int, minute: Int): Long { - val cleanupTime = Calendar.getInstance().apply { - timeInMillis = System.currentTimeMillis() + computeDelayMillis(hour, minute) - } - val leadMillis = TimeUnit.MINUTES.toMillis(AppConstants.PRE_CLEANUP_REMINDER_MINUTES.toLong()) - return (cleanupTime.timeInMillis - leadMillis).coerceAtLeast(System.currentTimeMillis()) - } - - /** PendingIntent that triggers [CleanupReminderReceiver] (immutable, updateable). */ - fun reminderPendingIntent(context: Context): android.app.PendingIntent { - val intent = Intent(context, CleanupReminderReceiver::class.java).apply { - action = CleanupReminderReceiver.ACTION_CLEANUP_REMINDER - } - return android.app.PendingIntent.getBroadcast( - context, - REMINDER_PENDING_INTENT_REQUEST_CODE, - intent, - android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE - ) - } - - /** - * Schedules the next pre-cleanup reminder via [AlarmManager] using the - * persisted cleanup time. Uses [AlarmManager.setExactAndAllowWhileIdle] for a - * precise, doze-defying wake-up; falls back to [AlarmManager.setAndAllowWhileIdle] - * when exact alarms are not permitted (API 31+ without SCHEDULE_EXACT_ALARM). - */ - fun setReminderAlarm(context: Context) { - val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val hour = cleanupHour(context) - val minute = cleanupMinute(context) - val triggerAt = computeReminderTimeMillis(hour, minute) - val pi = reminderPendingIntent(context) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || - CleanupReminderReceiver.canScheduleExactAlarms(context) - ) { - am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi) - } else { - // Exact alarms not permitted: best-effort inexact fallback so the - // reminder still arrives (possibly slightly delayed). - am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi) - } - } else { - @Suppress("DEPRECATION") - am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi) - } - Log.d( - TAG, - "Reminder alarm set for ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()).format(java.util.Date(triggerAt))}" - ) - } - - /** Cancels any pending pre-cleanup reminder alarm. */ - fun cancelReminderAlarm(context: Context) { - val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - am.cancel(reminderPendingIntent(context)) - } - - private fun cleanupHour(context: Context): Int = - context.getSharedPreferences(AppConstants.PREF_NAME, Context.MODE_PRIVATE) - .getInt(AppConstants.PREF_CLEANUP_HOUR, AppConstants.DEFAULT_CLEANUP_HOUR) - - private fun cleanupMinute(context: Context): Int = - context.getSharedPreferences(AppConstants.PREF_NAME, Context.MODE_PRIVATE) - .getInt(AppConstants.PREF_CLEANUP_MINUTE, AppConstants.DEFAULT_CLEANUP_MINUTE) - - private const val REMINDER_PENDING_INTENT_REQUEST_CODE = 2004 - - fun scheduleCleanup( context: Context, delayMillis: Long, @@ -147,22 +61,29 @@ object CleanupScheduler { } /** - * Schedules the pre-cleanup reminder for the next cleanup time. The reminder is - * fired [PRE_CLEANUP_REMINDER_MINUTES] before the cleanup via [AlarmManager] - * (see [setReminderAlarm]); it intentionally has no battery/storage constraints - * so the heads-up reliably appears on time. If the cleanup is scheduled less than - * the reminder lead time away, the reminder is due immediately. + * Schedules a daily reminder [PRE_CLEANUP_REMINDER_MINUTES] before the cleanup. + * The reminder intentionally has no battery/storage constraints so the warning + * reliably appears on time. If the cleanup is scheduled less than the reminder + * lead time away, the reminder fires as soon as possible (delay clamped to 0). */ fun scheduleReminder( context: Context, cleanupDelayMillis: Long, policy: ExistingPeriodicWorkPolicy ) { - // cleanupDelayMillis is ignored for the alarm: the alarm is derived directly - // from the persisted cleanup hour/minute so it stays correct across reboots. - // `policy` is retained for API-compatibility but the alarm semantics make the - // previous KEEP/CANCEL_AND_REENQUEUE distinction moot (the PendingIntent is - // replaced in place by FLAG_UPDATE_CURRENT). - setReminderAlarm(context) + val reminderDelay = maxOf( + 0L, + cleanupDelayMillis - TimeUnit.MINUTES.toMillis(AppConstants.PRE_CLEANUP_REMINDER_MINUTES.toLong()) + ) + + val reminderRequest = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) + .setInitialDelay(reminderDelay, TimeUnit.MILLISECONDS) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + AppConstants.WORK_REMINDER_NAME, + policy, + reminderRequest + ) } } diff --git a/app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt b/app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt deleted file mode 100644 index 1625621..0000000 --- a/app/src/test/java/dev/sj010/ssjanitor/CleanupSchedulerReminderTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package dev.sj010.ssjanitor - -import dev.sj010.ssjanitor.core.constants.AppConstants -import dev.sj010.ssjanitor.worker.CleanupScheduler -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.Calendar -import java.util.concurrent.TimeUnit - -/** - * Verifies the pre-cleanup reminder timing math that [CleanupScheduler] derives - * from the persisted cleanup hour/minute. This is the logic that previously - * clamped to 0 under WorkManager and made the reminder unreliable. - * - * Because [CleanupScheduler] resolves "now" from the real clock internally, these - * tests derive the expected cleanup instant from the implementation's own - * [CleanupScheduler.computeDelayMillis] (which uses the same clock) rather than - * reconstructing the calendar by hand. - */ -class CleanupSchedulerReminderTest { - - private val lead = TimeUnit.MINUTES.toMillis(AppConstants.PRE_CLEANUP_REMINDER_MINUTES.toLong()) - - @Test - fun reminder_isLeadTimeBefore_cleanup() { - val now = System.currentTimeMillis() - // Pick a cleanup time comfortably > 30 min away from `now` (tomorrow 09:15). - val cleanup = now + CleanupScheduler.computeDelayMillis(9, 15) - val actual = CleanupScheduler.computeReminderTimeMillis(9, 15) - - assertTrue( - "reminder should be exactly the lead time before cleanup " + - "(delta=${cleanup - actual - lead})", - kotlin.math.abs((cleanup - actual) - lead) < 1000 - ) - } - - @Test - fun reminder_dueNow_whenCleanupLessThanLeadTimeAway() { - val now = System.currentTimeMillis() - // A cleanup 28 min after `now` is < the 30 min lead, so computeReminderTimeMillis - // clamps the reminder to "now". Convert that delay into an hour/minute pair. - val cleanupDelay = TimeUnit.MINUTES.toMillis(28) - val cal = Calendar.getInstance().apply { timeInMillis = now + cleanupDelay } - val hour = cal.get(Calendar.HOUR_OF_DAY) - val minute = cal.get(Calendar.MINUTE) - - val actual = CleanupScheduler.computeReminderTimeMillis(hour, minute) - assertTrue( - "reminder with < lead-time cleanup should be due now " + - "(actual=$actual, now=${now - 1000})", - actual >= now - 1000 - ) - } - - @Test - fun reminder_isInTheFuture_whenCleanupAlreadyPassedToday() { - val now = System.currentTimeMillis() - // 09:15 today is almost certainly in the past for this test run; next - // occurrence is tomorrow, so the reminder must be in the future. - val cleanup = now + CleanupScheduler.computeDelayMillis(9, 15) - val actual = CleanupScheduler.computeReminderTimeMillis(9, 15) - assertTrue( - "reminder for a passed cleanup time should be in the future " + - "(actual=$actual, now=$now)", - actual > now - ) - } -} From f2c002d0b777e42469fae580c37a11ac51df5919 Mon Sep 17 00:00:00 2001 From: Shubham Jha Date: Sat, 11 Jul 2026 02:17:55 +0530 Subject: [PATCH 11/11] =?UTF-8?q?Fix=20permission=20state=20stuck=20on=20A?= =?UTF-8?q?ndroid=2010-12=20(READ=5FEXTERNAL=5FSTORAGE=20path)\n\nThe=20pe?= =?UTF-8?q?rmission-launcher=20result=20callback=20read=20permissions[READ?= =?UTF-8?q?=5FMEDIA=5FIMAGES]\nto=20update=20hasStoragePermission,=20but?= =?UTF-8?q?=20on=20API=2029-32=20the=20app=20requests\nREAD=5FEXTERNAL=5FS?= =?UTF-8?q?TORAGE=20(via=20StoragePermissions.requiredStoragePermission())?= =?UTF-8?q?,\nso=20READ=5FMEDIA=5FIMAGES=20is=20absent=20from=20the=20resu?= =?UTF-8?q?lt=20map=20and=20the=20=3F:=20fallback=20kept\nthe=20stale=20un?= =?UTF-8?q?-granted=20value=20=E2=80=94=20stranding=20those=20devices=20in?= =?UTF-8?q?=20'Permissions=20Required'\neven=20after=20granting.\n\nRe-eva?= =?UTF-8?q?luate=20from=20StoragePermissions.hasStoragePermission(context)?= =?UTF-8?q?=20so=20the=20state\nis=20correct=20on=20every=20API=20level,?= =?UTF-8?q?=20consistent=20with=20the=20initial-state=20computation=20and\?= =?UTF-8?q?nthe=20single=20source=20of=20truth.\n\nVerified:=20./gradlew?= =?UTF-8?q?=20compileDebugKotlin=20BUILD=20SUCCESSFUL;=20release=20APK=20i?= =?UTF-8?q?nstalled\non=20OnePlus=208T=20(API=2030).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt index 34e0b93..6f0764e 100644 --- a/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/sj010/ssjanitor/ui/screens/home/HomeScreen.kt @@ -147,8 +147,11 @@ fun HomeScreen( hasNotificationPermission = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: hasNotificationPermission } - hasStoragePermission = - permissions[Manifest.permission.READ_MEDIA_IMAGES] ?: hasStoragePermission + // Re-evaluate from the SDK-aware helper rather than the result map: + // on API 29-32 the requested permission is READ_EXTERNAL_STORAGE, so + // READ_MEDIA_IMAGES is absent from `permissions` and a map lookup would + // keep the stale (un-granted) value, stranding the UI in "Permissions Required". + hasStoragePermission = StoragePermissions.hasStoragePermission(context) } val batteryOptLauncher = rememberLauncherForActivityResult(