package de.wechselbild.wallpaper import android.app.WallpaperManager import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix import android.graphics.Paint import android.util.Base64 import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import java.io.ByteArrayOutputStream import java.io.File import kotlin.random.Random import org.json.JSONObject object WallpaperStore { private const val PREFS = "wechselbild" private const val KEY_INDEX = "current_index" private const val CROP_PREFIX = "crop_" private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5) fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() } fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList() private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) fun enabled(context: Context) = prefs(context).getBoolean("enabled", false) fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true) fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true) fun set(context: Context, name: String, value: Boolean) { require(name in setOf("enabled", "shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" } prefs(context).edit().putBoolean(name, value).apply() } fun state(context: Context): JSObject { val originals = files(context) val index = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) val previewFiles = if (index < 24) originals.take(24) else listOf(originals[index]) + originals.take(23) val previewIndex = if (index < 24) index else 0 return JSObject().apply { put("imageCount", originals.size) put("enabled", enabled(context)) put("shuffle", shuffle(context)) put("lockScreenOnly", lockOnly(context)) put("currentIndex", previewIndex) val previews = JSArray() previewFiles.forEach { previews.put(thumbnailDataUrl(it)) } put("imageUrls", previews) } } 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 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) } } 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() return try { val json = JSONObject(raw) CropSettings( mode = if (json.optString("mode") == "contain") "contain" else "cover", zoom = json.optDouble("zoom", 1.0).coerceIn(0.35, 3.0), x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0), y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0), ) } catch (_: Exception) { CropSettings() } } private fun galleryImage(context: Context, file: File, selected: Boolean): JSObject { val crop = crop(context, file) return JSObject().apply { put("id", file.name) put("url", thumbnailDataUrl(file)) put("selected", selected) put("cropMode", crop.mode) put("cropZoom", crop.zoom) put("cropPositionX", crop.x) put("cropPositionY", crop.y) } } @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 val normalized = CropSettings( mode = if (mode == "contain") "contain" else "cover", zoom = zoom.coerceIn(0.35, 3.0), x = x.coerceIn(0.0, 1.0), y = y.coerceIn(0.0, 1.0), ) val json = JSONObject().apply { put("mode", normalized.mode) put("zoom", normalized.zoom) put("x", normalized.x) put("y", normalized.y) } 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) } @Synchronized fun delete(context: Context, id: String): Boolean { if (id.isBlank() || File(id).name != id) return false val originals = files(context) val position = originals.indexOfFirst { it.name == id } if (position < 0 || !originals[position].delete()) return false val preferences = prefs(context) val previousIndex = preferences.getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) val remaining = originals.size - 1 val nextIndex = when { remaining <= 0 -> 0 position < previousIndex -> previousIndex - 1 previousIndex >= remaining -> remaining - 1 else -> previousIndex } preferences.edit().remove(cropKey(id)).putInt(KEY_INDEX, nextIndex).apply() return true } private fun renderForScreen(context: Context, source: Bitmap, crop: CropSettings): Bitmap { 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 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 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) } canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)) } } private fun decodeForScreen(context: Context, file: File): Bitmap? { 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) 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 }) } private fun thumbnailDataUrl(file: File): String { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.absolutePath, bounds) var sample = 1 while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2 val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return "" return ByteArrayOutputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out) bitmap.recycle() "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) } } @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() } } }