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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user