feat(wallpaper): add apply_wallpaper workflow and rotation worker

Adds a new apply_wallpaper command and its plumbing across
desktop, mobile, and Android. A WorkManager-based rotation
worker handles applying wallpapers in the background. Wallpaper
state now tracks currentId and imageIds for precise control.

- Introduced apply_wallpaper command across desktop, mobile and Android
- Added WorkManager-based rotation worker to apply wallpapers in the
  background
- Extended wallpaper state with currentId and imageIds and updated schemas/permissions
This commit is contained in:
2026-08-21 23:15:00 +02:00
parent e494b17117
commit 73f8599c7f
25 changed files with 266 additions and 49 deletions
+1
View File
@@ -37,6 +37,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
implementation("androidx.work:work-runtime-ktx:2.10.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service android:name="de.wechselbild.wallpaper.WallpaperRotationService" android:exported="false" android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper at the configured interval" />
@@ -3,7 +3,6 @@ package de.wechselbild.wallpaper
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.concurrent.Executors
class WallpaperAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@@ -11,24 +10,10 @@ class WallpaperAlarmReceiver : BroadcastReceiver() {
WallpaperScheduler.cancel(context)
return
}
val applicationContext = context.applicationContext
val result = goAsync()
executor.execute {
try {
if (WallpaperScheduler.claimIfDue(applicationContext)) try {
WallpaperStore.applyNext(applicationContext)
} finally {
WallpaperScheduler.scheduleNext(applicationContext)
}
} finally {
WallpaperScheduler.ensureScheduled(applicationContext)
WallpaperRotationService.rescheduleRunningService()
result.finish()
}
if (WallpaperScheduler.claimIfDue(context)) {
WallpaperRotationWorker.enqueue(context)
}
}
companion object {
private val executor = Executors.newSingleThreadExecutor()
WallpaperScheduler.ensureScheduled(context)
WallpaperRotationService.rescheduleRunningService()
}
}
@@ -28,6 +28,9 @@ class GalleryArgs { var offset: Int = 0; var limit: Int = 48 }
@InvokeArg
class DeleteImageArgs { lateinit var id: String }
@InvokeArg
class ApplyWallpaperArgs { lateinit var id: String }
@InvokeArg
class DeleteImagesArgs { var ids: Array<String> = emptyArray() }
@@ -130,7 +133,7 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
try {
val args = invoke.parseArgs(SettingArgs::class.java)
WallpaperStore.set(activity, args.name, args.value)
invoke.resolve(WallpaperStore.state(activity, includePreviews = false))
invoke.resolve(WallpaperStore.state(activity))
} catch (error: Exception) { invoke.reject(error.message ?: "Einstellung konnte nicht gespeichert werden") }
}
@@ -139,7 +142,7 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
val args = invoke.parseArgs(IntervalArgs::class.java)
WallpaperStore.setInterval(activity, args.minutes)
if (args.minutes > 0) WallpaperRotationService.restart(activity) else WallpaperRotationService.stop(activity)
invoke.resolve(WallpaperStore.state(activity, includePreviews = false))
invoke.resolve(WallpaperStore.state(activity))
} catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") }
}
@@ -150,6 +153,14 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
} catch (error: Exception) { invoke.reject(error.message ?: "Das Hintergrundbild konnte nicht geändert werden") }
}
@Command fun applyWallpaper(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ApplyWallpaperArgs::class.java)
if (WallpaperStore.apply(activity, args.id)) invoke.resolve(WallpaperStore.state(activity))
else invoke.reject("Bild konnte nicht angewendet werden")
} catch (error: Exception) { invoke.reject(error.message ?: "Das Hintergrundbild konnte nicht geändert werden") }
}
@Command fun getImmichConnection(invoke: Invoke) = io.execute {
invoke.resolve(ImmichClient.connection(activity))
}
@@ -24,10 +24,8 @@ class WallpaperRotationService : Service() {
return
}
worker.execute {
if (WallpaperScheduler.claimIfDue(this@WallpaperRotationService)) try {
WallpaperStore.applyNext(this@WallpaperRotationService)
} finally {
WallpaperScheduler.scheduleNext(this@WallpaperRotationService)
if (WallpaperScheduler.claimIfDue(this@WallpaperRotationService)) {
WallpaperRotationWorker.enqueue(this@WallpaperRotationService)
}
handler.post { scheduleLocalTimer() }
}
@@ -0,0 +1,28 @@
package de.wechselbild.wallpaper
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
override fun doWork(): Result {
if (!WallpaperStore.enabled(applicationContext)) return Result.success()
if (WallpaperStore.applyNext(applicationContext)) return Result.success()
return if (runAttemptCount < 2) Result.retry() else Result.failure()
}
companion object {
private const val WORK_NAME = "wallpaper-rotation"
fun enqueue(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<WallpaperRotationWorker>().build(),
)
}
}
}
@@ -19,7 +19,8 @@ object WallpaperScheduler {
}
val now = SystemClock.elapsedRealtime()
val storedTrigger = preferences(context).getLong(KEY_NEXT_TRIGGER, 0L)
if (storedTrigger <= now) scheduleNext(context)
val interval = WallpaperStore.intervalMinutes(context) * 60_000L
if (storedTrigger <= now || storedTrigger > now + interval) scheduleNext(context)
}
fun remainingDelay(context: Context): Long {
@@ -32,10 +33,11 @@ object WallpaperScheduler {
val preferences = preferences(context)
val trigger = preferences.getLong(KEY_NEXT_TRIGGER, 0L)
if (trigger <= 0L || trigger > SystemClock.elapsedRealtime() + 1_000L) return false
preferences.edit().putLong(KEY_NEXT_TRIGGER, Long.MAX_VALUE).commit()
scheduleNext(context)
return true
}
@Synchronized
fun scheduleNext(context: Context) {
val minutes = WallpaperStore.intervalMinutes(context)
if (minutes <= 0) {
@@ -50,7 +52,7 @@ object WallpaperScheduler {
} else {
alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation)
}
preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).apply()
preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).commit()
}
fun cancel(context: Context) {
@@ -119,6 +119,10 @@ object WallpaperStore {
put("shuffle", shuffle(context))
put("lockScreenOnly", lockOnly(context))
put("currentIndex", previewIndex)
put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL)
val ids = JSArray()
previewItems.forEach { ids.put(it.id) }
put("imageIds", ids)
val previews = JSArray()
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(it.preview)) }
put("imageUrls", previews)
@@ -318,6 +322,18 @@ object WallpaperStore {
val candidates = if (shuffle(context) && items.size > 1) {
items.indices.filter { it != previous }.shuffled() + previous
} else (1..items.size).map { (previous + it).mod(items.size) }
return applyCandidates(context, items, candidates)
}
@Synchronized
fun apply(context: Context, id: String): Boolean {
val items = entries(context)
val index = items.indexOfFirst { it.id == id }
if (index < 0) return false
return applyCandidates(context, items, listOf(index))
}
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>): Boolean {
val unavailableServers = mutableSetOf<String>()
for (index in candidates) {
val entry = items[index]
+1
View File
@@ -9,6 +9,7 @@ const COMMANDS: &[&str] = &[
"set_setting",
"set_interval",
"next_wallpaper",
"apply_wallpaper",
"get_immich_connection",
"connect_immich",
"disconnect_immich",
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-apply-wallpaper"
description = "Enables the apply_wallpaper command without any pre-configured scope."
commands.allow = ["apply_wallpaper"]
[[permission]]
identifier = "deny-apply-wallpaper"
description = "Denies the apply_wallpaper command without any pre-configured scope."
commands.deny = ["apply_wallpaper"]
@@ -14,6 +14,7 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
- `allow-set-setting`
- `allow-set-interval`
- `allow-next-wallpaper`
- `allow-apply-wallpaper`
- `allow-get-immich-connection`
- `allow-connect-immich`
- `allow-disconnect-immich`
@@ -31,6 +32,32 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
</tr>
<tr>
<td>
`wallpaper:allow-apply-wallpaper`
</td>
<td>
Enables the apply_wallpaper command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-apply-wallpaper`
</td>
<td>
Denies the apply_wallpaper command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
+1 -1
View File
@@ -1,3 +1,3 @@
[default]
description = "Allow the LockScreenWallpaper app to manage its wallpaper collection"
permissions = ["allow-get-state", "allow-get-gallery", "allow-select-images", "allow-delete-image", "allow-get-image-ids", "allow-delete-images", "allow-set-image-crop", "allow-set-setting", "allow-set-interval", "allow-next-wallpaper", "allow-get-immich-connection", "allow-connect-immich", "allow-disconnect-immich", "allow-get-immich-albums", "allow-get-immich-assets", "allow-import-immich-assets", "allow-get-immich-import-progress"]
permissions = ["allow-get-state", "allow-get-gallery", "allow-select-images", "allow-delete-image", "allow-get-image-ids", "allow-delete-images", "allow-set-image-crop", "allow-set-setting", "allow-set-interval", "allow-next-wallpaper", "allow-apply-wallpaper", "allow-get-immich-connection", "allow-connect-immich", "allow-disconnect-immich", "allow-get-immich-albums", "allow-get-immich-assets", "allow-import-immich-assets", "allow-get-immich-import-progress"]
+14 -2
View File
@@ -294,6 +294,18 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the apply_wallpaper command without any pre-configured scope.",
"type": "string",
"const": "allow-apply-wallpaper",
"markdownDescription": "Enables the apply_wallpaper command without any pre-configured scope."
},
{
"description": "Denies the apply_wallpaper command without any pre-configured scope.",
"type": "string",
"const": "deny-apply-wallpaper",
"markdownDescription": "Denies the apply_wallpaper command without any pre-configured scope."
},
{
"description": "Enables the connect_immich command without any pre-configured scope.",
"type": "string",
@@ -499,10 +511,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-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`\n- `allow-get-immich-import-progress`",
"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-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-apply-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`\n- `allow-get-immich-import-progress`",
"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-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`\n- `allow-get-immich-import-progress`"
"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-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-apply-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`\n- `allow-get-immich-import-progress`"
}
]
}
+4
View File
@@ -71,6 +71,10 @@ pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: usize)
pub(crate) async fn next_wallpaper<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> {
app.wallpaper().next_wallpaper()
}
#[command]
pub(crate) async fn apply_wallpaper<R: Runtime>(app: AppHandle<R>, id: String) -> Result<WallpaperState> {
app.wallpaper().apply_wallpaper(ApplyWallpaperRequest { id })
}
#[command]
pub(crate) async fn get_immich_connection<R: Runtime>(app: AppHandle<R>) -> Result<ImmichConnection> {
+11
View File
@@ -22,6 +22,8 @@ impl<R: Runtime> Wallpaper<R> {
shuffle: true,
lock_screen_only: true,
current_index: 0,
current_id: Some("demo-0".into()),
image_ids: vec!["demo-0".into(), "demo-1".into(), "demo-2".into()],
image_urls: vec![
"/wallpapers/alpine.png".into(),
"/wallpapers/waterfall.png".into(),
@@ -100,6 +102,15 @@ impl<R: Runtime> Wallpaper<R> {
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
let mut state = Self::demo();
state.current_index = 1;
state.current_id = Some("demo-1".into());
Ok(state)
}
pub fn apply_wallpaper(&self, payload: ApplyWallpaperRequest) -> crate::Result<WallpaperState> {
let mut state = Self::demo();
if let Some(index) = state.image_ids.iter().position(|id| id == &payload.id) {
state.current_index = index;
state.current_id = Some(payload.id);
}
Ok(state)
}
pub fn get_immich_connection(&self) -> crate::Result<ImmichConnection> {
+1
View File
@@ -46,6 +46,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_setting,
commands::set_interval,
commands::next_wallpaper,
commands::apply_wallpaper,
commands::get_immich_connection,
commands::connect_immich,
commands::disconnect_immich,
+5
View File
@@ -70,6 +70,11 @@ impl<R: Runtime> Wallpaper<R> {
.run_mobile_plugin("nextWallpaper", ())
.map_err(Into::into)
}
pub fn apply_wallpaper(&self, payload: ApplyWallpaperRequest) -> crate::Result<WallpaperState> {
self.0
.run_mobile_plugin("applyWallpaper", payload)
.map_err(Into::into)
}
pub fn get_immich_connection(&self) -> crate::Result<ImmichConnection> {
self.0.run_mobile_plugin("getImmichConnection", ()).map_err(Into::into)
}
+8
View File
@@ -9,6 +9,8 @@ pub struct WallpaperState {
pub shuffle: bool,
pub lock_screen_only: bool,
pub current_index: usize,
pub current_id: Option<String>,
pub image_ids: Vec<String>,
pub image_urls: Vec<String>,
}
@@ -38,6 +40,12 @@ pub struct DeleteImageRequest {
pub id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyWallpaperRequest {
pub id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteImagesRequest {