Skip to content
Merged
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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ 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

applicationId = org.cssnr.remotewallpaper
Debug applicationId = org.cssnr.remotewallpaper.dev

minSdk = 26
targetSdk = 36
compileSdk = 37

## Commands

ALWAYS use the `task *` commands
Expand All @@ -23,6 +32,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!!!
18 changes: 18 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 19 additions & 1 deletion app/src/main/java/org/cssnr/remotewallpaper/db/RemoteDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -36,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()

Expand Down Expand Up @@ -68,17 +76,26 @@ 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

companion object {
@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<Remote> = listOf(
Remote("https://picsum.photos/4800/2400", active = true),
Remote("https://picsum.photos/4800/2400?blur=10", active = false),
Expand All @@ -103,6 +120,7 @@ abstract class RemoteDatabase : RoomDatabase() {
}
}
})
.addMigrations(MIGRATION_1_2)
.build().also { instance = it }
}
}
Expand Down
59 changes: 48 additions & 11 deletions app/src/main/java/org/cssnr/remotewallpaper/ui/home/HomeFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -246,19 +261,27 @@ 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")

val body = it.body
val imageFile = File(filesDir, "wallpaper.img")

Expand All @@ -269,8 +292,22 @@ fun Context.downloadImage(url: String): Response {
}

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 response
return DownloadResult.Downloaded(response)
}

fun Context.setAutoCroppedWallpaper(imageFile: File) {
Expand Down
Loading