From 55270af5fb03fc31226cf54ee75aa3b07f5c9b4d Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:06:34 -0700 Subject: [PATCH 1/4] Implement HTTP Cache Checking --- .../org/cssnr/remotewallpaper/db/RemoteDao.kt | 13 ++++- .../remotewallpaper/ui/home/HomeFragment.kt | 52 +++++++++++++++---- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt b/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt index bb418bb..e589d8d 100644 --- a/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt +++ b/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt @@ -13,6 +13,7 @@ import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.Transaction import androidx.room.Upsert +import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import java.util.concurrent.Executors @@ -68,10 +69,12 @@ data class Remote( //@PrimaryKey(autoGenerate = true) val id: Long = 0, @PrimaryKey val url: String, val active: Boolean = false, + val etag: String? = null, + val lastModified: String? = null, ) -@Database(entities = [Remote::class], version = 1, exportSchema = false) +@Database(entities = [Remote::class], version = 2, exportSchema = false) abstract class RemoteDatabase : RoomDatabase() { abstract fun remoteDao(): RemoteDao @@ -79,6 +82,13 @@ abstract class RemoteDatabase : RoomDatabase() { @Volatile private var instance: RemoteDatabase? = null + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Remote ADD COLUMN etag TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE Remote ADD COLUMN lastModified TEXT DEFAULT NULL") + } + } + private val defaultData: List = listOf( Remote("https://picsum.photos/4800/2400", active = true), Remote("https://picsum.photos/4800/2400?blur=10", active = false), @@ -103,6 +113,7 @@ abstract class RemoteDatabase : RoomDatabase() { } } }) + .addMigrations(MIGRATION_1_2) .build().also { instance = it } } } diff --git a/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt b/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt index 43596c0..6543754 100644 --- a/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt +++ b/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt @@ -32,6 +32,7 @@ import org.cssnr.remotewallpaper.R import org.cssnr.remotewallpaper.databinding.FragmentHomeBinding import org.cssnr.remotewallpaper.db.HistoryDatabase import org.cssnr.remotewallpaper.db.HistoryItem +import org.cssnr.remotewallpaper.db.Remote import org.cssnr.remotewallpaper.db.RemoteDatabase import java.io.File import java.io.FileOutputStream @@ -188,7 +189,7 @@ fun Context.showAddDialog() { if (url.isNotEmpty()) { CoroutineScope(Dispatchers.IO).launch { try { - downloadImage(url) + downloadImage(Remote(url = url)) withContext(Dispatchers.Main) { Toast.makeText(this@showAddDialog, "Done.", Toast.LENGTH_SHORT).show() dialog.dismiss() @@ -208,6 +209,11 @@ fun Context.showAddDialog() { dialog.show() } +sealed class DownloadResult { + data class Downloaded(val response: Response) : DownloadResult() + data object NotModified : DownloadResult() +} + // TODO: updateWallpaper is used globally to update the wallpaper and should be a package // The rest of the functions are only used by updateWallpaper and are internal to updateWallpaper suspend fun Context.updateWallpaper(): Boolean { @@ -220,10 +226,19 @@ suspend fun Context.updateWallpaper(): Boolean { Log.d("updateWallpaper", "remote: $remote") if (remote != null) { history.remote = remote.url - val response = withContext(Dispatchers.IO) { downloadImage(remote.url) } - history.status = response.code - history.url = response.request.url.toString() - Log.d("updateWallpaper", "response: $response") + val result = withContext(Dispatchers.IO) { downloadImage(remote) } + when (result) { + is DownloadResult.Downloaded -> { + history.status = result.response.code + history.url = result.response.request.url.toString() + Log.d("updateWallpaper", "response: ${result.response}") + } + is DownloadResult.NotModified -> { + history.status = 304 + history.url = remote.url + Log.i("updateWallpaper", "Image not modified, skipping wallpaper update") + } + } // TODO: Replace timestamp with history.timestamp val timestamp: String = ZonedDateTime.now().format(DateTimeFormatter.ISO_ZONED_DATE_TIME) @@ -246,19 +261,34 @@ suspend fun Context.updateWallpaper(): Boolean { } } -fun Context.downloadImage(url: String): Response { +suspend fun Context.downloadImage(remote: Remote): DownloadResult { val client = OkHttpClient.Builder() .followRedirects(true) .build() - val request = Request.Builder() - .url(url) - .build() + val requestBuilder = Request.Builder().url(remote.url) + remote.etag?.let { requestBuilder.header("If-None-Match", it) } + remote.lastModified?.let { requestBuilder.header("If-Modified-Since", it) } - val response = client.newCall(request).execute() + val response = client.newCall(requestBuilder.build()).execute() response.use { + if (it.code == 304) { + Log.d("downloadImage", "304 Not Modified for ${remote.url}") + return DownloadResult.NotModified + } if (!it.isSuccessful) throw Exception("Failed to download image: $it") + + val newEtag = it.header("ETag") + val newLastModified = it.header("Last-Modified") + if (newEtag != null || newLastModified != null) { + val dao = RemoteDatabase.getInstance(this).remoteDao() + withContext(Dispatchers.IO) { + dao.addOrUpdate(remote.copy(etag = newEtag, lastModified = newLastModified)) + } + Log.d("downloadImage", "Saved cache headers: etag=$newEtag, lastModified=$newLastModified") + } + val body = it.body val imageFile = File(filesDir, "wallpaper.img") @@ -270,7 +300,7 @@ fun Context.downloadImage(url: String): Response { setAutoCroppedWallpaper(imageFile) } - return response + return DownloadResult.Downloaded(response) } fun Context.setAutoCroppedWallpaper(imageFile: File) { From e6234fc23bcfb1485063ca9fda0c628f5e0f73bf Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:00:06 -0700 Subject: [PATCH 2/4] Fix AI --- AGENTS.md | 10 +++++++++ .../org/cssnr/remotewallpaper/db/RemoteDao.kt | 7 +++++++ .../remotewallpaper/ui/home/HomeFragment.kt | 21 ++++++++++++------- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e7ce6f0..97249f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,12 @@ Android application to update wallpaper from a remote URL on an interval. - `Taskfile.yml` - [task](https://github.com/go-task/task) commands - `docs/` - [Zensical](https://github.com/zensical/zensical) docs +## Android + +minSdk = 26 +targetSdk = 36 +compileSdk = 37 + ## Commands ALWAYS use the `task *` commands @@ -23,6 +29,10 @@ ALWAYS use the `task *` commands Do NOT use `-q` or pipe Gradle output through `Select-Object` — both hide progress and make long builds look hung. +## Testing + +To test on a device use the `adb` command. If no devices are running and attached, ask the user to do this! + ## Rules Do NOT run task compile/debug/release/bundle after making edits unless it is REQUIRED!!! diff --git a/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt b/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt index e589d8d..a8f24eb 100644 --- a/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt +++ b/app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt @@ -37,6 +37,13 @@ interface RemoteDao { @Upsert fun addOrUpdate(remote: Remote) + // FIX AI: Update ONLY the cache validator columns. Do NOT use addOrUpdate() for this: + // @Upsert overwrites ALL columns, which would reset active to false on rows + // that already exist (only one remote may be active at a time). + // Rows whose url is not in the table are silently skipped (0 rows updated). + @Query("UPDATE Remote SET etag = :etag, lastModified = :lastModified WHERE url = :url") + fun updateCacheHeaders(url: String, etag: String?, lastModified: String?) + @Query("UPDATE Remote SET active = 1 WHERE ROWID = (SELECT ROWID FROM Remote LIMIT 1)") fun activateFirst() diff --git a/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt b/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt index 6543754..6b5ab2f 100644 --- a/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt +++ b/app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt @@ -281,13 +281,6 @@ suspend fun Context.downloadImage(remote: Remote): DownloadResult { val newEtag = it.header("ETag") val newLastModified = it.header("Last-Modified") - if (newEtag != null || newLastModified != null) { - val dao = RemoteDatabase.getInstance(this).remoteDao() - withContext(Dispatchers.IO) { - dao.addOrUpdate(remote.copy(etag = newEtag, lastModified = newLastModified)) - } - Log.d("downloadImage", "Saved cache headers: etag=$newEtag, lastModified=$newLastModified") - } val body = it.body val imageFile = File(filesDir, "wallpaper.img") @@ -299,6 +292,20 @@ suspend fun Context.downloadImage(remote: Remote): DownloadResult { } setAutoCroppedWallpaper(imageFile) + + // FIX AI: Save cache validators AFTER the wallpaper is applied. Persisting them earlier + // would let a failed file write, bad image decode (silent return in + // setAutoCroppedWallpaper), or setBitmap error still store the ETag - then every + // future update would 304-skip with a stale or missing wallpaper. + // Uses updateCacheHeaders (NOT addOrUpdate) so the active flag is preserved; + // urls not yet in the database (preview downloads from showAddDialog) are skipped. + if (newEtag != null || newLastModified != null) { + val dao = RemoteDatabase.getInstance(this).remoteDao() + withContext(Dispatchers.IO) { + dao.updateCacheHeaders(remote.url, newEtag, newLastModified) + } + Log.d("downloadImage", "Saved cache headers: etag=$newEtag, lastModified=$newLastModified") + } } return DownloadResult.Downloaded(response) } From 24d7b739a595d274176188130555fdd880955fd4 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:37:48 -0700 Subject: [PATCH 3/4] AGENTS.md --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 97249f7..ebfe878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,9 @@ Android application to update wallpaper from a remote URL on an interval. ## Android +Release - applicationId = org.cssnr.remotewallpaper +Debug - applicationId = org.cssnr.remotewallpaper.dev + minSdk = 26 targetSdk = 36 compileSdk = 37 From 6eb88f58a5b5b53dea1c4668c612ca89637e4c00 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:47:52 -0700 Subject: [PATCH 4/4] AGENTS.md and TODO.md --- AGENTS.md | 4 ++-- TODO.md | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 TODO.md diff --git a/AGENTS.md b/AGENTS.md index ebfe878..d41f5a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ Android application to update wallpaper from a remote URL on an interval. ## Android -Release - applicationId = org.cssnr.remotewallpaper -Debug - applicationId = org.cssnr.remotewallpaper.dev +applicationId = org.cssnr.remotewallpaper +Debug applicationId = org.cssnr.remotewallpaper.dev minSdk = 26 targetSdk = 36 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..adfdf48 --- /dev/null +++ b/TODO.md @@ -0,0 +1,18 @@ +# TODO + +## Known Issues + +### HomeFragment.kt:310 + +DownloadResult.Downloaded wraps a closed OkHttp Response (body consumed in use{} block). +Safe today: callers only read .code/.request.url. + +FIX IF: any new caller needs response body. +FIX: store code+url strings instead of Response. + +### HomeFragment.kt:192 + +showAddDialog ignores DownloadResult; a 304 would still toast "Done.". +Unreachable in practice: request sends no validators (etag=null) and client has no Cache. + +STATUS: Intentionally not handled - unreachable dead branch.