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
212 lines
8.8 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|