Files
LockScreenWallpaper/plugins/android/src/main/java/WallpaperStore.kt
T
Christoph 197090ddd2 feat(wallpapers): add image crop support and i18n strings
Adds per-image image-crop support with persistent settings.
Rendering adapts to crop mode, zoom and position per image.
Locales the UI strings and notifications; app name updated.

- Cropping workflow with per-image crop state and rendering
- Localizes strings and notifications for en and de
- Introduces set_image_crop command and its permission schema
2026-08-20 23:26:15 +02:00

212 lines
8.8 KiB
Kotlin

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()
}
}
}