From 69600321c88abed1383edecbd1c7c16da78cbeda Mon Sep 17 00:00:00 2001 From: codenameakshay Date: Tue, 15 Sep 2026 23:37:28 +0530 Subject: [PATCH] Reload live wallpapers when the same component is set again When the plugin's live wallpaper is already active and the user sets it again, Android 11+ keeps the running engine and sends it the "android.wallpaper.reapply" command instead of rebinding. Neither the video nor the OpenGL engine handled that command, so the home screen kept playing the previous video or shader even though the preview had shown the new one. The video engine now replaces its player, and the OpenGL render thread swaps in the saved configuration, when the command arrives. Android 10 and older never send it; the docs say so. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + .../async_wallpaper/OpenGlLiveWallpaper.kt | 52 ++++++++++++++++--- .../async_wallpaper/VideoLiveWallpaper.kt | 37 +++++++++++++ .../async_wallpaper/VideoStateMachineTest.kt | 27 ++++++++++ doc/android-compatibility.md | 1 + 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2310ff..e462126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `startWallpaperRotation`, `stopWallpaperRotation`, `rotateWallpaperNow`, and `getWallpaperRotationStatus` now return an `unsupported` result / a not-running status off Android instead of falling through to a platform call. - `getWallpaperRotationStatus()` no longer throws when the platform call fails; it returns a not-running status with `lastError` set instead. - OpenGL live wallpaper: an unexpected I/O error while reading a texture source during configuration is now reported as `texture-source-unavailable` instead of `configuration-store-failed`, matching what the renderer reports for the same failure. +- Video and OpenGL live wallpapers now reload when the user sets the plugin's wallpaper again over itself. Android keeps the running engine for the same component and only sends it a reapply command, which the engines ignored, so the home screen kept the previous video or shader until the process restarted. On Android 10 and older the running engine picks up the new content only when its surface is recreated. - Internal: removed unused legacy platform-channel endpoints and dead pre-Android 7 code paths. ## 3.2.0 diff --git a/android/src/main/kotlin/com/codenameakshay/async_wallpaper/OpenGlLiveWallpaper.kt b/android/src/main/kotlin/com/codenameakshay/async_wallpaper/OpenGlLiveWallpaper.kt index 8e2174a..42f0eb4 100644 --- a/android/src/main/kotlin/com/codenameakshay/async_wallpaper/OpenGlLiveWallpaper.kt +++ b/android/src/main/kotlin/com/codenameakshay/async_wallpaper/OpenGlLiveWallpaper.kt @@ -5,6 +5,7 @@ import android.app.WallpaperManager import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.Bundle import android.os.Handler import android.os.HandlerThread import android.os.SystemClock @@ -200,6 +201,21 @@ class OpenGlLiveWallpaper : WallpaperService() { super.onTouchEvent(event) } + /** Loads the newly saved configuration when the user confirms this same component again. */ + override fun onCommand( + action: String?, + x: Int, + y: Int, + z: Int, + extras: Bundle?, + resultRequested: Boolean, + ): Bundle? { + if (action == WALLPAPER_COMMAND_REAPPLY) { + renderThread.configurationChanged(OpenGlWallpaperConfigurationStore.load(applicationContext)) + } + return super.onCommand(action, x, y, z, extras, resultRequested) + } + override fun onDestroy() { renderThread.shutdown() super.onDestroy() @@ -208,21 +224,16 @@ class OpenGlLiveWallpaper : WallpaperService() { private class WallpaperRenderThread( context: Context, - private val configuration: OpenGlWallpaperConfiguration, + initialConfiguration: OpenGlWallpaperConfiguration, ) { private val appContext = context.applicationContext private val thread = HandlerThread("AsyncWallpaper-OpenGL").apply { start() } private val handler = Handler(thread.looper) - private val frameIntervalMillis = max( - 1L, - 1_000L / configuration.frameRate.coerceIn( - ShaderProgramValidator.MIN_FRAME_RATE.toInt(), - ShaderProgramValidator.MAX_FRAME_RATE.toInt(), - ), - ) private val createdAtNanos = SystemClock.elapsedRealtimeNanos() // Everything below is accessed only from [handler]. + private var configuration = initialConfiguration + private var frameIntervalMillis = frameIntervalFor(initialConfiguration) private var destroyed = false private var visible = false private var surface: Surface? = null @@ -326,6 +337,23 @@ class OpenGlLiveWallpaper : WallpaperService() { } } + /** Swaps in a newly confirmed configuration; the next frame builds a renderer for it. */ + fun configurationChanged(newConfiguration: OpenGlWallpaperConfiguration) { + handler.post { + if (destroyed) { + return@post + } + configuration = newConfiguration + frameIntervalMillis = frameIntervalFor(newConfiguration) + renderer?.release() + renderer = null + attachedGeneration = -1L + fatalSurfaceGeneration = null + invalidateScheduledFrame() + scheduleFrame(immediate = true) + } + } + fun shutdown() { if (Thread.currentThread() === thread) { releaseOnRenderThread() @@ -445,6 +473,14 @@ class OpenGlLiveWallpaper : WallpaperService() { private companion object { private const val NANOS_PER_SECOND = 1_000_000_000.0 private const val SHUTDOWN_WAIT_MILLIS = 1_000L + + private fun frameIntervalFor(configuration: OpenGlWallpaperConfiguration): Long = max( + 1L, + 1_000L / configuration.frameRate.coerceIn( + ShaderProgramValidator.MIN_FRAME_RATE.toInt(), + ShaderProgramValidator.MAX_FRAME_RATE.toInt(), + ), + ) } } diff --git a/android/src/main/kotlin/com/codenameakshay/async_wallpaper/VideoLiveWallpaper.kt b/android/src/main/kotlin/com/codenameakshay/async_wallpaper/VideoLiveWallpaper.kt index 1d55b0c..55d7779 100644 --- a/android/src/main/kotlin/com/codenameakshay/async_wallpaper/VideoLiveWallpaper.kt +++ b/android/src/main/kotlin/com/codenameakshay/async_wallpaper/VideoLiveWallpaper.kt @@ -5,11 +5,19 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.media.MediaPlayer +import android.os.Bundle import android.service.wallpaper.WallpaperService import android.util.Log import android.view.SurfaceHolder import java.io.File +/** + * `WallpaperManager.COMMAND_REAPPLY` (hidden API). On API 30+ Android does not rebind a live + * wallpaper when the user sets the same component again; it sends this command to the running + * engine instead, so the engine must reload its content itself. + */ +internal const val WALLPAPER_COMMAND_REAPPLY = "android.wallpaper.reapply" + /** The two scaling modes supported directly by Android's MediaPlayer. */ enum class VideoWallpaperScaleMode { CENTER_CROP, @@ -61,6 +69,26 @@ class VideoLiveWallpaper : WallpaperService() { super.onDestroy() } + /** Reloads the active video when the user confirms this same component again. */ + override fun onCommand( + action: String?, + x: Int, + y: Int, + z: Int, + extras: Bundle?, + resultRequested: Boolean, + ): Bundle? { + if (action == WALLPAPER_COMMAND_REAPPLY) { + synchronized(lifecycleLock) { + if (stateMachine.onAssetReapplied() == VideoPlaybackAction.PREPARE) { + releasePlayerLocked() + preparePlayerLocked(surfaceHolder) + } + } + } + return super.onCommand(action, x, y, z, extras, resultRequested) + } + private fun preparePlayerLocked(holder: SurfaceHolder) { val file = File(filesDir, VideoWallpaperRepository.ACTIVE_VIDEO_FILE_NAME) if (!file.isFile || !file.canRead() || file.length() <= 0L) { @@ -269,6 +297,15 @@ class VideoPlaybackStateMachine { } } + /** A new active asset was confirmed; replace the player if a surface can show it now. */ + fun onAssetReapplied(): VideoPlaybackAction { + if (state == VideoPlaybackState.RELEASED || !hasSurface) { + return VideoPlaybackAction.NONE + } + state = VideoPlaybackState.PREPARING + return VideoPlaybackAction.PREPARE + } + fun onPlayerError(): VideoPlaybackAction { if (state == VideoPlaybackState.RELEASED) { return VideoPlaybackAction.NONE diff --git a/android/src/test/kotlin/com/codenameakshay/async_wallpaper/VideoStateMachineTest.kt b/android/src/test/kotlin/com/codenameakshay/async_wallpaper/VideoStateMachineTest.kt index 91cfd23..2aa5ccb 100644 --- a/android/src/test/kotlin/com/codenameakshay/async_wallpaper/VideoStateMachineTest.kt +++ b/android/src/test/kotlin/com/codenameakshay/async_wallpaper/VideoStateMachineTest.kt @@ -26,6 +26,33 @@ class VideoStateMachineTest { assertEquals(VideoPlaybackAction.START, stateMachine.onVisibilityChanged(true)) } + @Test + fun `reapply replaces the player only while a surface can show it`() { + val stateMachine = VideoPlaybackStateMachine() + assertEquals(VideoPlaybackAction.NONE, stateMachine.onAssetReapplied()) + + stateMachine.onSurfaceCreated() + stateMachine.onVisibilityChanged(true) + stateMachine.onPrepared() + assertEquals(VideoPlaybackState.PLAYING, stateMachine.state) + assertEquals(VideoPlaybackAction.PREPARE, stateMachine.onAssetReapplied()) + assertEquals(VideoPlaybackState.PREPARING, stateMachine.state) + assertEquals(VideoPlaybackAction.START, stateMachine.onPrepared()) + + stateMachine.onPlayerError() + assertEquals(VideoPlaybackAction.PREPARE, stateMachine.onAssetReapplied()) + + stateMachine.onSurfaceDestroyed() + assertEquals(VideoPlaybackAction.NONE, stateMachine.onAssetReapplied()) + stateMachine.onEngineDestroyed() + assertEquals(VideoPlaybackAction.NONE, stateMachine.onAssetReapplied()) + } + + @Test + fun `reapply command matches the platform constant`() { + assertEquals("android.wallpaper.reapply", WALLPAPER_COMMAND_REAPPLY) + } + @Test fun `hidden preparation waits until wallpaper is visible`() { val stateMachine = VideoPlaybackStateMachine() diff --git a/doc/android-compatibility.md b/doc/android-compatibility.md index 426b7d8..da3ae22 100644 --- a/doc/android-compatibility.md +++ b/doc/android-compatibility.md @@ -115,6 +115,7 @@ Consequently: - `previewOpened` means that the preview UI opened, not that the wallpaper was applied. - `awaitingUserConfirmation` means Android is waiting for the user's choice. - This release reports `previewOpened` when it launches the system UI and does not infer `applied` after the user leaves that UI. +- When the plugin's live wallpaper is already active and the user sets it again with new content, Android 11+ keeps the running engine and the plugin reloads the content in place. On Android 10 and older the new content appears only after the wallpaper's surface is recreated, for example after a restart. - `cancelled`, `unsupported`, and `failed` must remain visible to the user/app; do not replace them with a success toast. This limitation is documented rather than hidden because Android does not expose a portable API to force a live-wallpaper target across OEM preview implementations.