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

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