diff --git a/.gitignore b/.gitignore
index d57ac62..4f29717 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
node_modules/
.expo/
-android/
-ios/
+/android/
+/ios/
dist/
.env
*.jks
diff --git a/modules/wallpaper-android/android/build.gradle.kts b/modules/wallpaper-android/android/build.gradle.kts
new file mode 100644
index 0000000..802db39
--- /dev/null
+++ b/modules/wallpaper-android/android/build.gradle.kts
@@ -0,0 +1,27 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.lively.wallpaper"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(":expo-modules-core"))
+ implementation("org.jetbrains.kotlin:kotlin-stdlib")
+}
diff --git a/modules/wallpaper-android/android/src/main/AndroidManifest.xml b/modules/wallpaper-android/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..15c1a11
--- /dev/null
+++ b/modules/wallpaper-android/android/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/LiveWallpaperService.kt b/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/LiveWallpaperService.kt
new file mode 100644
index 0000000..e2a4775
--- /dev/null
+++ b/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/LiveWallpaperService.kt
@@ -0,0 +1,189 @@
+package com.lively.wallpaper
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.opengl.GLSurfaceView
+import android.os.Handler
+import android.os.Looper
+import android.service.wallpaper.WallpaperService
+import android.view.SurfaceHolder
+import org.json.JSONObject
+import javax.microedition.khronos.egl.EGLConfig
+import javax.microedition.khronos.opengles.GL10
+
+/**
+ * Live Wallpaper service that renders animated shaders via OpenGL ES 2.0.
+ *
+ * Reads wallpaper config from SharedPreferences (written by WallpaperAndroidModule).
+ * Config format: { shaderId, imagePath, depthMapPath?, uniforms: { intensity, speed, direction } }
+ */
+class LiveWallpaperService : WallpaperService() {
+
+ override fun onCreateEngine(): Engine = LiveEngine()
+
+ inner class LiveEngine : WallpaperService.Engine(), SensorEventListener {
+
+ private var renderer: ShaderRenderer? = null
+ private var glSurface: WallpaperGLSurfaceView? = null
+
+ // Config
+ private var shaderId = "ken-burns"
+ private var imagePath = ""
+ private var depthMapPath: String? = null
+ private var intensity = 0.5f
+ private var speed = 0.3f
+ private var direction = 0f
+ private var targetFps = 24
+
+ // State
+ private var startTime = 0L
+ private var gyroX = 0f
+ private var gyroY = 0f
+ private var isVisible = false
+
+ // Sensors
+ private var sensorManager: SensorManager? = null
+ private var gyroscope: Sensor? = null
+
+ // Render loop
+ private val handler = Handler(Looper.getMainLooper())
+ private val renderRunnable = object : Runnable {
+ override fun run() {
+ if (isVisible) {
+ glSurface?.requestRender()
+ handler.postDelayed(this, (1000L / targetFps))
+ }
+ }
+ }
+
+ override fun onCreate(surfaceHolder: SurfaceHolder) {
+ super.onCreate(surfaceHolder)
+ setTouchEventsEnabled(false)
+
+ loadConfig()
+
+ sensorManager = getSystemService(Context.SENSOR_SERVICE) as? SensorManager
+ gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
+ }
+
+ override fun onSurfaceCreated(holder: SurfaceHolder) {
+ super.onSurfaceCreated(holder)
+
+ glSurface = WallpaperGLSurfaceView(this@LiveWallpaperService).also { view ->
+ view.setEGLContextClientVersion(2)
+ view.setRenderer(WallpaperRenderer())
+ view.renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
+ }
+
+ startTime = System.currentTimeMillis()
+ }
+
+ override fun onVisibilityChanged(visible: Boolean) {
+ super.onVisibilityChanged(visible)
+ isVisible = visible
+
+ if (visible) {
+ handler.post(renderRunnable)
+ gyroscope?.let {
+ sensorManager?.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
+ }
+ } else {
+ handler.removeCallbacks(renderRunnable)
+ sensorManager?.unregisterListener(this)
+ }
+ }
+
+ override fun onSurfaceDestroyed(holder: SurfaceHolder) {
+ super.onSurfaceDestroyed(holder)
+ isVisible = false
+ handler.removeCallbacks(renderRunnable)
+ sensorManager?.unregisterListener(this)
+ renderer?.release()
+ renderer = null
+ }
+
+ override fun onSensorChanged(event: SensorEvent?) {
+ event?.let {
+ if (it.sensor.type == Sensor.TYPE_GYROSCOPE) {
+ // Accumulate with damping
+ gyroX += it.values[0] * 0.05f
+ gyroY += it.values[1] * 0.05f
+ // Damping toward zero
+ gyroX *= 0.95f
+ gyroY *= 0.95f
+ }
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
+
+ private fun loadConfig() {
+ val prefs = getSharedPreferences("lively_wallpaper", Context.MODE_PRIVATE)
+ val configStr = prefs.getString("config", null) ?: return
+
+ try {
+ val json = JSONObject(configStr)
+ shaderId = json.optString("animation", "ken-burns")
+ imagePath = json.optString("sourceUri", "")
+ depthMapPath = json.optString("depthMapUri", null)
+ targetFps = json.optInt("fps", 24)
+
+ val uniforms = json.optJSONObject("uniforms")
+ if (uniforms != null) {
+ intensity = uniforms.optDouble("intensity", 0.5).toFloat()
+ speed = uniforms.optDouble("speed", 0.3).toFloat()
+ direction = uniforms.optDouble("direction", 0.0).toFloat()
+ }
+ } catch (e: Exception) {
+ // Fallback to defaults on parse error
+ }
+ }
+
+ inner class WallpaperRenderer : GLSurfaceView.Renderer {
+ override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
+ renderer = ShaderRenderer().also { r ->
+ r.initialize(shaderId)
+ if (imagePath.isNotEmpty()) {
+ r.loadTexture(imagePath)
+ }
+ depthMapPath?.let { path ->
+ if (path.isNotEmpty()) r.loadDepthTexture(path)
+ }
+ }
+ }
+
+ override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
+ android.opengl.GLES20.glViewport(0, 0, width, height)
+ }
+
+ override fun onDrawFrame(gl: GL10?) {
+ val elapsed = (System.currentTimeMillis() - startTime) / 1000f
+ val holder = surfaceHolder
+ val width = holder.surfaceFrame.width()
+ val height = holder.surfaceFrame.height()
+
+ renderer?.draw(
+ width = width,
+ height = height,
+ time = elapsed,
+ intensity = intensity,
+ speed = speed,
+ direction = direction,
+ gyroX = gyroX,
+ gyroY = gyroY
+ )
+ }
+ }
+
+ /**
+ * Custom GLSurfaceView that uses the WallpaperService's SurfaceHolder.
+ */
+ inner class WallpaperGLSurfaceView(context: Context) : GLSurfaceView(context) {
+ override fun getHolder(): SurfaceHolder = this@LiveEngine.surfaceHolder
+ }
+ }
+}
diff --git a/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/ShaderRenderer.kt b/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/ShaderRenderer.kt
new file mode 100644
index 0000000..859d2ec
--- /dev/null
+++ b/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/ShaderRenderer.kt
@@ -0,0 +1,438 @@
+package com.lively.wallpaper
+
+import android.opengl.GLES20
+import android.opengl.GLUtils
+import android.graphics.BitmapFactory
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.FloatBuffer
+
+/**
+ * OpenGL ES 2.0 renderer that applies fragment shaders to a texture.
+ * Each animation type maps to a GLSL fragment shader equivalent to the SkSL version.
+ */
+class ShaderRenderer {
+
+ private var programId = 0
+ private var textureId = 0
+ private var depthTextureId = 0
+ private var vertexBuffer: FloatBuffer? = null
+
+ // Uniform locations
+ private var uResolution = -1
+ private var uTime = -1
+ private var uIntensity = -1
+ private var uSpeed = -1
+ private var uDirection = -1
+ private var uImage = -1
+ private var uDepthMap = -1
+ private var uGyroX = -1
+ private var uGyroY = -1
+ private var uParticleType = -1
+
+ private val vertexShaderCode = """
+ attribute vec4 aPosition;
+ attribute vec2 aTexCoord;
+ varying vec2 vTexCoord;
+ void main() {
+ gl_Position = aPosition;
+ vTexCoord = aTexCoord;
+ }
+ """.trimIndent()
+
+ // Fullscreen quad vertices (position + texcoord)
+ private val quadVertices = floatArrayOf(
+ // X, Y, U, V
+ -1.0f, -1.0f, 0.0f, 1.0f,
+ 1.0f, -1.0f, 1.0f, 1.0f,
+ -1.0f, 1.0f, 0.0f, 0.0f,
+ 1.0f, 1.0f, 1.0f, 0.0f,
+ )
+
+ fun initialize(shaderId: String) {
+ val bb = ByteBuffer.allocateDirect(quadVertices.size * 4)
+ bb.order(ByteOrder.nativeOrder())
+ vertexBuffer = bb.asFloatBuffer().apply {
+ put(quadVertices)
+ position(0)
+ }
+
+ val fragmentShaderCode = getFragmentShader(shaderId)
+ programId = createProgram(vertexShaderCode, fragmentShaderCode)
+
+ GLES20.glUseProgram(programId)
+
+ uResolution = GLES20.glGetUniformLocation(programId, "uResolution")
+ uTime = GLES20.glGetUniformLocation(programId, "uTime")
+ uIntensity = GLES20.glGetUniformLocation(programId, "uIntensity")
+ uSpeed = GLES20.glGetUniformLocation(programId, "uSpeed")
+ uDirection = GLES20.glGetUniformLocation(programId, "uDirection")
+ uImage = GLES20.glGetUniformLocation(programId, "uImage")
+ uDepthMap = GLES20.glGetUniformLocation(programId, "uDepthMap")
+ uGyroX = GLES20.glGetUniformLocation(programId, "uGyroX")
+ uGyroY = GLES20.glGetUniformLocation(programId, "uGyroY")
+ uParticleType = GLES20.glGetUniformLocation(programId, "uParticleType")
+ }
+
+ fun loadTexture(imagePath: String): Boolean {
+ val bitmap = BitmapFactory.decodeFile(imagePath) ?: return false
+
+ val textures = IntArray(1)
+ GLES20.glGenTextures(1, textures, 0)
+ textureId = textures[0]
+
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
+ GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
+ bitmap.recycle()
+
+ return true
+ }
+
+ fun loadDepthTexture(depthMapPath: String): Boolean {
+ val bitmap = BitmapFactory.decodeFile(depthMapPath) ?: return false
+
+ val textures = IntArray(1)
+ GLES20.glGenTextures(1, textures, 0)
+ depthTextureId = textures[0]
+
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, depthTextureId)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
+ GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
+ bitmap.recycle()
+
+ return true
+ }
+
+ fun draw(
+ width: Int,
+ height: Int,
+ time: Float,
+ intensity: Float,
+ speed: Float,
+ direction: Float,
+ gyroX: Float = 0f,
+ gyroY: Float = 0f,
+ particleType: Float = 0f
+ ) {
+ GLES20.glUseProgram(programId)
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
+
+ // Bind image texture to unit 0
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
+ if (uImage >= 0) GLES20.glUniform1i(uImage, 0)
+
+ // Bind depth map to unit 1 (if available)
+ if (depthTextureId != 0) {
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE1)
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, depthTextureId)
+ if (uDepthMap >= 0) GLES20.glUniform1i(uDepthMap, 1)
+ }
+
+ // Set uniforms
+ if (uResolution >= 0) GLES20.glUniform2f(uResolution, width.toFloat(), height.toFloat())
+ if (uTime >= 0) GLES20.glUniform1f(uTime, time)
+ if (uIntensity >= 0) GLES20.glUniform1f(uIntensity, intensity)
+ if (uSpeed >= 0) GLES20.glUniform1f(uSpeed, speed)
+ if (uDirection >= 0) GLES20.glUniform1f(uDirection, direction)
+ if (uGyroX >= 0) GLES20.glUniform1f(uGyroX, gyroX)
+ if (uGyroY >= 0) GLES20.glUniform1f(uGyroY, gyroY)
+ if (uParticleType >= 0) GLES20.glUniform1f(uParticleType, particleType)
+
+ // Draw fullscreen quad
+ val posHandle = GLES20.glGetAttribLocation(programId, "aPosition")
+ val texHandle = GLES20.glGetAttribLocation(programId, "aTexCoord")
+
+ vertexBuffer?.let { buf ->
+ buf.position(0)
+ GLES20.glEnableVertexAttribArray(posHandle)
+ GLES20.glVertexAttribPointer(posHandle, 2, GLES20.GL_FLOAT, false, 16, buf)
+
+ buf.position(2)
+ GLES20.glEnableVertexAttribArray(texHandle)
+ GLES20.glVertexAttribPointer(texHandle, 2, GLES20.GL_FLOAT, false, 16, buf)
+ }
+
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
+
+ GLES20.glDisableVertexAttribArray(posHandle)
+ GLES20.glDisableVertexAttribArray(texHandle)
+ }
+
+ fun release() {
+ if (programId != 0) {
+ GLES20.glDeleteProgram(programId)
+ programId = 0
+ }
+ if (textureId != 0) {
+ GLES20.glDeleteTextures(1, intArrayOf(textureId), 0)
+ textureId = 0
+ }
+ if (depthTextureId != 0) {
+ GLES20.glDeleteTextures(1, intArrayOf(depthTextureId), 0)
+ depthTextureId = 0
+ }
+ }
+
+ private fun createProgram(vertexSource: String, fragmentSource: String): Int {
+ val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
+ val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
+
+ val program = GLES20.glCreateProgram()
+ GLES20.glAttachShader(program, vertexShader)
+ GLES20.glAttachShader(program, fragmentShader)
+ GLES20.glLinkProgram(program)
+
+ val linkStatus = IntArray(1)
+ GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
+ if (linkStatus[0] == 0) {
+ val log = GLES20.glGetProgramInfoLog(program)
+ GLES20.glDeleteProgram(program)
+ throw RuntimeException("Program link failed: $log")
+ }
+
+ GLES20.glDeleteShader(vertexShader)
+ GLES20.glDeleteShader(fragmentShader)
+
+ return program
+ }
+
+ private fun compileShader(type: Int, source: String): Int {
+ val shader = GLES20.glCreateShader(type)
+ GLES20.glShaderSource(shader, source)
+ GLES20.glCompileShader(shader)
+
+ val compileStatus = IntArray(1)
+ GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
+ if (compileStatus[0] == 0) {
+ val log = GLES20.glGetShaderInfoLog(shader)
+ GLES20.glDeleteShader(shader)
+ throw RuntimeException("Shader compile failed: $log")
+ }
+
+ return shader
+ }
+
+ companion object {
+ /**
+ * Maps animation IDs to GLSL fragment shaders.
+ * These are GLSL ES 2.0 equivalents of the SkSL shaders in src/shaders/.
+ */
+ fun getFragmentShader(shaderId: String): String = when (shaderId) {
+ "ken-burns" -> """
+ precision mediump float;
+ varying vec2 vTexCoord;
+ uniform sampler2D uImage;
+ uniform vec2 uResolution;
+ uniform float uTime;
+ uniform float uIntensity;
+ uniform float uSpeed;
+ uniform float uDirection;
+
+ void main() {
+ float t = uTime * uSpeed * 0.1;
+ float zoom = 1.0 + uIntensity * 0.15 * (0.5 + 0.5 * sin(t));
+ float panX = cos(uDirection) * uIntensity * 0.05 * sin(t * 0.7);
+ float panY = sin(uDirection) * uIntensity * 0.05 * cos(t * 0.7);
+ vec2 center = vec2(0.5);
+ vec2 uv = (vTexCoord - center) / zoom + center;
+ uv += vec2(panX, panY);
+ uv = clamp(uv, 0.0, 1.0);
+ gl_FragColor = texture2D(uImage, uv);
+ }
+ """.trimIndent()
+
+ "color-shift" -> """
+ precision mediump float;
+ varying vec2 vTexCoord;
+ uniform sampler2D uImage;
+ uniform float uTime;
+ uniform float uIntensity;
+ uniform float uSpeed;
+
+ vec3 rgb2hsl(vec3 c) {
+ float mx = max(c.r, max(c.g, c.b));
+ float mn = min(c.r, min(c.g, c.b));
+ float l = (mx + mn) * 0.5;
+ float s = 0.0, h = 0.0;
+ if (mx != mn) {
+ float d = mx - mn;
+ s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn);
+ if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
+ else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
+ else h = (c.r - c.g) / d + 4.0;
+ h /= 6.0;
+ }
+ return vec3(h, s, l);
+ }
+
+ float hue2rgb(float p, float q, float t) {
+ float tt = t;
+ if (tt < 0.0) tt += 1.0;
+ if (tt > 1.0) tt -= 1.0;
+ if (tt < 1.0/6.0) return p + (q - p) * 6.0 * tt;
+ if (tt < 1.0/2.0) return q;
+ if (tt < 2.0/3.0) return p + (q - p) * (2.0/3.0 - tt) * 6.0;
+ return p;
+ }
+
+ vec3 hsl2rgb(vec3 hsl) {
+ if (hsl.y == 0.0) return vec3(hsl.z);
+ float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
+ float p = 2.0 * hsl.z - q;
+ return vec3(hue2rgb(p,q,hsl.x+1.0/3.0), hue2rgb(p,q,hsl.x), hue2rgb(p,q,hsl.x-1.0/3.0));
+ }
+
+ void main() {
+ vec4 color = texture2D(uImage, vTexCoord);
+ vec3 hsl = rgb2hsl(color.rgb);
+ float t = uTime * uSpeed * 0.15;
+ hsl.x = fract(hsl.x + uIntensity * 0.1 * sin(t));
+ hsl.y = clamp(hsl.y + uIntensity * 0.15 * sin(t * 1.3), 0.0, 1.0);
+ hsl.z = clamp(hsl.z + uIntensity * 0.03 * sin(t * 0.8), 0.0, 1.0);
+ gl_FragColor = vec4(hsl2rgb(hsl), color.a);
+ }
+ """.trimIndent()
+
+ "particles" -> """
+ precision mediump float;
+ varying vec2 vTexCoord;
+ uniform sampler2D uImage;
+ uniform vec2 uResolution;
+ uniform float uTime;
+ uniform float uIntensity;
+ uniform float uSpeed;
+ uniform float uParticleType;
+
+ float hash(vec2 p) {
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+ p3 += dot(p3, p3.yzx + 33.33);
+ return fract((p3.x + p3.y) * p3.z);
+ }
+
+ float snow(vec2 uv, float layer) {
+ float t = uTime * uSpeed * 0.4 + layer * 12.345;
+ vec2 grid = floor(uv * (8.0 + layer * 6.0));
+ vec2 f = fract(uv * (8.0 + layer * 6.0));
+ float rnd = hash(grid);
+ vec2 center = vec2(0.5 + 0.3*sin(t+rnd*6.28), fract(rnd+t*(0.1+rnd*0.08)));
+ float d = length(f - center);
+ float size = 0.04 + rnd * 0.06;
+ return smoothstep(size, size*0.3, d) * (0.4+0.6*rnd);
+ }
+
+ float bokeh(vec2 uv, float layer) {
+ float t = uTime * uSpeed * 0.2 + layer * 5.67;
+ vec2 grid = floor(uv * (3.0 + layer * 2.0));
+ vec2 f = fract(uv * (3.0 + layer * 2.0));
+ float rnd = hash(grid);
+ vec2 center = vec2(0.5+0.2*sin(t*0.5+rnd*6.28), 0.5+0.2*cos(t*0.3+rnd*6.28));
+ float d = length(f - center);
+ float size = 0.1 + rnd * 0.15;
+ float ring = smoothstep(size,size-0.02,d) - smoothstep(size-0.03,size-0.06,d);
+ float fill = smoothstep(size,size*0.2,d) * 0.3;
+ return (ring+fill) * (0.3+0.7*rnd) * step(0.3, rnd);
+ }
+
+ void main() {
+ vec4 color = texture2D(uImage, vTexCoord);
+ float particles = 0.0;
+ for (float i = 0.0; i < 3.0; i += 1.0) {
+ if (uParticleType < 0.5) particles += snow(vTexCoord, i);
+ else particles += bokeh(vTexCoord, i);
+ }
+ particles *= uIntensity;
+ gl_FragColor = vec4(clamp(color.rgb + vec3(particles), 0.0, 1.0), color.a);
+ }
+ """.trimIndent()
+
+ "vignette-pulse" -> """
+ precision mediump float;
+ varying vec2 vTexCoord;
+ uniform sampler2D uImage;
+ uniform float uTime;
+ uniform float uIntensity;
+ uniform float uSpeed;
+
+ void main() {
+ vec4 color = texture2D(uImage, vTexCoord);
+ vec2 center = vTexCoord - 0.5;
+ float dist = length(center);
+ float t = uTime * uSpeed * 0.3;
+ float strength = uIntensity * (0.6 + 0.4 * sin(t));
+ float vignette = 1.0 - smoothstep(0.2, 0.85, dist * (1.0 + strength));
+ vec3 tinted = color.rgb * vignette;
+ float tint = (1.0 - vignette) * 0.15 * uIntensity;
+ tinted.b += tint * 0.3;
+ gl_FragColor = vec4(clamp(tinted, 0.0, 1.0), color.a);
+ }
+ """.trimIndent()
+
+ "glitch" -> """
+ precision mediump float;
+ varying vec2 vTexCoord;
+ uniform sampler2D uImage;
+ uniform vec2 uResolution;
+ uniform float uTime;
+ uniform float uIntensity;
+ uniform float uSpeed;
+
+ float hash(float n) { return fract(sin(n) * 43758.5453); }
+
+ void main() {
+ float t = uTime * uSpeed * 0.5;
+ float trigger = step(0.92, sin(t*2.0)*sin(t*7.3));
+ float amount = uIntensity * trigger;
+ float shift = uIntensity * 0.003 + amount * 0.015;
+ vec2 uvR = vec2(vTexCoord.x + shift, vTexCoord.y);
+ vec2 uvB = vec2(vTexCoord.x - shift, vTexCoord.y);
+ uvR = clamp(uvR, 0.0, 1.0);
+ uvB = clamp(uvB, 0.0, 1.0);
+ float r = texture2D(uImage, uvR).r;
+ float g = texture2D(uImage, vTexCoord).g;
+ float b = texture2D(uImage, uvB).b;
+ gl_FragColor = vec4(r, g, b, 1.0);
+ }
+ """.trimIndent()
+
+ "depth-parallax" -> """
+ precision mediump float;
+ varying vec2 vTexCoord;
+ uniform sampler2D uImage;
+ uniform sampler2D uDepthMap;
+ uniform vec2 uResolution;
+ uniform float uTime;
+ uniform float uIntensity;
+ uniform float uSpeed;
+ uniform float uGyroX;
+ uniform float uGyroY;
+
+ void main() {
+ float depth = texture2D(uDepthMap, vTexCoord).r;
+ float t = uTime * uSpeed * 0.2;
+ vec2 offset = vec2(
+ uGyroX * 0.8 + sin(t) * 0.3,
+ uGyroY * 0.8 + cos(t * 0.7) * 0.3
+ );
+ vec2 displacement = offset * depth * uIntensity * 0.04;
+ vec2 uv = clamp(vTexCoord + displacement, 0.0, 1.0);
+ gl_FragColor = texture2D(uImage, uv);
+ }
+ """.trimIndent()
+
+ else -> """
+ precision mediump float;
+ varying vec2 vTexCoord;
+ uniform sampler2D uImage;
+ void main() { gl_FragColor = texture2D(uImage, vTexCoord); }
+ """.trimIndent()
+ }
+ }
+}
diff --git a/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/WallpaperAndroidModule.kt b/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/WallpaperAndroidModule.kt
new file mode 100644
index 0000000..4abce00
--- /dev/null
+++ b/modules/wallpaper-android/android/src/main/java/com/lively/wallpaper/WallpaperAndroidModule.kt
@@ -0,0 +1,59 @@
+package com.lively.wallpaper
+
+import android.app.WallpaperManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import org.json.JSONObject
+
+class WallpaperAndroidModule : Module() {
+
+ override fun definition() = ModuleDefinition {
+ Name("WallpaperAndroid")
+
+ /**
+ * Save wallpaper configuration to SharedPreferences.
+ * The LiveWallpaperService reads this on start.
+ */
+ AsyncFunction("saveConfig") { configJson: String ->
+ val context = appContext.reactContext ?: throw Exception("Context unavailable")
+ val prefs = context.getSharedPreferences("lively_wallpaper", Context.MODE_PRIVATE)
+ prefs.edit().putString("config", configJson).apply()
+ }
+
+ /**
+ * Launch the system live wallpaper picker with our service pre-selected.
+ */
+ AsyncFunction("setLiveWallpaper") {
+ val context = appContext.reactContext ?: throw Exception("Context unavailable")
+ val intent = Intent(WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER).apply {
+ putExtra(
+ WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
+ ComponentName(context.packageName, LiveWallpaperService::class.java.name)
+ )
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ /**
+ * Check if live wallpapers are supported on this device.
+ */
+ Function("isLiveWallpaperSupported") {
+ val context = appContext.reactContext ?: return@Function false
+ val pm = context.packageManager
+ pm.hasSystemFeature("android.software.live_wallpaper")
+ }
+
+ /**
+ * Get the currently active wallpaper config (if it's ours).
+ */
+ Function("getCurrentConfig") {
+ val context = appContext.reactContext ?: return@Function null
+ val prefs = context.getSharedPreferences("lively_wallpaper", Context.MODE_PRIVATE)
+ prefs.getString("config", null)
+ }
+ }
+}
diff --git a/modules/wallpaper-android/android/src/main/res/xml/wallpaper.xml b/modules/wallpaper-android/android/src/main/res/xml/wallpaper.xml
new file mode 100644
index 0000000..da4cb50
--- /dev/null
+++ b/modules/wallpaper-android/android/src/main/res/xml/wallpaper.xml
@@ -0,0 +1,5 @@
+
+