feat(wallpapers): add Immich integration with caching and rotation

This patch adds Immich integration for remote images with local
caching and pruning.
It adds image rotation to the crop flow and stores rotation
with the crop state.
It adds progress tracking and size checks during image
download to avoid oversized assets.

- Add Immich caching with size limits and automatic pruning
- Extend crop handling to support rotation and persist it
- Improve download progress and error messaging
This commit is contained in:
2026-08-21 22:01:42 +02:00
parent 789b58398a
commit e494b17117
11 changed files with 343 additions and 137 deletions
+76 -22
View File
@@ -15,6 +15,7 @@ import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import java.security.MessageDigest
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
@@ -32,6 +33,9 @@ object ImmichClient {
private const val KEY_API_KEY_DATA = "immich_api_key_data"
private const val KEY_API_KEY_IV = "immich_api_key_iv"
private const val KEY_ALIAS = "wallpaperflow_immich_api_key"
private const val CACHE_MAX_FILES = 3
private const val CACHE_MAX_BYTES = 256L * 1024 * 1024
private const val DOWNLOAD_MAX_BYTES = 128L * 1024 * 1024
private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
private val thumbnailPool = Executors.newFixedThreadPool(4)
private val importProgress = AtomicReference(ImportProgress())
@@ -193,13 +197,14 @@ object ImmichClient {
} finally { connection.disconnect() }
}
private fun downloadToFile(serverUrl: String, apiKey: String, path: String, target: File): String {
private fun downloadToFile(serverUrl: String, apiKey: String, path: String, target: File, trackProgress: Boolean = true): String {
val connection = openConnection(serverUrl, apiKey, path)
try {
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
val total = connection.contentLengthLong.coerceAtLeast(0)
require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
var downloaded = 0L
importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) }
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) }
connection.inputStream.use { input ->
target.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
@@ -208,7 +213,8 @@ object ImmichClient {
if (count < 0) break
output.write(buffer, 0, count)
downloaded += count
importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
}
}
}
@@ -275,32 +281,80 @@ object ImmichClient {
}
}
private fun extension(contentType: String) = when (contentType.lowercase()) {
"image/png" -> "png"
"image/webp" -> "webp"
"image/heic", "image/heif" -> "heic"
else -> "jpg"
}
private fun importOne(context: Context, credentials: Credentials, assetId: String) {
require(assetIdPattern.matches(assetId)) { "Ungültige Immich-Bild-ID." }
if (WallpaperStore.files(context).any { it.name.startsWith("immich-$assetId.") }) return
if (WallpaperStore.hasImmich(context, credentials.serverUrl, assetId)) return
val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
val directory = WallpaperStore.directory(context)
val temporary = File(directory, ".immich-$assetId.download")
var contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/original", temporary)
val temporary = File(context.cacheDir, ".immich-preview-$assetId.download")
downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(temporary.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
temporary.delete()
contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary)
BitmapFactory.decodeFile(temporary.absolutePath, bounds)
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat kein unterstütztes Bildformat geliefert." }
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat keine gültige Vorschau geliefert." }
try { WallpaperStore.addImmich(context, credentials.serverUrl, assetId, temporary) }
finally { temporary.delete() }
}
private fun cacheDirectory(context: Context) = File(context.cacheDir, "immich-wallpaper-originals").apply { mkdirs() }
private fun cacheKey(serverUrl: String, assetId: String): String {
val server = MessageDigest.getInstance("SHA-256").digest(serverUrl.toByteArray(StandardCharsets.UTF_8))
.take(6).joinToString("") { "%02x".format(it) }
return "$server-$assetId"
}
private fun validImage(file: File): Boolean {
if (!file.isFile || file.length() <= 0) return false
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
return bounds.outWidth > 0 && bounds.outHeight > 0
}
@Synchronized
fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? {
val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image")
if (validImage(target)) {
target.setLastModified(System.currentTimeMillis())
return target
}
val target = File(directory, "immich-$assetId.${extension(contentType)}")
if (!temporary.renameTo(target)) {
temporary.copyTo(target, overwrite = true)
target.delete()
if (!allowNetwork) return null
val current = runCatching { credentials(context) }.getOrNull() ?: return null
if (current.serverUrl != serverUrl || !assetIdPattern.matches(assetId)) return null
val temporary = File(cacheDirectory(context), ".${cacheKey(serverUrl, assetId)}.download")
val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
return try {
downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/original", temporary, trackProgress = false)
if (!validImage(temporary)) {
temporary.delete()
downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary, trackProgress = false)
}
if (!validImage(temporary)) return null
if (!temporary.renameTo(target)) { temporary.copyTo(target, overwrite = true); temporary.delete() }
target.setLastModified(System.currentTimeMillis())
trimCache(context, target)
target
} catch (_: Exception) {
temporary.delete()
null
}
}
@Synchronized
fun deleteCachedOriginal(context: Context, serverUrl: String, assetId: String) {
val key = cacheKey(serverUrl, assetId)
File(cacheDirectory(context), "$key.image").delete()
File(cacheDirectory(context), ".$key.download").delete()
}
private fun trimCache(context: Context, protected: File) {
val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedByDescending { it.lastModified() } ?: return
var bytes = 0L
files.forEachIndexed { index, file ->
bytes += file.length()
if (file != protected && (index >= CACHE_MAX_FILES || bytes > CACHE_MAX_BYTES)) {
bytes -= file.length()
file.delete()
}
}
}
@@ -38,6 +38,7 @@ class ImageCropArgs {
var zoom: Double = 1.0
var positionX: Double = 0.5
var positionY: Double = 0.5
var rotation: Int = 0
}
@InvokeArg
@@ -99,7 +100,7 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
@Command fun setImageCrop(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImageCropArgs::class.java)
val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY)
val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY, args.rotation)
?: throw IllegalArgumentException("Bild wurde nicht gefunden")
invoke.resolve(image)
} catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") }
@@ -143,7 +144,10 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
}
@Command fun nextWallpaper(invoke: Invoke) = io.execute {
if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity)) else invoke.reject("Bitte wähle zuerst Bilder aus")
try {
if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity))
else invoke.reject("Kein verfügbares Bild konnte angewendet werden")
} catch (error: Exception) { invoke.reject(error.message ?: "Das Hintergrundbild konnte nicht geändert werden") }
}
@Command fun getImmichConnection(invoke: Invoke) = io.execute {
+200 -88
View File
@@ -14,21 +14,79 @@ import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.random.Random
import java.security.MessageDigest
import org.json.JSONObject
object WallpaperStore {
private const val PREFS = "wechselbild"
private const val KEY_INDEX = "current_index"
private const val KEY_CURRENT_ID = "current_entry_id"
private const val KEY_INTERVAL = "interval_minutes"
private const val CROP_PREFIX = "crop_"
private const val HOME_PREVIEW_LIMIT = 12
private val thumbnailCache = LruCache<String, String>(48)
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5)
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5, val rotation: Int = 0)
private sealed class Entry {
abstract val id: String
abstract val preview: File
data class Local(val file: File) : Entry() { override val id = file.name; override val preview = file }
data class Immich(
override val id: String,
val assetId: String,
val serverUrl: String,
val addedAt: Long,
val metadata: File,
override val preview: File,
) : Entry()
}
fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() }
fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList()
fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.name } ?: emptyList()
private fun immichRoot(context: Context) = File(context.filesDir, "immich-wallpapers").apply { mkdirs() }
private fun immichEntries(context: Context) = File(immichRoot(context), "entries").apply { mkdirs() }
private fun immichPreviews(context: Context) = File(immichRoot(context), "previews").apply { mkdirs() }
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun serverKey(serverUrl: String): String = MessageDigest.getInstance("SHA-256")
.digest(serverUrl.toByteArray(Charsets.UTF_8)).take(6).joinToString("") { "%02x".format(it) }
private fun virtualKey(serverUrl: String, assetId: String) = "${serverKey(serverUrl)}-$assetId"
private fun virtualId(serverUrl: String, assetId: String) = "immich:${serverKey(serverUrl)}:$assetId"
private fun entries(context: Context): List<Entry> {
val local = files(context).map { Entry.Local(it) }
val virtual = immichEntries(context).listFiles()?.filter { it.isFile && it.extension == "json" }?.mapNotNull { metadata ->
runCatching {
val json = JSONObject(metadata.readText())
val serverUrl = json.getString("serverUrl")
val assetId = json.getString("assetId")
val preview = File(immichPreviews(context), "${virtualKey(serverUrl, assetId)}.preview")
if (!preview.isFile) return@runCatching null
Entry.Immich(
id = virtualId(serverUrl, assetId),
assetId = assetId,
serverUrl = serverUrl,
addedAt = json.optLong("addedAt", metadata.lastModified()),
metadata = metadata,
preview = preview,
)
}.getOrNull()
}?.filterNotNull()?.sortedWith(compareBy<Entry.Immich> { it.addedAt }.thenBy { it.id }) ?: emptyList()
return local + virtual
}
private fun currentIndex(context: Context, items: List<Entry>): Int {
if (items.isEmpty()) return 0
val preferences = prefs(context)
val currentId = preferences.getString(KEY_CURRENT_ID, null)
val byId = items.indexOfFirst { it.id == currentId }
if (byId >= 0) return byId
val legacy = preferences.getInt(KEY_INDEX, 0).coerceIn(0, items.lastIndex)
preferences.edit().putString(KEY_CURRENT_ID, items[legacy].id).apply()
return legacy
}
fun intervalMinutes(context: Context): Int {
val preferences = prefs(context)
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0)
@@ -48,45 +106,44 @@ object WallpaperStore {
prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes > 0).apply()
}
@Synchronized
fun state(context: Context, includePreviews: Boolean = true): JSObject {
val originals = files(context)
val index = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
val previewFiles = if (index < HOME_PREVIEW_LIMIT) originals.take(HOME_PREVIEW_LIMIT) else listOf(originals[index]) + originals.take(HOME_PREVIEW_LIMIT - 1)
val items = entries(context)
val index = currentIndex(context, items)
val previewItems = if (index < HOME_PREVIEW_LIMIT) items.take(HOME_PREVIEW_LIMIT) else listOf(items[index]) + items.take(HOME_PREVIEW_LIMIT - 1)
val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0
return JSObject().apply {
put("imageCount", originals.size)
put("imageCount", items.size)
put("enabled", enabled(context))
put("intervalMinutes", intervalMinutes(context))
put("shuffle", shuffle(context))
put("lockScreenOnly", lockOnly(context))
put("currentIndex", previewIndex)
val previews = JSArray()
if (includePreviews) previewFiles.forEach { previews.put(thumbnailDataUrl(it)) }
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(it.preview)) }
put("imageUrls", previews)
}
}
@Synchronized
fun gallery(context: Context, offset: Int, limit: Int): JSObject {
val originals = files(context)
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(originals.size)
val items = entries(context)
val selectedIndex = currentIndex(context, items)
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size)
val safeLimit = limit.coerceIn(1, 100)
val items = JSArray()
originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file ->
items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex))
}
return JSObject().apply {
put("total", originals.size)
put("items", items)
val page = JSArray()
items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry ->
page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex))
}
return JSObject().apply { put("total", items.size); put("items", page) }
}
fun imageIds(context: Context) = files(context).map { it.name }
@Synchronized
fun imageIds(context: Context) = entries(context).map { it.id }
private fun cropKey(id: String) = CROP_PREFIX + id
private fun crop(context: Context, file: File): CropSettings {
val raw = prefs(context).getString(cropKey(file.name), null) ?: return CropSettings()
private fun crop(context: Context, entry: Entry): CropSettings {
val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
return try {
val json = JSONObject(raw)
CropSettings(
@@ -94,68 +151,103 @@ object WallpaperStore {
zoom = json.optDouble("zoom", 1.0).coerceIn(1.0, 3.0),
x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0),
y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0),
rotation = json.optInt("rotation", 0).let { ((it % 360) + 360) % 360 }.let { if (it % 90 == 0) it else 0 },
)
} catch (_: Exception) { CropSettings() }
}
private fun galleryImage(context: Context, file: File, selected: Boolean): JSObject {
val crop = crop(context, file)
private fun galleryImage(context: Context, entry: Entry, selected: Boolean): JSObject {
val crop = crop(context, entry)
return JSObject().apply {
put("id", file.name)
put("url", thumbnailDataUrl(file))
put("id", entry.id)
put("url", thumbnailDataUrl(entry.preview))
put("selected", selected)
put("cropMode", crop.mode)
put("cropZoom", crop.zoom)
put("cropPositionX", crop.x)
put("cropPositionY", crop.y)
put("cropRotation", crop.rotation)
}
}
@Synchronized
fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double): JSObject? {
if (id.isBlank() || File(id).name != id) return null
val file = files(context).firstOrNull { it.name == id } ?: return null
fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double, rotation: Int): JSObject? {
val items = entries(context)
val entry = items.firstOrNull { it.id == id } ?: return null
val normalized = CropSettings(
mode = if (mode == "contain") "contain" else "cover",
zoom = zoom.coerceIn(1.0, 3.0),
x = x.coerceIn(0.0, 1.0),
y = y.coerceIn(0.0, 1.0),
zoom = zoom.coerceIn(1.0, 3.0), x = x.coerceIn(0.0, 1.0), y = y.coerceIn(0.0, 1.0),
rotation = ((rotation % 360) + 360) % 360,
)
require(normalized.rotation % 90 == 0) { "Ungültige Bilddrehung" }
val json = JSONObject().apply {
put("mode", normalized.mode)
put("zoom", normalized.zoom)
put("x", normalized.x)
put("y", normalized.y)
put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation)
}
prefs(context).edit().putString(cropKey(id), json.toString()).apply()
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0)
return galleryImage(context, file, files(context).indexOf(file) == selectedIndex)
return galleryImage(context, entry, items.indexOf(entry) == currentIndex(context, items))
}
@Synchronized
fun delete(context: Context, id: String): Boolean {
return deleteMany(context, listOf(id)) == 1
fun addImmich(context: Context, serverUrl: String, assetId: String, previewSource: File): Boolean {
val key = virtualKey(serverUrl, assetId)
val metadata = File(immichEntries(context), "$key.json")
val preview = File(immichPreviews(context), "$key.preview")
if (metadata.isFile && preview.isFile) return false
val previewTemp = File(immichPreviews(context), ".$key.preview.tmp")
val metadataTemp = File(immichEntries(context), ".$key.json.tmp")
try {
previewSource.copyTo(previewTemp, overwrite = true)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(previewTemp.absolutePath, bounds)
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich-Vorschau konnte nicht gelesen werden." }
if (!previewTemp.renameTo(preview)) { previewTemp.copyTo(preview, overwrite = true); previewTemp.delete() }
metadataTemp.writeText(JSONObject().apply {
put("version", 1); put("serverUrl", serverUrl); put("assetId", assetId); put("addedAt", System.currentTimeMillis())
}.toString())
if (!metadataTemp.renameTo(metadata)) { metadataTemp.copyTo(metadata, overwrite = true); metadataTemp.delete() }
return true
} catch (error: Exception) {
previewTemp.delete()
metadataTemp.delete()
if (!metadata.isFile) preview.delete()
throw error
}
}
fun hasImmich(context: Context, serverUrl: String, assetId: String) = File(immichEntries(context), "${virtualKey(serverUrl, assetId)}.json").isFile
@Synchronized
fun delete(context: Context, id: String) = deleteMany(context, listOf(id)) == 1
@Synchronized
fun deleteMany(context: Context, ids: List<String>): Int {
val requested = ids.filter { it.isNotBlank() && File(it).name == it }.toSet()
if (requested.isEmpty()) return 0
val originals = files(context)
val preferences = prefs(context)
val previousIndex = preferences.getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
val currentName = originals.getOrNull(previousIndex)?.name
val deleted = originals.filter { it.name in requested && it.delete() }
val requested = ids.toSet()
val items = entries(context)
val current = items.getOrNull(currentIndex(context, items))
val deleted = items.filter { it.id in requested }.filter { entry ->
when (entry) {
is Entry.Local -> entry.file.delete().also { if (it) removeThumbnail(entry.preview) }
is Entry.Immich -> {
entry.metadata.delete().also { removed ->
if (removed) {
removeThumbnail(entry.preview)
entry.preview.delete()
ImmichClient.deleteCachedOriginal(context, entry.serverUrl, entry.assetId)
}
}
}
}
}
if (deleted.isEmpty()) return 0
val remaining = files(context)
val retainedCurrent = currentName?.let { name -> remaining.indexOfFirst { it.name == name } } ?: -1
val nextIndex = when {
remaining.isEmpty() -> 0
retainedCurrent >= 0 -> retainedCurrent
else -> previousIndex.coerceAtMost(remaining.lastIndex)
val editor = prefs(context).edit()
deleted.forEach { editor.remove(cropKey(it.id)) }
val remaining = entries(context)
val retained = current?.let { item -> remaining.indexOfFirst { it.id == item.id } } ?: -1
if (remaining.isEmpty()) editor.remove(KEY_CURRENT_ID).putInt(KEY_INDEX, 0)
else {
val next = if (retained >= 0) retained else currentIndex(context, items).coerceAtMost(remaining.lastIndex)
editor.putString(KEY_CURRENT_ID, remaining[next].id).putInt(KEY_INDEX, next)
}
val editor = preferences.edit().putInt(KEY_INDEX, nextIndex)
deleted.forEach { editor.remove(cropKey(it.name)) }
editor.apply()
return deleted.size
}
@@ -164,21 +256,25 @@ object WallpaperStore {
val metrics = context.resources.displayMetrics
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
val widthScale = targetWidth.toDouble() / source.width
val heightScale = targetHeight.toDouble() / source.height
val quarterTurn = crop.rotation == 90 || crop.rotation == 270
val rotatedWidth = if (quarterTurn) source.height else source.width
val rotatedHeight = if (quarterTurn) source.width else source.height
val widthScale = targetWidth.toDouble() / rotatedWidth
val heightScale = targetHeight.toDouble() / rotatedHeight
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
val scale = (baseScale * crop.zoom).toFloat()
val scaledWidth = source.width * scale
val scaledHeight = source.height * scale
val scaledWidth = rotatedWidth * scale
val scaledHeight = rotatedHeight * scale
val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
val canvas = Canvas(output)
canvas.drawColor(Color.BLACK)
val matrix = Matrix().apply {
setScale(scale, scale)
postTranslate(left, top)
postTranslate(-source.width / 2f, -source.height / 2f)
postRotate(crop.rotation.toFloat())
postScale(scale, scale)
postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f)
}
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
}
@@ -188,14 +284,10 @@ object WallpaperStore {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val metrics = context.resources.displayMetrics
val targetLongSide = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
})
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 })
}
private fun thumbnailDataUrl(file: File): String {
@@ -207,34 +299,54 @@ object WallpaperStore {
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
val result = ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out)
bitmap.recycle()
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle()
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
thumbnailCache.put(cacheKey, result)
return result
}
private fun removeThumbnail(file: File) {
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}")
}
@Synchronized
fun applyNext(context: Context): Boolean {
val images = files(context)
if (images.isEmpty()) return false
val preferences = prefs(context)
val previous = preferences.getInt(KEY_INDEX, -1)
val index = if (shuffle(context) && images.size > 1) {
generateSequence { Random.nextInt(images.size) }.first { it != previous }
} else (previous + 1).mod(images.size)
val bitmap = decodeForScreen(context, images[index]) ?: return false
val rendered = renderForScreen(context, bitmap, crop(context, images[index]))
try {
val manager = WallpaperManager.getInstance(context)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(rendered)
preferences.edit().putInt(KEY_INDEX, index).apply()
return true
} finally {
rendered.recycle()
bitmap.recycle()
val items = entries(context)
if (items.isEmpty()) return false
val previous = currentIndex(context, items)
val candidates = if (shuffle(context) && items.size > 1) {
items.indices.filter { it != previous }.shuffled() + previous
} else (1..items.size).map { (previous + it).mod(items.size) }
val unavailableServers = mutableSetOf<String>()
for (index in candidates) {
val entry = items[index]
val sourceFile = when (entry) {
is Entry.Local -> entry.file
is Entry.Immich -> {
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, entry.serverUrl !in unavailableServers)
if (source == null) {
unavailableServers.add(entry.serverUrl)
continue
}
source
}
}
val bitmap = decodeForScreen(context, sourceFile) ?: continue
val rendered = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull()
if (rendered == null) { bitmap.recycle(); continue }
try {
val manager = WallpaperManager.getInstance(context)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(rendered)
prefs(context).edit().putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
return true
} catch (_: Exception) {
// Try the next usable entry without changing the current selection.
} finally {
rendered.recycle(); bitmap.recycle()
}
}
return false
}
}
+2
View File
@@ -44,6 +44,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
zoom: f64,
position_x: f64,
position_y: f64,
rotation: i32,
) -> Result<GalleryImage> {
app.wallpaper().set_image_crop(ImageCropRequest {
id,
@@ -51,6 +52,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
zoom,
position_x,
position_y,
rotation,
})
}
#[command]
+2
View File
@@ -47,6 +47,7 @@ impl<R: Runtime> Wallpaper<R> {
crop_zoom: 1.0,
crop_position_x: 0.5,
crop_position_y: 0.5,
crop_rotation: 0,
})
.collect();
Ok(GalleryPage { total: 3, items })
@@ -77,6 +78,7 @@ impl<R: Runtime> Wallpaper<R> {
crop_zoom: payload.zoom,
crop_position_x: payload.position_x,
crop_position_y: payload.position_y,
crop_rotation: payload.rotation,
})
}
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> {
+2
View File
@@ -58,6 +58,7 @@ pub struct ImageCropRequest {
pub zoom: f64,
pub position_x: f64,
pub position_y: f64,
pub rotation: i32,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -70,6 +71,7 @@ pub struct GalleryImage {
pub crop_zoom: f64,
pub crop_position_x: f64,
pub crop_position_y: f64,
pub crop_rotation: i32,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]