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