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
This commit is contained in:
2026-08-20 23:26:15 +02:00
parent 2cfc7c5dcb
commit 197090ddd2
23 changed files with 622 additions and 74 deletions
@@ -23,6 +23,15 @@ class GalleryArgs { var offset: Int = 0; var limit: Int = 48 }
@InvokeArg
class DeleteImageArgs { lateinit var id: String }
@InvokeArg
class ImageCropArgs {
lateinit var id: String
lateinit var mode: String
var zoom: Double = 1.0
var positionX: Double = 0.5
var positionY: Double = 0.5
}
@TauriPlugin
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
private val io = Executors.newSingleThreadExecutor()
@@ -54,6 +63,15 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
} catch (error: Exception) { invoke.reject(error.message ?: "Bild konnte nicht gelöscht werden") }
}
@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)
?: throw IllegalArgumentException("Bild wurde nicht gefunden")
invoke.resolve(image)
} catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") }
}
@ActivityCallback
fun selectedImages(invoke: Invoke, result: ActivityResult) {
if (result.resultCode != Activity.RESULT_OK) { invoke.reject("Bildauswahl abgebrochen"); return }
@@ -29,8 +29,8 @@ class WallpaperRotationService : Service() {
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val notification = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_gallery)
.setContentTitle("LockScreenWallpaper ist aktiv")
.setContentText("Das Motiv wechselt beim Aktivieren des Displays.")
.setContentTitle(getString(R.string.wallpaper_service_title))
.setContentText(getString(R.string.wallpaper_service_text))
.setOngoing(true).setSilent(true).setContentIntent(pending).build()
startForeground(NOTIFICATION_ID, notification)
ContextCompat.registerReceiver(this, screenReceiver, IntentFilter(Intent.ACTION_SCREEN_ON), ContextCompat.RECEIVER_NOT_EXPORTED)
@@ -42,7 +42,7 @@ class WallpaperRotationService : Service() {
private fun createChannel() {
if (Build.VERSION.SDK_INT >= 26) {
val channel = NotificationChannel(CHANNEL, "Automatischer Bildwechsel", NotificationManager.IMPORTANCE_LOW)
val channel = NotificationChannel(CHANNEL, getString(R.string.wallpaper_channel_name), NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
+104 -10
View File
@@ -4,16 +4,23 @@ 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)
@@ -51,11 +58,7 @@ object WallpaperStore {
val safeLimit = limit.coerceIn(1, 100)
val items = JSArray()
originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file ->
items.put(JSObject().apply {
put("id", file.name)
put("url", thumbnailDataUrl(file))
put("selected", safeOffset + pageIndex == selectedIndex)
})
items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex))
}
return JSObject().apply {
put("total", originals.size)
@@ -63,6 +66,55 @@ object WallpaperStore {
}
}
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
@@ -79,10 +131,48 @@ object WallpaperStore {
previousIndex >= remaining -> remaining - 1
else -> previousIndex
}
preferences.edit().putInt(KEY_INDEX, nextIndex).apply()
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)
@@ -105,13 +195,17 @@ object WallpaperStore {
val index = if (shuffle(context) && images.size > 1) {
generateSequence { Random.nextInt(images.size) }.first { it != previous }
} else (previous + 1).mod(images.size)
val bitmap = BitmapFactory.decodeFile(images[index].absolutePath) ?: return false
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(bitmap, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(bitmap)
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 { bitmap.recycle() }
} finally {
rendered.recycle()
bitmap.recycle()
}
}
}
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
<string name="wallpaper_service_text">Das Motiv wechselt beim Aktivieren des Displays.</string>
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow is active</string>
<string name="wallpaper_service_text">The wallpaper changes whenever the screen wakes.</string>
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string>
</resources>
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-image-crop"
description = "Enables the set_image_crop command without any pre-configured scope."
commands.allow = ["set_image_crop"]
[[permission]]
identifier = "deny-set-image-crop"
description = "Denies the set_image_crop command without any pre-configured scope."
commands.deny = ["set_image_crop"]
@@ -8,6 +8,7 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
- `allow-get-gallery`
- `allow-select-images`
- `allow-delete-image`
- `allow-set-image-crop`
- `allow-set-setting`
- `allow-next-wallpaper`
@@ -153,6 +154,32 @@ Denies the select_images command without any pre-configured scope.
<tr>
<td>
`wallpaper:allow-set-image-crop`
</td>
<td>
Enables the set_image_crop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-set-image-crop`
</td>
<td>
Denies the set_image_crop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-set-setting`
</td>
+14 -2
View File
@@ -354,6 +354,18 @@
"const": "deny-select-images",
"markdownDescription": "Denies the select_images command without any pre-configured scope."
},
{
"description": "Enables the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "allow-set-image-crop",
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
},
{
"description": "Denies the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "deny-set-image-crop",
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
},
{
"description": "Enables the set_setting command without any pre-configured scope.",
"type": "string",
@@ -367,10 +379,10 @@
"markdownDescription": "Denies the set_setting command without any pre-configured scope."
},
{
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"type": "string",
"const": "default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
}
]
}
+9 -9
View File
@@ -38,15 +38,15 @@ impl<R: Runtime> Wallpaper<R> {
.enumerate()
.skip(payload.offset)
.take(payload.limit)
.map(|(index, url)| GalleryImage {
id: format!("demo-{index}"),
url,
selected: index == 0,
crop_mode: "cover".into(),
crop_zoom: 1.0,
crop_position_x: 0.5,
crop_position_y: 0.5,
})
.map(|(index, url)| GalleryImage {
id: format!("demo-{index}"),
url,
selected: index == 0,
crop_mode: "cover".into(),
crop_zoom: 1.0,
crop_position_x: 0.5,
crop_position_y: 0.5,
})
.collect();
Ok(GalleryPage { total: 3, items })
}