diff --git a/.github/workflows/build-pr-test-apk.yml b/.github/workflows/build-pr-test-apk.yml
new file mode 100644
index 0000000..0610e18
--- /dev/null
+++ b/.github/workflows/build-pr-test-apk.yml
@@ -0,0 +1,68 @@
+name: Build PR Test APK
+
+# 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 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:
+ pull_request:
+ branches:
+ - "main"
+
+permissions:
+ contents: write # needed to create/update pre-release tags
+
+jobs:
+ build-pr-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: Determine pre-release tag
+ id: tag
+ shell: bash
+ run: echo "tag=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
+
+ - 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 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.
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 678e0a9..81bc584 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,16 @@
# Changelog
-## [1.1.0] - 2026-07-09
+## [Unreleased]
+
+### Changed
+- **Lower `minSdk` to 29 (Android 10)** — Issue #2. The app now supports Android 10+ instead of requiring Android 14+. Backward-compatible screenshot detection continues to use `MediaStore` + `ContentObserver`; the foreground service now only requests the `specialUse` type on API 34+ and falls back to no type on older levels.
+
+### Fixed
+- Storage-permission trap on Android 10-12: the runtime check and request now use `READ_EXTERNAL_STORAGE` below API 33 (via `StoragePermissions`) instead of always requesting `READ_MEDIA_IMAGES`, which does not exist below 33.
+- Foreground-service crash on API 29-33: `startForeground` only passes `FOREGROUND_SERVICE_TYPE_SPECIAL_USE` on API 34+.
+
+### Notes
+- Automatic background cleanup requires Android 11+ (All-Files access). On Android 10, detection and manual/notification deletes still work; silent background deletion is intentionally disabled.
### Added
- **Janitor On/Off toggle** — Long-press the **Pending** stat card to toggle the Janitor background monitor on or off. When off, a Material 3 Expressive red **"OFF" stamp** appears on the Pending card, the background detection service is stopped (the squiggly rotating indicator in **Next Scheduled Cleanup** also freezes), and a snackbar confirms the state. The choice persists across restarts and reboots. Implemented via `HomeViewModel.toggleJanitor()`, `SettingsRepository`, `SsJanitorApp.stopDetectionService()`, the new `OffBadge`, and an `isActive` flag on `NextCleanupBanner`.
diff --git a/README.md b/README.md
index 147bf63..9d1d33f 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
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/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/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/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/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..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
@@ -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
+ }
)
}
@@ -146,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(
@@ -243,7 +247,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/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..f72584e
--- /dev/null
+++ b/app/src/main/res/drawable-v31/avd_auto_delete.xml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..d82df8c 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,5 @@
-
+
-
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