chore: add project scaffolding and resources

This initial commit scaffolds the project with essential docs, assets, and build configs.
It adds Android icons, design assets, and release metadata to support mobile targets.
A fastlane config and F-Droid manifest are included to streamline builds.

- Adds F-Droid configuration for automated builds
- Includes license and privacy policy documents
- Provides app icons and assets for Android and design system
This commit is contained in:
2026-08-21 14:45:18 +02:00
parent 197090ddd2
commit 6e43d52680
169 changed files with 1685 additions and 256 deletions
+6 -2
View File
@@ -7,10 +7,14 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<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 when the display turns on" />
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper at the configured interval" />
</service>
<receiver android:name="de.wechselbild.wallpaper.BootReceiver" android:enabled="true" android:exported="true">
<intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter>
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver android:name="de.wechselbild.wallpaper.WallpaperAlarmReceiver" android:exported="false" />
</application>
</manifest>
@@ -6,6 +6,9 @@ import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED && WallpaperStore.enabled(context)) runCatching { WallpaperRotationService.start(context) }
if (intent.action in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) && WallpaperStore.enabled(context)) {
WallpaperScheduler.scheduleNext(context)
runCatching { WallpaperRotationService.start(context) }
}
}
}
@@ -0,0 +1,34 @@
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) {
if (!WallpaperStore.enabled(context)) {
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()
}
}
}
companion object {
private val executor = Executors.newSingleThreadExecutor()
}
}
@@ -9,6 +9,8 @@ import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import java.io.File
import java.util.UUID
@@ -17,12 +19,18 @@ import java.util.concurrent.Executors
@InvokeArg
class SettingArgs { lateinit var name: String; var value: Boolean = false }
@InvokeArg
class IntervalArgs { var minutes: Int = 0 }
@InvokeArg
class GalleryArgs { var offset: Int = 0; var limit: Int = 48 }
@InvokeArg
class DeleteImageArgs { lateinit var id: String }
@InvokeArg
class DeleteImagesArgs { var ids: Array<String> = emptyArray() }
@InvokeArg
class ImageCropArgs {
lateinit var id: String
@@ -36,7 +44,10 @@ class ImageCropArgs {
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
private val io = Executors.newSingleThreadExecutor()
@Command fun getState(invoke: Invoke) = io.execute { invoke.resolve(WallpaperStore.state(activity)) }
@Command fun getState(invoke: Invoke) {
if (WallpaperStore.enabled(activity)) runCatching { WallpaperRotationService.start(activity) }
io.execute { invoke.resolve(WallpaperStore.state(activity)) }
}
@Command fun getGallery(invoke: Invoke) = io.execute {
try {
@@ -63,6 +74,19 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
} catch (error: Exception) { invoke.reject(error.message ?: "Bild konnte nicht gelöscht werden") }
}
@Command fun getImageIds(invoke: Invoke) = io.execute {
val ids = JSArray().apply { WallpaperStore.imageIds(activity).forEach { put(it) } }
invoke.resolve(JSObject().apply { put("ids", ids) })
}
@Command fun deleteImages(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(DeleteImagesArgs::class.java)
if (WallpaperStore.deleteMany(activity, args.ids.toList()) == 0) throw IllegalArgumentException("Keine Bilder gefunden")
invoke.resolve(WallpaperStore.state(activity))
} catch (error: Exception) { invoke.reject(error.message ?: "Bilder konnten nicht gelöscht werden") }
}
@Command fun setImageCrop(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImageCropArgs::class.java)
@@ -96,13 +120,19 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
try {
val args = invoke.parseArgs(SettingArgs::class.java)
WallpaperStore.set(activity, args.name, args.value)
if (args.name == "enabled") {
if (args.value) WallpaperRotationService.start(activity) else WallpaperRotationService.stop(activity)
}
invoke.resolve(WallpaperStore.state(activity))
invoke.resolve(WallpaperStore.state(activity, includePreviews = false))
} catch (error: Exception) { invoke.reject(error.message ?: "Einstellung konnte nicht gespeichert werden") }
}
@Command fun setInterval(invoke: Invoke) {
try {
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))
} catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") }
}
@Command fun nextWallpaper(invoke: Invoke) = io.execute {
if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity)) else invoke.reject("Bitte wähle zuerst Bilder aus")
}
@@ -4,26 +4,39 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import java.util.concurrent.Executors
class WallpaperRotationService : Service() {
private val worker = Executors.newSingleThreadExecutor()
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_SCREEN_ON && WallpaperStore.enabled(context)) worker.execute { WallpaperStore.applyNext(context) }
private val handler = Handler(Looper.getMainLooper())
private val rotate = object : Runnable {
override fun run() {
if (!WallpaperStore.enabled(this@WallpaperRotationService)) {
stopSelf()
return
}
worker.execute {
if (WallpaperScheduler.claimIfDue(this@WallpaperRotationService)) try {
WallpaperStore.applyNext(this@WallpaperRotationService)
} finally {
WallpaperScheduler.scheduleNext(this@WallpaperRotationService)
}
handler.post { scheduleLocalTimer() }
}
}
}
override fun onCreate() {
super.onCreate()
runningService = this
createChannel()
val launch = packageManager.getLaunchIntentForPackage(packageName)
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
@@ -33,12 +46,34 @@ class WallpaperRotationService : Service() {
.setContentText(getString(R.string.wallpaper_service_text))
.setOngoing(true).setSilent(true).setContentIntent(pending).build()
startForeground(NOTIFICATION_ID, notification)
ContextCompat.registerReceiver(this, screenReceiver, IntentFilter(Intent.ACTION_SCREEN_ON), ContextCompat.RECEIVER_NOT_EXPORTED)
WallpaperScheduler.ensureScheduled(this)
scheduleLocalTimer()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_STICKY
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!WallpaperStore.enabled(this)) {
stopSelf()
return START_NOT_STICKY
}
WallpaperScheduler.ensureScheduled(this)
scheduleLocalTimer()
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() { runCatching { unregisterReceiver(screenReceiver) }; worker.shutdown(); super.onDestroy() }
override fun onDestroy() {
handler.removeCallbacks(rotate)
worker.shutdown()
if (runningService === this) runningService = null
super.onDestroy()
}
private fun scheduleLocalTimer() {
handler.removeCallbacks(rotate)
if (WallpaperStore.enabled(this)) {
handler.postDelayed(rotate, WallpaperScheduler.remainingDelay(this))
}
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= 26) {
@@ -50,7 +85,23 @@ class WallpaperRotationService : Service() {
companion object {
private const val CHANNEL = "wechselbild_rotation"
private const val NOTIFICATION_ID = 4217
fun start(context: Context) = ContextCompat.startForegroundService(context, Intent(context, WallpaperRotationService::class.java))
fun stop(context: Context) { context.stopService(Intent(context, WallpaperRotationService::class.java)) }
@Volatile private var runningService: WallpaperRotationService? = null
fun rescheduleRunningService() {
val service = runningService ?: return
service.handler.post { service.scheduleLocalTimer() }
}
fun start(context: Context) {
WallpaperScheduler.ensureScheduled(context)
ContextCompat.startForegroundService(context, Intent(context, WallpaperRotationService::class.java))
}
fun restart(context: Context) {
WallpaperScheduler.scheduleNext(context)
start(context)
}
fun stop(context: Context) {
WallpaperScheduler.cancel(context)
context.stopService(Intent(context, WallpaperRotationService::class.java))
}
}
}
@@ -0,0 +1,70 @@
package de.wechselbild.wallpaper
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.SystemClock
object WallpaperScheduler {
private const val PREFS = "wechselbild"
private const val KEY_NEXT_TRIGGER = "next_rotation_elapsed"
private const val REQUEST_CODE = 4218
fun ensureScheduled(context: Context) {
if (!WallpaperStore.enabled(context)) {
cancel(context)
return
}
val now = SystemClock.elapsedRealtime()
val storedTrigger = preferences(context).getLong(KEY_NEXT_TRIGGER, 0L)
if (storedTrigger <= now) scheduleNext(context)
}
fun remainingDelay(context: Context): Long {
val trigger = preferences(context).getLong(KEY_NEXT_TRIGGER, 0L)
return (trigger - SystemClock.elapsedRealtime()).coerceAtLeast(0L)
}
@Synchronized
fun claimIfDue(context: Context): Boolean {
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()
return true
}
fun scheduleNext(context: Context) {
val minutes = WallpaperStore.intervalMinutes(context)
if (minutes <= 0) {
cancel(context)
return
}
val trigger = SystemClock.elapsedRealtime() + minutes * 60_000L
val alarmManager = context.getSystemService(AlarmManager::class.java)
val operation = operation(context)
if (Build.VERSION.SDK_INT >= 23) {
alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation)
} else {
alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation)
}
preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).apply()
}
fun cancel(context: Context) {
context.getSystemService(AlarmManager::class.java).cancel(operation(context))
preferences(context).edit().remove(KEY_NEXT_TRIGGER).apply()
}
private fun operation(context: Context) = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, WallpaperAlarmReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun preferences(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
+49 -20
View File
@@ -9,6 +9,7 @@ import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.util.Base64
import android.util.LruCache
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream
@@ -19,34 +20,48 @@ import org.json.JSONObject
object WallpaperStore {
private const val PREFS = "wechselbild"
private const val KEY_INDEX = "current_index"
private const val KEY_INTERVAL = "interval_minutes"
private const val CROP_PREFIX = "crop_"
private const val HOME_PREVIEW_LIMIT = 12
private val thumbnailCache = LruCache<String, String>(48)
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5)
fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() }
fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList()
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun enabled(context: Context) = prefs(context).getBoolean("enabled", false)
fun intervalMinutes(context: Context): Int {
val preferences = prefs(context)
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0)
else if (preferences.getBoolean("enabled", false)) 30 else 0
}
fun enabled(context: Context) = intervalMinutes(context) > 0
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
fun set(context: Context, name: String, value: Boolean) {
require(name in setOf("enabled", "shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" }
require(name in setOf("shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" }
prefs(context).edit().putBoolean(name, value).apply()
}
fun state(context: Context): JSObject {
fun setInterval(context: Context, minutes: Int) {
require(minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" }
prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes > 0).apply()
}
fun state(context: Context, includePreviews: Boolean = true): JSObject {
val originals = files(context)
val index = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
val previewFiles = if (index < 24) originals.take(24) else listOf(originals[index]) + originals.take(23)
val previewIndex = if (index < 24) index else 0
val previewFiles = if (index < HOME_PREVIEW_LIMIT) originals.take(HOME_PREVIEW_LIMIT) else listOf(originals[index]) + originals.take(HOME_PREVIEW_LIMIT - 1)
val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0
return JSObject().apply {
put("imageCount", originals.size)
put("enabled", enabled(context))
put("intervalMinutes", intervalMinutes(context))
put("shuffle", shuffle(context))
put("lockScreenOnly", lockOnly(context))
put("currentIndex", previewIndex)
val previews = JSArray()
previewFiles.forEach { previews.put(thumbnailDataUrl(it)) }
if (includePreviews) previewFiles.forEach { previews.put(thumbnailDataUrl(it)) }
put("imageUrls", previews)
}
}
@@ -66,6 +81,8 @@ object WallpaperStore {
}
}
fun imageIds(context: Context) = files(context).map { it.name }
private fun cropKey(id: String) = CROP_PREFIX + id
private fun crop(context: Context, file: File): CropSettings {
@@ -74,7 +91,7 @@ object WallpaperStore {
val json = JSONObject(raw)
CropSettings(
mode = if (json.optString("mode") == "contain") "contain" else "cover",
zoom = json.optDouble("zoom", 1.0).coerceIn(0.35, 3.0),
zoom = json.optDouble("zoom", 1.0).coerceIn(1.0, 3.0),
x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0),
y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0),
)
@@ -100,7 +117,7 @@ object WallpaperStore {
val file = files(context).firstOrNull { it.name == id } ?: return null
val normalized = CropSettings(
mode = if (mode == "contain") "contain" else "cover",
zoom = zoom.coerceIn(0.35, 3.0),
zoom = zoom.coerceIn(1.0, 3.0),
x = x.coerceIn(0.0, 1.0),
y = y.coerceIn(0.0, 1.0),
)
@@ -117,22 +134,30 @@ object WallpaperStore {
@Synchronized
fun delete(context: Context, id: String): Boolean {
if (id.isBlank() || File(id).name != id) return false
val originals = files(context)
val position = originals.indexOfFirst { it.name == id }
if (position < 0 || !originals[position].delete()) return false
return deleteMany(context, listOf(id)) == 1
}
@Synchronized
fun deleteMany(context: Context, ids: List<String>): Int {
val requested = ids.filter { it.isNotBlank() && File(it).name == it }.toSet()
if (requested.isEmpty()) return 0
val originals = files(context)
val preferences = prefs(context)
val previousIndex = preferences.getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
val remaining = originals.size - 1
val currentName = originals.getOrNull(previousIndex)?.name
val deleted = originals.filter { it.name in requested && it.delete() }
if (deleted.isEmpty()) return 0
val remaining = files(context)
val retainedCurrent = currentName?.let { name -> remaining.indexOfFirst { it.name == name } } ?: -1
val nextIndex = when {
remaining <= 0 -> 0
position < previousIndex -> previousIndex - 1
previousIndex >= remaining -> remaining - 1
else -> previousIndex
remaining.isEmpty() -> 0
retainedCurrent >= 0 -> retainedCurrent
else -> previousIndex.coerceAtMost(remaining.lastIndex)
}
preferences.edit().remove(cropKey(id)).putInt(KEY_INDEX, nextIndex).apply()
return true
val editor = preferences.edit().putInt(KEY_INDEX, nextIndex)
deleted.forEach { editor.remove(cropKey(it.name)) }
editor.apply()
return deleted.size
}
private fun renderForScreen(context: Context, source: Bitmap, crop: CropSettings): Bitmap {
@@ -174,16 +199,20 @@ object WallpaperStore {
}
private fun thumbnailDataUrl(file: File): String {
val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}"
thumbnailCache.get(cacheKey)?.let { return it }
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
var sample = 1
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
return ByteArrayOutputStream().use { out ->
val result = ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out)
bitmap.recycle()
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
thumbnailCache.put(cacheKey, result)
return result
}
@Synchronized
@@ -1,5 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
<string name="wallpaper_service_text">Das Motiv wechselt beim Aktivieren des Displays.</string>
<string name="wallpaper_service_text">Das Motiv wechselt nach dem eingestellten Intervall.</string>
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow está activo</string>
<string name="wallpaper_service_text">El fondo cambia según el intervalo configurado.</string>
<string name="wallpaper_channel_name">Rotación automática de fondos</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow est actif</string>
<string name="wallpaper_service_text">Le fond d’écran change selon lintervalle défini.</string>
<string name="wallpaper_channel_name">Rotation automatique des fonds d’écran</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow è attivo</string>
<string name="wallpaper_service_text">Lo sfondo cambia allintervallo configurato.</string>
<string name="wallpaper_channel_name">Rotazione automatica degli sfondi</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow は実行中です</string>
<string name="wallpaper_service_text">設定した間隔で壁紙が切り替わります。</string>
<string name="wallpaper_channel_name">壁紙の自動切り替え</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow 실행 중</string>
<string name="wallpaper_service_text">설정한 간격에 따라 배경화면이 변경됩니다.</string>
<string name="wallpaper_channel_name">자동 배경화면 변경</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow is actief</string>
<string name="wallpaper_service_text">De achtergrond wisselt volgens het ingestelde interval.</string>
<string name="wallpaper_channel_name">Automatisch achtergronden wisselen</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow jest aktywny</string>
<string name="wallpaper_service_text">Tapeta zmienia się zgodnie z ustawionym interwałem.</string>
<string name="wallpaper_channel_name">Automatyczna zmiana tapety</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow está ativo</string>
<string name="wallpaper_service_text">O fundo muda de acordo com o intervalo definido.</string>
<string name="wallpaper_channel_name">Rotação automática de fundos</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow 正在运行</string>
<string name="wallpaper_service_text">壁纸会按照设定的间隔更换。</string>
<string name="wallpaper_channel_name">自动更换壁纸</string>
</resources>
@@ -1,5 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow is active</string>
<string name="wallpaper_service_text">The wallpaper changes whenever the screen wakes.</string>
<string name="wallpaper_service_text">The wallpaper changes at the configured interval.</string>
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string>
</resources>