Add an Android live wallpaper version in droid/

3f7bd79 · Gabriel Morell · 2026-09-29 23:17

60 files +2128 -4
Message
{commit_body(@commit)}

Files changed

added droid/.gitignore
+12 −0
@@ -0,0 +1,12 @@
1 +*.iml
2 +.gradle
3 +.kotlin
4 +.idea
5 +/local.properties
6 +/build
7 +/app/build
8 +.externalNativeBuild
9 +.cxx
10 +*.jks
11 +*.pass
12 +/apk
added droid/app/build.gradle.kts
+68 −0
@@ -0,0 +1,68 @@
1 +plugins {
2 + alias(libs.plugins.android.application)
3 +}
4 +
5 +// Bumped by hand; everything after it is generated.
6 +val baseVersion = "0.1.0"
7 +
8 +// Seconds, from the environment when it's there. CI sets it once per run
9 +// so every artifact from that run agrees; a local build computes it,
10 +// which is fine because local builds are never published.
11 +val buildTimestamp: Long =
12 + (System.getenv("SOURCE_DATE_EPOCH") ?: System.getenv("BUILD_TIMESTAMP"))
13 + ?.toLongOrNull()
14 + ?: (System.currentTimeMillis() / 1000)
15 +
16 +val appVersionName: String =
17 + System.getenv("RELEASE_VERSION")?.takeIf { it.isNotBlank() }
18 + ?: "$baseVersion-dev-$buildTimestamp"
19 +
20 +android {
21 + namespace = "io.neiam.moire"
22 + compileSdk {
23 + version = release(36) {
24 + minorApiLevel = 1
25 + }
26 + }
27 +
28 + defaultConfig {
29 + applicationId = "io.neiam.moire"
30 + // Adaptive icons, and OpenGL ES 3.0 on every device.
31 + minSdk = 26
32 + targetSdk = 36
33 + // Android refuses an update whose versionCode isn't higher than
34 + // what's installed, and F-Droid orders versions by it. The CI run
35 + // number is monotonic and needs no state; local builds stay at 1.
36 + versionCode = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 1
37 + versionName = appVersionName
38 + }
39 +
40 + // The shader is the desktop wallpaper's, read as it is: one source
41 + // for both, so they can't drift apart. Shader.kt adapts its Qt
42 + // header to OpenGL ES at load time.
43 + sourceSets["main"].assets.srcDir("../../moire/shaders")
44 +
45 + buildTypes {
46 + release {
47 + optimization {
48 + enable = false
49 + }
50 + }
51 + }
52 + compileOptions {
53 + sourceCompatibility = JavaVersion.VERSION_11
54 + targetCompatibility = JavaVersion.VERSION_11
55 + }
56 + lint {
57 + // `assembleRelease` is a packaging step, not a review gate; lint
58 + // still runs on demand with ./gradlew :app:lint.
59 + checkReleaseBuilds = false
60 + }
61 +}
62 +
63 +dependencies {
64 + implementation(libs.androidx.core.ktx)
65 + implementation(libs.androidx.appcompat)
66 + implementation(libs.androidx.preference)
67 + testImplementation(libs.junit)
68 +}
added droid/app/src/main/AndroidManifest.xml
+41 −0
@@ -0,0 +1,41 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 +
4 + <!-- Only for "Use this device's location", which fills in latitude
5 + and longitude once; the wallpaper itself never asks. -->
6 + <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
7 +
8 + <uses-feature android:glEsVersion="0x00030000" android:required="true" />
9 + <uses-feature android:name="android.software.live_wallpaper" android:required="true" />
10 +
11 + <application
12 + android:allowBackup="true"
13 + android:icon="@mipmap/ic_launcher"
14 + android:label="@string/app_name"
15 + android:supportsRtl="true"
16 + android:theme="@style/Theme.Moire">
17 +
18 + <activity
19 + android:name=".SettingsActivity"
20 + android:exported="true"
21 + android:label="@string/app_name">
22 + <intent-filter>
23 + <action android:name="android.intent.action.MAIN" />
24 + <category android:name="android.intent.category.LAUNCHER" />
25 + </intent-filter>
26 + </activity>
27 +
28 + <service
29 + android:name=".MoireWallpaperService"
30 + android:exported="true"
31 + android:label="@string/app_name"
32 + android:permission="android.permission.BIND_WALLPAPER">
33 + <intent-filter>
34 + <action android:name="android.service.wallpaper.WallpaperService" />
35 + </intent-filter>
36 + <meta-data
37 + android:name="android.service.wallpaper"
38 + android:resource="@xml/wallpaper" />
39 + </service>
40 + </application>
41 +</manifest>
added droid/app/src/main/assets/licenses/B612-OFL.txt
+93 −0
@@ -0,0 +1,93 @@
1 +Copyright 2012 The B612 Project Authors (https://github.com/polarsys/b612)
2 +
3 +This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 +This license is copied below, and is also available with a FAQ at:
5 +http://scripts.sil.org/OFL
6 +
7 +
8 +-----------------------------------------------------------
9 +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 +-----------------------------------------------------------
11 +
12 +PREAMBLE
13 +The goals of the Open Font License (OFL) are to stimulate worldwide
14 +development of collaborative font projects, to support the font creation
15 +efforts of academic and linguistic communities, and to provide a free and
16 +open framework in which fonts may be shared and improved in partnership
17 +with others.
18 +
19 +The OFL allows the licensed fonts to be used, studied, modified and
20 +redistributed freely as long as they are not sold by themselves. The
21 +fonts, including any derivative works, can be bundled, embedded,
22 +redistributed and/or sold with any software provided that any reserved
23 +names are not used by derivative works. The fonts and derivatives,
24 +however, cannot be released under any other type of license. The
25 +requirement for fonts to remain under this license does not apply
26 +to any document created using the fonts or their derivatives.
27 +
28 +DEFINITIONS
29 +"Font Software" refers to the set of files released by the Copyright
30 +Holder(s) under this license and clearly marked as such. This may
31 +include source files, build scripts and documentation.
32 +
33 +"Reserved Font Name" refers to any names specified as such after the
34 +copyright statement(s).
35 +
36 +"Original Version" refers to the collection of Font Software components as
37 +distributed by the Copyright Holder(s).
38 +
39 +"Modified Version" refers to any derivative made by adding to, deleting,
40 +or substituting -- in part or in whole -- any of the components of the
41 +Original Version, by changing formats or by porting the Font Software to a
42 +new environment.
43 +
44 +"Author" refers to any designer, engineer, programmer, technical
45 +writer or other person who contributed to the Font Software.
46 +
47 +PERMISSION & CONDITIONS
48 +Permission is hereby granted, free of charge, to any person obtaining
49 +a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 +redistribute, and sell modified and unmodified copies of the Font
51 +Software, subject to the following conditions:
52 +
53 +1) Neither the Font Software nor any of its individual components,
54 +in Original or Modified Versions, may be sold by itself.
55 +
56 +2) Original or Modified Versions of the Font Software may be bundled,
57 +redistributed and/or sold with any software, provided that each copy
58 +contains the above copyright notice and this license. These can be
59 +included either as stand-alone text files, human-readable headers or
60 +in the appropriate machine-readable metadata fields within text or
61 +binary files as long as those fields can be easily viewed by the user.
62 +
63 +3) No Modified Version of the Font Software may use the Reserved Font
64 +Name(s) unless explicit written permission is granted by the corresponding
65 +Copyright Holder. This restriction only applies to the primary font name as
66 +presented to the users.
67 +
68 +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 +Software shall not be used to promote, endorse or advertise any
70 +Modified Version, except to acknowledge the contribution(s) of the
71 +Copyright Holder(s) and the Author(s) or with their explicit written
72 +permission.
73 +
74 +5) The Font Software, modified or unmodified, in part or in whole,
75 +must be distributed entirely under this license, and must not be
76 +distributed under any other license. The requirement for fonts to
77 +remain under this license does not apply to any document created
78 +using the Font Software.
79 +
80 +TERMINATION
81 +This license becomes null and void if any of the above conditions are
82 +not met.
83 +
84 +DISCLAIMER
85 +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 +OTHER DEALINGS IN THE FONT SOFTWARE.
added droid/app/src/main/java/io/neiam/moire/FieldRenderer.kt
+200 −0
@@ -0,0 +1,200 @@
1 +package io.neiam.moire
2 +
3 +import android.content.Context
4 +import android.opengl.GLES30
5 +import android.opengl.GLSurfaceView
6 +import android.opengl.GLUtils
7 +import android.util.Log
8 +import java.nio.ByteBuffer
9 +import java.nio.ByteOrder
10 +import javax.microedition.khronos.egl.EGLConfig
11 +import javax.microedition.khronos.opengles.GL10
12 +
13 +/**
14 + * Draws the field with the desktop's shader, then the wordmark over it.
15 + * Runs on GLSurfaceView's thread; settings arrive via [update], queued
16 + * onto that thread by the engine.
17 + */
18 +class FieldRenderer(private val context: Context) : GLSurfaceView.Renderer {
19 + private val motion = Motion()
20 + private val wordmark = Wordmark(context)
21 +
22 + private var settings = Settings()
23 + private var scheme: Schedule.Scheme = Schedule.scheme(settings, System.currentTimeMillis())
24 + private var schemeAt = 0L
25 +
26 + /** Called on the GL thread whenever the scheme changes, for
27 + * WallpaperColors. */
28 + var onScheme: ((Schedule.Scheme) -> Unit)? = null
29 +
30 + private var width = 0
31 + private var height = 0
32 + private var lastFrame = 0L
33 +
34 + private var field = 0
35 + private var fieldUniforms = HashMap<String, Int>()
36 + private var mark = 0
37 + private var markTexture = 0
38 + private var markImage: Wordmark.Image? = null
39 + private var markKey: Triple<Int, Int, Double>? = null
40 +
41 + val settling get() = motion.settling
42 +
43 + fun update(s: Settings) {
44 + settings = s
45 + refreshScheme(force = true)
46 + }
47 +
48 + private fun refreshScheme(force: Boolean) {
49 + val now = System.currentTimeMillis()
50 + // The sun moves slowly enough that once a minute is smooth, and
51 + // Motion eases between the steps.
52 + if (!force && now - schemeAt < 60_000) return
53 + val next = Schedule.scheme(settings, now)
54 + schemeAt = now
55 + if (next != scheme) {
56 + scheme = next
57 + onScheme?.invoke(next)
58 + }
59 + }
60 +
61 + private val quad = ByteBuffer.allocateDirect(8 * 4).order(ByteOrder.nativeOrder()).asFloatBuffer()
62 + .apply { put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)); position(0) }
63 +
64 + override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
65 + val source = context.assets.open("field.frag").bufferedReader().use { it.readText() }
66 + field = program(Shader.VERTEX, Shader.toGles(source))
67 + fieldUniforms.clear()
68 + mark = program(MARK_VERTEX, MARK_FRAGMENT)
69 + markTexture = IntArray(1).also { GLES30.glGenTextures(1, it, 0) }[0]
70 + markKey = null
71 + lastFrame = 0
72 + }
73 +
74 + override fun onSurfaceChanged(gl: GL10?, w: Int, h: Int) {
75 + width = w
76 + height = h
77 + GLES30.glViewport(0, 0, w, h)
78 + }
79 +
80 + override fun onDrawFrame(gl: GL10?) {
81 + val now = System.nanoTime()
82 + val dt = if (lastFrame == 0L) 0.0 else (now - lastFrame) / 1e9
83 + lastFrame = now
84 +
85 + refreshScheme(force = false)
86 + motion.advance(dt, settings, scheme)
87 +
88 + GLES30.glDisable(GLES30.GL_BLEND)
89 + GLES30.glUseProgram(field)
90 + for ((name, v) in motion.uniforms(width, height, settings)) {
91 + val at = fieldUniforms.getOrPut(name) { GLES30.glGetUniformLocation(field, name) }
92 + if (at < 0) continue
93 + when (v.size) {
94 + 1 -> GLES30.glUniform1f(at, v[0])
95 + 2 -> GLES30.glUniform2f(at, v[0], v[1])
96 + 4 -> GLES30.glUniform4f(at, v[0], v[1], v[2], v[3])
97 + }
98 + }
99 + drawQuad(field)
100 +
101 + if (settings.wordmark) drawWordmark()
102 + }
103 +
104 + private fun drawWordmark() {
105 + val key = Triple(width, height, settings.wordmarkGlow)
106 + if (key != markKey) {
107 + markImage?.bitmap?.recycle()
108 + markImage = wordmark.render(width, height, settings.wordmarkGlow)
109 + GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, markTexture)
110 + GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR)
111 + GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR)
112 + GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_CLAMP_TO_EDGE)
113 + GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_CLAMP_TO_EDGE)
114 + GLUtils.texImage2D(GLES30.GL_TEXTURE_2D, 0, markImage!!.bitmap, 0)
115 + markKey = key
116 + }
117 + val image = markImage ?: return
118 +
119 + GLES30.glEnable(GLES30.GL_BLEND)
120 + // The bitmap is premultiplied, as Android's always are.
121 + GLES30.glBlendFunc(GLES30.GL_ONE, GLES30.GL_ONE_MINUS_SRC_ALPHA)
122 + GLES30.glUseProgram(mark)
123 + GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
124 + GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, markTexture)
125 + GLES30.glUniform1i(GLES30.glGetUniformLocation(mark, "image"), 0)
126 + // Where the bitmap sits, as a rectangle in clip space.
127 + val x0 = image.left * 2f / width - 1
128 + val x1 = (image.left + image.bitmap.width) * 2f / width - 1
129 + val y0 = 1 - (image.top + image.bitmap.height) * 2f / height
130 + val y1 = 1 - image.top * 2f / height
131 + GLES30.glUniform4f(GLES30.glGetUniformLocation(mark, "rect"), x0, y0, x1, y1)
132 + val ink = motion.ink
133 + GLES30.glUniform4f(
134 + GLES30.glGetUniformLocation(mark, "ink"),
135 + ink.r, ink.g, ink.b, settings.wordmarkOpacity.toFloat(),
136 + )
137 + drawQuad(mark)
138 + }
139 +
140 + private fun drawQuad(program: Int) {
141 + val at = GLES30.glGetAttribLocation(program, "position")
142 + GLES30.glEnableVertexAttribArray(at)
143 + GLES30.glVertexAttribPointer(at, 2, GLES30.GL_FLOAT, false, 0, quad)
144 + GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4)
145 + GLES30.glDisableVertexAttribArray(at)
146 + }
147 +
148 + private fun program(vertex: String, fragment: String): Int {
149 + fun compile(type: Int, src: String): Int {
150 + val shader = GLES30.glCreateShader(type)
151 + GLES30.glShaderSource(shader, src)
152 + GLES30.glCompileShader(shader)
153 + val ok = IntArray(1).also { GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, it, 0) }
154 + if (ok[0] == 0) {
155 + val log = GLES30.glGetShaderInfoLog(shader)
156 + Log.e(TAG, "shader failed to compile: $log")
157 + error("shader failed to compile: $log")
158 + }
159 + return shader
160 + }
161 + val p = GLES30.glCreateProgram()
162 + GLES30.glAttachShader(p, compile(GLES30.GL_VERTEX_SHADER, vertex))
163 + GLES30.glAttachShader(p, compile(GLES30.GL_FRAGMENT_SHADER, fragment))
164 + GLES30.glLinkProgram(p)
165 + val ok = IntArray(1).also { GLES30.glGetProgramiv(p, GLES30.GL_LINK_STATUS, it, 0) }
166 + if (ok[0] == 0) error("program failed to link: ${GLES30.glGetProgramInfoLog(p)}")
167 + return p
168 + }
169 +
170 + companion object {
171 + private const val TAG = "neiam-moire"
172 +
173 + private const val MARK_VERTEX = """#version 300 es
174 +in vec2 position;
175 +uniform vec4 rect; // x0, y0, x1, y1 in clip space
176 +out vec2 uv;
177 +void main()
178 +{
179 + vec2 t = position * 0.5 + 0.5;
180 + uv = vec2(t.x, 1.0 - t.y);
181 + gl_Position = vec4(mix(rect.xy, rect.zw, t), 0.0, 1.0);
182 +}
183 +"""
184 +
185 + // The bitmap is white with coverage in alpha; tint it with the
186 + // ink colour, and apply the opacity to letters and glow together.
187 + private const val MARK_FRAGMENT = """#version 300 es
188 +precision mediump float;
189 +in vec2 uv;
190 +uniform sampler2D image;
191 +uniform vec4 ink; // rgb, and the wordmark's opacity in a
192 +out vec4 fragColor;
193 +void main()
194 +{
195 + float a = texture(image, uv).a * ink.a;
196 + fragColor = vec4(ink.rgb * a, a);
197 +}
198 +"""
199 + }
200 +}
added droid/app/src/main/java/io/neiam/moire/MoireWallpaperService.kt
+116 −0

Click to load diff…

added droid/app/src/main/java/io/neiam/moire/Motion.kt
+144 −0

Click to load diff…

added droid/app/src/main/java/io/neiam/moire/Schedule.kt
+186 −0

Click to load diff…

added droid/app/src/main/java/io/neiam/moire/Settings.kt
+97 −0

Click to load diff…

added droid/app/src/main/java/io/neiam/moire/SettingsActivity.kt
+170 −0

Click to load diff…

added droid/app/src/main/java/io/neiam/moire/Shader.kt
+48 −0

Click to load diff…

added droid/app/src/main/java/io/neiam/moire/Themes.kt
+69 −0

Click to load diff…

added droid/app/src/main/java/io/neiam/moire/Wordmark.kt
+56 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added droid/app/src/main/res/drawable/ic_foreground.xml
+79 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added droid/app/src/main/res/mipmap-anydpi/ic_launcher.xml
+6 −0

Click to load diff…

added droid/app/src/main/res/values/arrays.xml
+44 −0

Click to load diff…

added droid/app/src/main/res/values/colors.xml
+4 −0

Click to load diff…

added droid/app/src/main/res/values/strings.xml
+5 −0

Click to load diff…

added droid/app/src/main/res/values/themes.xml
+8 −0

Click to load diff…

added droid/app/src/main/res/xml/preferences.xml
+140 −0

Click to load diff…

added droid/app/src/main/res/xml/wallpaper.xml
+5 −0

Click to load diff…

added droid/app/src/test/java/io/neiam/moire/ScheduleTest.kt
+53 −0

Click to load diff…

added droid/app/src/test/java/io/neiam/moire/ShaderTest.kt
+41 −0

Click to load diff…

added droid/build.gradle.kts
+3 −0

Click to load diff…

added droid/gradle.properties
+3 −0

Click to load diff…

added droid/gradle/gradle-daemon-jvm.properties
+12 −0

Click to load diff…

added droid/gradle/libs.versions.toml
+15 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added droid/gradle/wrapper/gradle-wrapper.properties
+8 −0

Click to load diff…

added droid/gradlew
+251 −0

Click to load diff…

added droid/gradlew.bat
+94 −0

Click to load diff…

added droid/settings.gradle.kts
+26 −0

Click to load diff…

modified tools/themes.py
+31 −4

Click to load diff…

Parents: 56cf58f