diff --git a/CHANGELOG.md b/CHANGELOG.md
index bafb0748..6c92a423 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,24 @@
+## v4.0.2
+
+- Reloaded the current live wallpaper at native resolution after fold, unfold,
+ surface-size, and scaling changes without advancing the wallpaper queue.
+- Made adaptive brightness update the wallpaper already on screen and removed
+ the brightness dip that could occur halfway through live crossfades.
+- Kept live vignette shading continuous across large images split into multiple
+ GPU texture tiles.
+- Prevented a delayed effect-slider save from overwriting a switch or other
+ setting changed immediately afterward.
+- Restored the launcher app shortcut for gesture apps and other launchers, with
+ automatic routing to the configured static or live wallpaper engine.
+- Added the missing daily album refresh to live schedules and hardened boot
+ recovery so valid jobs are restored without duplicates and stale jobs are removed.
+- Removed two unused legacy serialization and document-file dependencies from
+ the release package.
+- Expanded device coverage for every static effect and verified synchronized,
+ independent, manual, live, and reboot scheduling paths.
+
+**Full Changelog**: https://github.com/Anthonyy232/Paperize/compare/v4.0.1...v4.0.2
+
## v4.0.1
- Restored native static FILL scrolling so wide wallpapers move between their real
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 12eb7c3b..dc28b881 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -32,8 +32,8 @@ android {
applicationId = "com.anthonyla.paperize"
minSdk = 31
targetSdk = 36
- versionCode = 52
- versionName = "4.0.1"
+ versionCode = 53
+ versionName = "4.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -105,7 +105,6 @@ dependencies {
implementation(libs.androidx.animation)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.compose)
- implementation(libs.gson)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.exifinterface)
implementation(libs.zoomable)
@@ -133,7 +132,6 @@ dependencies {
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
- implementation(libs.dfc)
implementation (libs.kotlinx.serialization.json)
implementation(libs.toolbar.compose)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index d54e2b95..72e7fa2c 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -20,8 +20,6 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keepattributes Signature
--keep class com.google.gson.reflect.TypeToken { *; }
--keep class * extends com.google.gson.reflect.TypeToken
-keep class kotlin.coroutines.Continuation
-keep class androidx.datastore.*.** {*;}
@@ -61,4 +59,4 @@
# Keep Room database
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
--dontwarn androidx.room.paging.**
\ No newline at end of file
+-dontwarn androidx.room.paging.**
diff --git a/app/src/androidTest/java/com/anthonyla/paperize/core/util/WallpaperUtilInstrumentedTest.kt b/app/src/androidTest/java/com/anthonyla/paperize/core/util/WallpaperUtilInstrumentedTest.kt
index bda67919..3060c06f 100644
--- a/app/src/androidTest/java/com/anthonyla/paperize/core/util/WallpaperUtilInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/anthonyla/paperize/core/util/WallpaperUtilInstrumentedTest.kt
@@ -13,6 +13,7 @@ import java.io.File
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
@@ -106,6 +107,94 @@ class WallpaperUtilInstrumentedTest {
assertEquals(expected?.second, actual.height)
}
+ @Test
+ fun disabledStaticEffectsLeaveBitmapUntouched() {
+ val source = mutableBitmap(32, 32, Color.rgb(240, 80, 20))
+
+ val result = processBitmap(
+ source = source,
+ enableDarken = false,
+ darkenPercent = 100,
+ enableBlur = false,
+ blurPercent = 100,
+ enableVignette = false,
+ vignettePercent = 100,
+ enableGrayscale = false,
+ grayscalePercent = 100
+ )
+
+ assertSame(source, result)
+ assertEquals(Color.rgb(240, 80, 20), result.getPixel(16, 16))
+ result.recycle()
+ }
+
+ @Test
+ fun staticDarkenAndGrayscaleEffectsChangePixels() {
+ val white = mutableBitmap(32, 32, Color.WHITE)
+ val darkened = processBitmap(
+ source = white,
+ enableDarken = true,
+ darkenPercent = 100
+ )
+ assertTrue(Color.red(darkened.getPixel(16, 16)) <= 2)
+ if (darkened !== white) white.recycle()
+ darkened.recycle()
+
+ val red = mutableBitmap(32, 32, Color.RED)
+ val grayscale = processBitmap(
+ source = red,
+ enableGrayscale = true,
+ grayscalePercent = 100
+ )
+ val grayPixel = grayscale.getPixel(16, 16)
+ assertTrue(kotlin.math.abs(Color.red(grayPixel) - Color.green(grayPixel)) <= 2)
+ assertTrue(kotlin.math.abs(Color.green(grayPixel) - Color.blue(grayPixel)) <= 2)
+ if (grayscale !== red) red.recycle()
+ grayscale.recycle()
+ }
+
+ @Test
+ fun staticVignetteDarkensEdgesMoreThanCenter() {
+ val source = mutableBitmap(96, 96, Color.WHITE)
+ val result = processBitmap(
+ source = source,
+ enableVignette = true,
+ vignettePercent = 75
+ )
+
+ val center = Color.red(result.getPixel(48, 48))
+ val corner = Color.red(result.getPixel(0, 0))
+ assertTrue("Expected vignette corner ($corner) below center ($center)", corner < center)
+ if (result !== source) source.recycle()
+ result.recycle()
+ }
+
+ @Test
+ fun staticBlurSoftensSharpBoundary() {
+ val source = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888)
+ for (y in 0 until source.height) {
+ for (x in 0 until source.width) {
+ source.setPixel(x, y, if (x < 48) Color.BLACK else Color.WHITE)
+ }
+ }
+
+ val result = processBitmap(
+ source = source,
+ enableBlur = true,
+ blurPercent = 60
+ )
+
+ val boundary = Color.red(result.getPixel(48, 48))
+ assertTrue("Expected blurred boundary, got $boundary", boundary in 2..253)
+ if (result !== source) source.recycle()
+ result.recycle()
+ }
+
+ private fun mutableBitmap(width: Int, height: Int, color: Int): Bitmap =
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
+ eraseColor(color)
+ }
+
private fun createJpeg(width: Int, height: Int): File {
val file = File.createTempFile("paperize-test-", ".jpg", context.cacheDir)
files += file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 61f52eee..e810b2ff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -49,8 +49,19 @@
+
+
+
,
- scheduleSettings: ScheduleSettings,
+ persistedScheduleSettings: ScheduleSettings,
appSettings: AppSettings,
wallpaperMode: WallpaperMode,
onToggleChanger: (Boolean) -> Unit,
@@ -79,12 +79,21 @@ fun WallpaperScreen(
var showAlbumSelectionSheet by rememberSaveable { mutableStateOf(false) }
var albumSelectionContext by rememberSaveable { mutableStateOf(AlbumSelectionContext.BOTH) }
var showEmptyAlbumWarning by rememberSaveable { mutableStateOf(false) }
+ var scheduleSettings by remember { mutableStateOf(persistedScheduleSettings) }
+
+ // Keep an immediate local draft so a slider value waiting for the ViewModel debounce
+ // is included in a switch or other setting changed before that debounce expires.
+ LaunchedEffect(persistedScheduleSettings) {
+ scheduleSettings = persistedScheduleSettings
+ }
fun updateSettingsDebounced(newSettings: ScheduleSettings) {
+ scheduleSettings = newSettings
onUpdateScheduleSettingsDebounced(newSettings)
}
fun updateSettingsImmediate(newSettings: ScheduleSettings) {
+ scheduleSettings = newSettings
onUpdateScheduleSettings(newSettings)
}
diff --git a/app/src/main/java/com/anthonyla/paperize/service/alarm/BootReceiver.kt b/app/src/main/java/com/anthonyla/paperize/service/alarm/BootReceiver.kt
index db38a1f2..1644fa35 100644
--- a/app/src/main/java/com/anthonyla/paperize/service/alarm/BootReceiver.kt
+++ b/app/src/main/java/com/anthonyla/paperize/service/alarm/BootReceiver.kt
@@ -58,8 +58,11 @@ class BootReceiver : BroadcastReceiver() {
ScreenType.LIVE,
settings.liveIntervalMinutes
)
+ wallpaperScheduler.scheduleAlbumRefresh()
Log.d(TAG, "Live wallpaper changes scheduled on boot")
} else {
+ wallpaperScheduler.cancelWallpaperChange(ScreenType.LIVE)
+ wallpaperScheduler.cancelAlbumRefresh()
Log.d(TAG, "Live mode but no album or interval, not scheduling")
}
} else {
@@ -105,10 +108,12 @@ class BootReceiver : BroadcastReceiver() {
Log.d(TAG, "Wallpaper changes rescheduled successfully")
} else {
+ wallpaperScheduler.cancelAllWallpaperChanges()
Log.d(TAG, "Wallpaper changer enabled but required albums not selected, not scheduling")
}
}
} else {
+ wallpaperScheduler.cancelAllWallpaperChanges()
Log.d(TAG, "Wallpaper changer disabled, not scheduling")
}
} catch (e: Exception) {
diff --git a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/PaperizeLiveWallpaperService.kt b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/PaperizeLiveWallpaperService.kt
index 1aa85367..b3603d13 100644
--- a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/PaperizeLiveWallpaperService.kt
+++ b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/PaperizeLiveWallpaperService.kt
@@ -11,6 +11,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import com.anthonyla.paperize.core.ScreenType
+import com.anthonyla.paperize.core.ScalingType
import com.anthonyla.paperize.domain.repository.SettingsRepository
import com.anthonyla.paperize.domain.repository.WallpaperRepository
import com.anthonyla.paperize.service.livewallpaper.gl.GLWallpaperService
@@ -102,6 +103,8 @@ class PaperizeLiveWallpaperService : GLWallpaperService(), LifecycleOwner {
private lateinit var renderController: PaperizeRenderController
private val engineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var currentAlbumId: String? = null
+ @Volatile private var currentWallpaper: Wallpaper? = null
+ private var observedScalingType: ScalingType? = null
private var hasShownParallaxWarning = false
private val gestureDetector = GestureDetector(
@@ -214,6 +217,8 @@ class PaperizeLiveWallpaperService : GLWallpaperService(), LifecycleOwner {
return@withContext EmptyImageLoader
}
+ currentWallpaper = wallpaper
+
// Peek at queue to see if it needs refilling (not dequeuing, just checking)
val nextInQueue = wallpaperRepository.getNextWallpaperInQueue(albumId, ScreenType.LIVE)
if (nextInQueue == null) {
@@ -285,9 +290,31 @@ class PaperizeLiveWallpaperService : GLWallpaperService(), LifecycleOwner {
val effects = settings.liveEffects
val scalingType = settings.liveScalingType
+ val albumChanged = albumId != currentAlbumId
+ if (albumChanged) {
+ currentWallpaper = null
+ }
+
renderer.updateEffects(effects)
renderer.updateScalingType(scalingType)
renderer.updateAdaptiveBrightness(settings.adaptiveBrightness)
+
+ val scalingChanged =
+ observedScalingType != null && observedScalingType != scalingType
+ observedScalingType = scalingType
+
+ if (scalingChanged && !albumChanged) {
+ currentWallpaper?.let { wallpaper ->
+ Log.d(TAG, "Live scaling changed; reloading current wallpaper without advancing")
+ renderer.queueWallpaper(
+ ContentUriImageLoader(
+ contentResolver,
+ wallpaper.uri.toUri(),
+ scalingType
+ )
+ )
+ }
+ }
// Show Toast warning if parallax is enabled but device has offset issues
if (effects.enableParallax && !hasShownParallaxWarning && GLCompatibility.shouldWarnAboutParallax()) {
@@ -302,7 +329,7 @@ class PaperizeLiveWallpaperService : GLWallpaperService(), LifecycleOwner {
}
// Reload if album changed
- if (albumId != currentAlbumId) {
+ if (albumChanged) {
Log.d(TAG, "Album changed from $currentAlbumId to $albumId, reloading")
currentAlbumId = albumId
renderController.reloadCurrentArtwork()
@@ -318,6 +345,11 @@ class PaperizeLiveWallpaperService : GLWallpaperService(), LifecycleOwner {
override fun onVisibilityChanged(visible: Boolean) {
renderController.visible = visible
+ if (visible) {
+ // Re-evaluate draw-time effects such as adaptive brightness after
+ // configuration changes while the wallpaper was hidden.
+ requestRender()
+ }
super.onVisibilityChanged(visible)
}
diff --git a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/gl/GLPicture.kt b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/gl/GLPicture.kt
index 0ce671ed..5cb4445d 100644
--- a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/gl/GLPicture.kt
+++ b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/gl/GLPicture.kt
@@ -20,7 +20,12 @@ import kotlin.math.min
*/
class GLPicture(
bitmap: Bitmap,
- val brightnessFactor: Float = 1.0f
+ /**
+ * Luminance of the unmodified source bitmap. The renderer derives the adaptive
+ * brightness multiplier at draw time so toggling the setting affects the image
+ * that is already on screen.
+ */
+ val sourceBrightness: Float
) {
companion object {
diff --git a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometry.kt b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometry.kt
index 50cdb536..060bc636 100644
--- a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometry.kt
+++ b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometry.kt
@@ -19,6 +19,25 @@ object GLGeometry {
val horizontalOffset: Float
)
+ data class CrossfadeAlphas(
+ val current: Float,
+ val next: Float
+ )
+
+ /**
+ * Alpha values for source-over crossfading.
+ *
+ * The current layer must stay opaque while the next layer fades over it. Fading
+ * both layers produces a visible brightness dip at the midpoint.
+ */
+ fun calculateCrossfadeAlphas(
+ progress: Float,
+ hasNextPicture: Boolean
+ ): CrossfadeAlphas {
+ val nextAlpha = if (hasNextPicture) progress.coerceIn(0f, 1f) else 0f
+ return CrossfadeAlphas(current = 1f, next = nextAlpha)
+ }
+
/**
* Calculate live-wallpaper scale and launcher-scroll offset without OpenGL dependencies.
*/
diff --git a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLShaders.kt b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLShaders.kt
index d0d5ceb4..10b5a9d1 100644
--- a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLShaders.kt
+++ b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLShaders.kt
@@ -15,10 +15,15 @@ object GLShaders {
attribute vec4 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
+ varying vec2 v_imageCoord;
void main() {
gl_Position = u_mvpMatrix * a_position;
v_texCoord = a_texCoord;
+ // a_position spans the complete picture even when its texture is split
+ // into tiles. Keep a global image coordinate so image-wide effects do
+ // not restart at every tile boundary.
+ v_imageCoord = vec2(a_position.x * 0.5 + 0.5, 0.5 - a_position.y * 0.5);
}
"""
@@ -132,6 +137,7 @@ object GLShaders {
uniform float u_grayscaleFactor;
uniform float u_adaptiveBrightnessFactor;
varying vec2 v_texCoord;
+ varying vec2 v_imageCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
@@ -142,7 +148,7 @@ object GLShaders {
// 2. Apply vignette — matches CPU vignetteBitmap gradient stops:
// [0% dark at center] → [10% dark at 70% of radius] → [80% dark at edge]
// radius is normalized to 0.5 UV (image edge along shorter axis)
- vec2 vignetteCenter = v_texCoord - 0.5;
+ vec2 vignetteCenter = v_imageCoord - 0.5;
float dist = length(vignetteCenter);
float t = clamp(dist / 0.5, 0.0, 1.0);
// Inner segment: 0% → 10% over [0, 0.7]
diff --git a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/PaperizeWallpaperRenderer.kt b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/PaperizeWallpaperRenderer.kt
index b9cf1789..69d23b2f 100644
--- a/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/PaperizeWallpaperRenderer.kt
+++ b/app/src/main/java/com/anthonyla/paperize/service/livewallpaper/renderer/PaperizeWallpaperRenderer.kt
@@ -120,6 +120,10 @@ class PaperizeWallpaperRenderer(
// Adaptive brightness (from ScheduleSettings)
@Volatile private var adaptiveBrightnessEnabled = false
+ // Retain the current source so a fold/unfold or other surface-size change can
+ // decode the same wallpaper again at the new native resolution.
+ @Volatile private var currentImageLoader: ImageLoader? = null
+
// Matrices
private val mvpMatrix = FloatArray(16)
private val projectionMatrix = FloatArray(16)
@@ -156,6 +160,10 @@ class PaperizeWallpaperRenderer(
override fun onSurfaceChanged(gl: GL10, width: Int, height: Int) {
Log.d(TAG, "onSurfaceChanged: ${width}x${height}")
+ val sizeChanged =
+ surfaceWidth > 0 &&
+ surfaceHeight > 0 &&
+ (surfaceWidth != width || surfaceHeight != height)
surfaceWidth = width
surfaceHeight = height
@@ -163,6 +171,13 @@ class PaperizeWallpaperRenderer(
// Recreate framebuffers for blur at new resolution
createBlurFramebuffers(width, height)
+
+ if (sizeChanged) {
+ currentImageLoader?.let { loader ->
+ Log.d(TAG, "Surface size changed; reloading current wallpaper at ${width}x${height}")
+ queueWallpaper(loader, skipCrossfade = true)
+ }
+ }
}
override fun onDrawFrame(gl: GL10) {
@@ -190,9 +205,13 @@ class PaperizeWallpaperRenderer(
}
// Draw current picture
+ val crossfadeAlphas = GLGeometry.calculateCrossfadeAlphas(
+ progress = crossfadeProgress,
+ hasNextPicture = next != null
+ )
+
current?.let { picture ->
- val alpha = if (next != null) 1.0f - crossfadeProgress else 1.0f
- drawPictureWithEffects(picture, alpha, blurRadius)
+ drawPictureWithEffects(picture, crossfadeAlphas.current, blurRadius)
}
// Draw next picture (if crossfading)
@@ -203,7 +222,7 @@ class PaperizeWallpaperRenderer(
}
// Draw next picture with its own alpha
- drawPictureWithEffects(picture, crossfadeProgress, blurRadius)
+ drawPictureWithEffects(picture, crossfadeAlphas.next, blurRadius)
// Update crossfade progress using time-based calculation
// This ensures consistent animation duration regardless of refresh rate (60Hz, 90Hz, 120Hz, etc.)
@@ -297,7 +316,7 @@ class PaperizeWallpaperRenderer(
uGrayscaleFactorHandle,
if (currentEffects.enableGrayscale) currentEffects.grayscalePercentage / Constants.PERCENTAGE_DIVISOR else 0f
)
- GLES20.glUniform1f(uAdaptiveBrightnessFactorHandle, picture.brightnessFactor)
+ GLES20.glUniform1f(uAdaptiveBrightnessFactorHandle, adaptiveBrightnessFactor(picture))
// Bind the fully blurred texture as input
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
@@ -328,7 +347,7 @@ class PaperizeWallpaperRenderer(
uGrayscaleFactorHandle,
if (currentEffects.enableGrayscale) currentEffects.grayscalePercentage / Constants.PERCENTAGE_DIVISOR else 0f
)
- GLES20.glUniform1f(uAdaptiveBrightnessFactorHandle, picture.brightnessFactor)
+ GLES20.glUniform1f(uAdaptiveBrightnessFactorHandle, adaptiveBrightnessFactor(picture))
picture.draw(effectsProgram, aPositionHandle, aTexCoordHandle, mvpMatrix, uMvpMatrixHandle)
}
@@ -558,17 +577,15 @@ class PaperizeWallpaperRenderer(
return@launch
}
- // Calculate adaptive brightness if enabled
- val brightnessFactor = if (adaptiveBrightnessEnabled) {
- val brightness = com.anthonyla.paperize.core.util.calculateBitmapBrightness(bitmap)
- com.anthonyla.paperize.core.util.getAdaptiveBrightnessMultiplier(context, brightness)
- } else {
- 1.0f
- }
+ // Always retain source luminance. Whether adaptive brightness is enabled
+ // is evaluated at draw time so settings changes are immediate and do not
+ // advance the wallpaper queue.
+ val sourceBrightness =
+ com.anthonyla.paperize.core.util.calculateBitmapBrightness(bitmap)
// Upload to GPU on GL thread
callbacks.queueEventOnGlThread {
- uploadBitmap(bitmap, brightnessFactor, skipCrossfade)
+ uploadBitmap(bitmap, sourceBrightness, imageLoader, skipCrossfade)
}
} else {
Log.w(TAG, "Failed to load wallpaper (null bitmap)")
@@ -614,10 +631,16 @@ class PaperizeWallpaperRenderer(
* Must be called on GL thread.
*
* @param bitmap The bitmap to upload
- * @param brightnessFactor The adaptive brightness multiplier for this bitmap
+ * @param sourceBrightness Luminance of the unmodified source bitmap
+ * @param imageLoader Source used to reproduce this bitmap after a surface resize
* @param skipCrossfade If true, instantly replace current wallpaper without animation
*/
- private fun uploadBitmap(bitmap: Bitmap, brightnessFactor: Float, skipCrossfade: Boolean = false) {
+ private fun uploadBitmap(
+ bitmap: Bitmap,
+ sourceBrightness: Float,
+ imageLoader: ImageLoader,
+ skipCrossfade: Boolean = false
+ ) {
// Validate bitmap before processing
if (bitmap.isRecycled) {
Log.e(TAG, "Cannot upload recycled bitmap")
@@ -630,7 +653,8 @@ class PaperizeWallpaperRenderer(
}
try {
- val picture = GLPicture(bitmap, brightnessFactor)
+ val picture = GLPicture(bitmap, sourceBrightness)
+ currentImageLoader = imageLoader
// Recycle bitmap (no longer needed after GPU upload)
bitmap.recycle()
@@ -701,10 +725,23 @@ class PaperizeWallpaperRenderer(
* Can be called from any thread.
*/
fun updateAdaptiveBrightness(enabled: Boolean) {
- adaptiveBrightnessEnabled = enabled
- Log.d(TAG, "Adaptive brightness updated: $enabled")
+ if (adaptiveBrightnessEnabled != enabled) {
+ adaptiveBrightnessEnabled = enabled
+ Log.d(TAG, "Adaptive brightness updated: $enabled")
+ callbacks.requestRender()
+ }
}
+ private fun adaptiveBrightnessFactor(picture: GLPicture): Float =
+ if (adaptiveBrightnessEnabled) {
+ com.anthonyla.paperize.core.util.getAdaptiveBrightnessMultiplier(
+ context,
+ picture.sourceBrightness
+ )
+ } else {
+ 1f
+ }
+
/**
* Update scaling type.
* Can be called from any thread.
@@ -730,6 +767,7 @@ class PaperizeWallpaperRenderer(
nextPicture?.recycle()
nextPicture = null
+ currentImageLoader = null
GLUtil.deleteProgram(simpleProgram)
GLUtil.deleteProgram(blurHorizontalProgram)
diff --git a/app/src/main/java/com/anthonyla/paperize/service/shortcut/WallpaperShortcutActivity.kt b/app/src/main/java/com/anthonyla/paperize/service/shortcut/WallpaperShortcutActivity.kt
new file mode 100644
index 00000000..52a33b2f
--- /dev/null
+++ b/app/src/main/java/com/anthonyla/paperize/service/shortcut/WallpaperShortcutActivity.kt
@@ -0,0 +1,23 @@
+package com.anthonyla.paperize.service.shortcut
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import com.anthonyla.paperize.service.wallpaper.WallpaperChangeService
+
+/**
+ * Invisible launcher-shortcut entry point that changes the configured wallpaper target.
+ */
+class WallpaperShortcutActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ startForegroundService(
+ Intent(this, WallpaperChangeService::class.java).apply {
+ action = WallpaperChangeService.ACTION_CHANGE_WALLPAPER_AUTO
+ }
+ )
+ finish()
+ }
+}
diff --git a/app/src/main/java/com/anthonyla/paperize/service/wallpaper/WallpaperChangeService.kt b/app/src/main/java/com/anthonyla/paperize/service/wallpaper/WallpaperChangeService.kt
index 0b2d794f..3fc46103 100644
--- a/app/src/main/java/com/anthonyla/paperize/service/wallpaper/WallpaperChangeService.kt
+++ b/app/src/main/java/com/anthonyla/paperize/service/wallpaper/WallpaperChangeService.kt
@@ -15,6 +15,7 @@ import com.anthonyla.paperize.R
import com.anthonyla.paperize.core.EmptyAlbumException
import com.anthonyla.paperize.core.Result as PaperizeResult
import com.anthonyla.paperize.core.ScreenType
+import com.anthonyla.paperize.core.WallpaperMode
import com.anthonyla.paperize.core.constants.Constants
import com.anthonyla.paperize.core.util.setBitmapChecked
import com.anthonyla.paperize.domain.model.PreparedWallpaper
@@ -71,6 +72,8 @@ class WallpaperChangeService : Service() {
?: ScreenType.BOTH
when (intent?.action) {
ACTION_CHANGE_WALLPAPER -> handleChangeWallpaper(screenType, startId)
+ ACTION_CHANGE_WALLPAPER_AUTO ->
+ handleChangeWallpaper(screenType, startId, respectWallpaperMode = true)
ACTION_REAPPLY_EFFECTS -> handleReapplyEffects(screenType, startId)
else -> {
Log.w(TAG, "Unknown action: ${intent?.action}")
@@ -80,11 +83,27 @@ class WallpaperChangeService : Service() {
return START_NOT_STICKY
}
- private fun handleChangeWallpaper(screenType: ScreenType, startId: Int) {
+ private fun handleChangeWallpaper(
+ screenType: ScreenType,
+ startId: Int,
+ respectWallpaperMode: Boolean = false
+ ) {
serviceScope.launch {
wallpaperChangeLock.mutex.withLock {
try {
- changeWallpaper(screenType, settingsRepository.getScheduleSettings())
+ val effectiveScreenType =
+ if (
+ respectWallpaperMode &&
+ settingsRepository.getWallpaperMode() == WallpaperMode.LIVE
+ ) {
+ ScreenType.LIVE
+ } else {
+ screenType
+ }
+ changeWallpaper(
+ effectiveScreenType,
+ settingsRepository.getScheduleSettings()
+ )
} catch (e: Exception) {
Log.e(TAG, "Error changing wallpaper", e)
showErrorNotification(
@@ -401,6 +420,8 @@ class WallpaperChangeService : Service() {
companion object {
private const val TAG = "WallpaperChangeService"
const val ACTION_CHANGE_WALLPAPER = Constants.ACTION_CHANGE_WALLPAPER
+ const val ACTION_CHANGE_WALLPAPER_AUTO =
+ "com.anthonyla.paperize.ACTION_CHANGE_WALLPAPER_AUTO"
const val ACTION_REAPPLY_EFFECTS = Constants.ACTION_REAPPLY_EFFECTS
const val EXTRA_SCREEN_TYPE = Constants.EXTRA_SCREEN_TYPE
private const val ERROR_NOTIFICATION_ID = Constants.NOTIFICATION_ID + 1
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9b7b3b1f..11da5d0b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -207,5 +207,6 @@
Optimizing (May delay changes)
Reliability
Change wallpaper now
+ Change to the next configured wallpaper
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 00000000..ef9a03d7
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/test/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometryTest.kt b/app/src/test/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometryTest.kt
index 54795a9f..56f8420c 100644
--- a/app/src/test/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometryTest.kt
+++ b/app/src/test/java/com/anthonyla/paperize/service/livewallpaper/renderer/GLGeometryTest.kt
@@ -62,6 +62,39 @@ class GLGeometryTest {
assertEquals(75f, result.horizontalOffset, 0.001f)
}
+ @Test
+ fun `crossfade keeps current opaque while next fades in`() {
+ val start = GLGeometry.calculateCrossfadeAlphas(0f, hasNextPicture = true)
+ val midpoint = GLGeometry.calculateCrossfadeAlphas(0.5f, hasNextPicture = true)
+ val end = GLGeometry.calculateCrossfadeAlphas(1f, hasNextPicture = true)
+
+ assertEquals(1f, start.current, 0.001f)
+ assertEquals(0f, start.next, 0.001f)
+ assertEquals(1f, midpoint.current, 0.001f)
+ assertEquals(0.5f, midpoint.next, 0.001f)
+ assertEquals(1f, end.current, 0.001f)
+ assertEquals(1f, end.next, 0.001f)
+ }
+
+ @Test
+ fun `crossfade clamps progress and disables next alpha when absent`() {
+ assertEquals(
+ 0f,
+ GLGeometry.calculateCrossfadeAlphas(-1f, hasNextPicture = true).next,
+ 0.001f
+ )
+ assertEquals(
+ 1f,
+ GLGeometry.calculateCrossfadeAlphas(2f, hasNextPicture = true).next,
+ 0.001f
+ )
+ assertEquals(
+ 0f,
+ GLGeometry.calculateCrossfadeAlphas(0.75f, hasNextPicture = false).next,
+ 0.001f
+ )
+ }
+
private fun transform(
scalingType: ScalingType,
offset: Float,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5fc5caf5..da1fcef8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,9 +47,7 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
-dfc = { module = "com.lazygeniouz:dfc", version = "1.5" }
google-material = { module = "com.google.android.material:material", version.ref = "material" }
-gson = { module = "com.google.code.gson:gson", version = "2.13.2" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
junit = { module = "junit:junit", version = "4.13.2" }