Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/build-pr-test-apk.yml
Original file line number Diff line number Diff line change
@@ -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.

12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div align="center">
<img src="screenshots/app_icon.png" alt="ssJanitor" width="96" height="96"/>
<h1>ssJanitor</h1>
<p>Minimal Android 14+ screenshot management utility</p>
<p>Minimal Android 10+ screenshot management utility</p>
<p>
<strong>Kotlin</strong> · <strong>Jetpack Compose</strong> · <strong>Material 3</strong>
</p>
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ android {

defaultConfig {
applicationId = "dev.sj010.ssjanitor"
minSdk = 34
minSdk = 29
targetSdk = 36
versionCode = 8
versionName = "1.1.0"
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
Expand Down
18 changes: 11 additions & 7 deletions app/src/main/java/dev/sj010/ssjanitor/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<String>()
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
)
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading
Loading