feat(android): switch to screen-on rotation service and updated scheduler
The rotation service now responds to screen on/off events via a dedicated ScreenOnRotationService, reducing background work and avoiding perpetual wakeful states. Manifest and receiver logic were adjusted to align with the new behavior and energy constraints. Scheduling and enablement now consistently use timed-based rules and screen-on preferences, with the boot and alarm paths updated to honor these settings. This avoids unnecessary foreground services and keeps the app compliant with Doze and battery optimizations. Immich image handling improvements include throttled progress updates, larger cache allowances, and a connectivity-aware automatic download check to avoid unwanted network usage. - Use ScreenOnRotationService for screen-on based rotation scheduling - Centralize enablement via timed-enabled paths and AlarmManager windowing - Improve Immich cache and progress reporting with connectivity checks
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.SET_WALLPAPER" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<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 at the configured interval" />
|
||||
<service android:name="de.wechselbild.wallpaper.ScreenOnRotationService" 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 screen turns on" />
|
||||
</service>
|
||||
<receiver android:name="de.wechselbild.wallpaper.BootReceiver" android:enabled="true" android:exported="true">
|
||||
<intent-filter>
|
||||
|
||||
@@ -6,9 +6,10 @@ import android.content.Intent
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) && WallpaperStore.enabled(context)) {
|
||||
WallpaperScheduler.scheduleNext(context)
|
||||
runCatching { WallpaperRotationService.start(context) }
|
||||
if (intent.action !in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)) return
|
||||
when {
|
||||
WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
|
||||
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.wechselbild.wallpaper
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
@@ -33,9 +34,9 @@ object ImmichClient {
|
||||
private const val KEY_API_KEY_DATA = "immich_api_key_data"
|
||||
private const val KEY_API_KEY_IV = "immich_api_key_iv"
|
||||
private const val KEY_ALIAS = "wallpaperflow_immich_api_key"
|
||||
private const val CACHE_MAX_FILES = 3
|
||||
private const val CACHE_MAX_BYTES = 256L * 1024 * 1024
|
||||
private const val CACHE_MAX_BYTES = 512L * 1024 * 1024
|
||||
private const val DOWNLOAD_MAX_BYTES = 128L * 1024 * 1024
|
||||
private const val PROGRESS_STEP_BYTES = 256L * 1024
|
||||
private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
|
||||
private val thumbnailPool = Executors.newFixedThreadPool(4)
|
||||
private val importProgress = AtomicReference(ImportProgress())
|
||||
@@ -204,6 +205,7 @@ object ImmichClient {
|
||||
val total = connection.contentLengthLong.coerceAtLeast(0)
|
||||
require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
|
||||
var downloaded = 0L
|
||||
var lastProgressUpdate = 0L
|
||||
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) }
|
||||
connection.inputStream.use { input ->
|
||||
target.outputStream().use { output ->
|
||||
@@ -214,7 +216,10 @@ object ImmichClient {
|
||||
output.write(buffer, 0, count)
|
||||
downloaded += count
|
||||
require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
|
||||
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
|
||||
if (trackProgress && (downloaded - lastProgressUpdate >= PROGRESS_STEP_BYTES || downloaded == total)) {
|
||||
lastProgressUpdate = downloaded
|
||||
importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +314,12 @@ object ImmichClient {
|
||||
return bounds.outWidth > 0 && bounds.outHeight > 0
|
||||
}
|
||||
|
||||
fun automaticDownloadsAllowed(context: Context): Boolean {
|
||||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
return connectivity.activeNetworkInfo?.isConnected == true && !connectivity.isActiveNetworkMetered
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? {
|
||||
val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image")
|
||||
@@ -347,13 +358,13 @@ object ImmichClient {
|
||||
}
|
||||
|
||||
private fun trimCache(context: Context, protected: File) {
|
||||
val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedByDescending { it.lastModified() } ?: return
|
||||
var bytes = 0L
|
||||
files.forEachIndexed { index, file ->
|
||||
bytes += file.length()
|
||||
if (file != protected && (index >= CACHE_MAX_FILES || bytes > CACHE_MAX_BYTES)) {
|
||||
bytes -= file.length()
|
||||
file.delete()
|
||||
val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.lastModified() } ?: return
|
||||
var bytes = files.sumOf { it.length() }
|
||||
for (file in files) {
|
||||
if (bytes <= CACHE_MAX_BYTES) break
|
||||
if (file != protected) {
|
||||
val length = file.length()
|
||||
if (file.delete()) bytes -= length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-50
@@ -4,37 +4,46 @@ 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
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class WallpaperRotationService : Service() {
|
||||
class ScreenOnRotationService : Service() {
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
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)) {
|
||||
WallpaperRotationWorker.enqueue(this@WallpaperRotationService)
|
||||
private val rotating = AtomicBoolean(false)
|
||||
private val changedWhileScreenOff = AtomicBoolean(false)
|
||||
private val screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (!WallpaperStore.screenOnEnabled(context)) return
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> rotate(context, preparedForScreenOn = true)
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
if (!changedWhileScreenOff.getAndSet(false)) rotate(context, preparedForScreenOn = false)
|
||||
}
|
||||
handler.post { scheduleLocalTimer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotate(context: Context, preparedForScreenOn: Boolean) {
|
||||
if (!rotating.compareAndSet(false, true)) return
|
||||
worker.execute {
|
||||
try {
|
||||
if (WallpaperStore.applyNext(context, automatic = true) && preparedForScreenOn) {
|
||||
changedWhileScreenOff.set(true)
|
||||
}
|
||||
} finally { rotating.set(false) }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -42,34 +51,49 @@ class WallpaperRotationService : Service() {
|
||||
.setSmallIcon(android.R.drawable.ic_menu_gallery)
|
||||
.setContentTitle(getString(R.string.wallpaper_service_title))
|
||||
.setContentText(getString(R.string.wallpaper_service_text))
|
||||
.setOngoing(true).setSilent(true).setContentIntent(pending).build()
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
WallpaperScheduler.ensureScheduled(this)
|
||||
scheduleLocalTimer()
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(screenReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
registerReceiver(screenReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!WallpaperStore.enabled(this)) {
|
||||
if (!WallpaperStore.screenOnEnabled(this)) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
WallpaperScheduler.ensureScheduled(this)
|
||||
scheduleLocalTimer()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacks(rotate)
|
||||
runCatching { unregisterReceiver(screenReceiver) }
|
||||
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))
|
||||
companion object {
|
||||
private const val CHANNEL = "wechselbild_screen_on"
|
||||
private const val NOTIFICATION_ID = 4217
|
||||
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(context, Intent(context, ScreenOnRotationService::class.java))
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, ScreenOnRotationService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,27 +103,4 @@ class WallpaperRotationService : Service() {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "wechselbild_rotation"
|
||||
private const val NOTIFICATION_ID = 4217
|
||||
@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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import android.content.Intent
|
||||
|
||||
class WallpaperAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (!WallpaperStore.enabled(context)) {
|
||||
if (!WallpaperStore.timedEnabled(context)) {
|
||||
WallpaperScheduler.cancel(context)
|
||||
return
|
||||
}
|
||||
@@ -14,6 +14,5 @@ class WallpaperAlarmReceiver : BroadcastReceiver() {
|
||||
WallpaperRotationWorker.enqueue(context)
|
||||
}
|
||||
WallpaperScheduler.ensureScheduled(context)
|
||||
WallpaperRotationService.rescheduleRunningService()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
|
||||
@Command fun getState(invoke: Invoke) {
|
||||
if (WallpaperStore.enabled(activity)) runCatching { WallpaperRotationService.start(activity) }
|
||||
io.execute { invoke.resolve(WallpaperStore.state(activity)) }
|
||||
}
|
||||
|
||||
@@ -141,7 +140,10 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
try {
|
||||
val args = invoke.parseArgs(IntervalArgs::class.java)
|
||||
WallpaperStore.setInterval(activity, args.minutes)
|
||||
if (args.minutes > 0) WallpaperRotationService.restart(activity) else WallpaperRotationService.stop(activity)
|
||||
WallpaperScheduler.cancel(activity)
|
||||
ScreenOnRotationService.stop(activity)
|
||||
if (args.minutes == -1) ScreenOnRotationService.start(activity)
|
||||
else if (args.minutes > 0) WallpaperScheduler.scheduleNext(activity)
|
||||
invoke.resolve(WallpaperStore.state(activity))
|
||||
} catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.wechselbild.wallpaper
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
@@ -9,9 +10,9 @@ import androidx.work.WorkerParameters
|
||||
|
||||
class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
|
||||
override fun doWork(): Result {
|
||||
if (!WallpaperStore.enabled(applicationContext)) return Result.success()
|
||||
if (WallpaperStore.applyNext(applicationContext)) return Result.success()
|
||||
return if (runAttemptCount < 2) Result.retry() else Result.failure()
|
||||
if (!WallpaperStore.timedEnabled(applicationContext)) return Result.success()
|
||||
WallpaperStore.applyNext(applicationContext, automatic = true)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -21,7 +22,9 @@ class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) :
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<WallpaperRotationWorker>().build(),
|
||||
OneTimeWorkRequestBuilder<WallpaperRotationWorker>()
|
||||
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -13,7 +12,7 @@ object WallpaperScheduler {
|
||||
private const val REQUEST_CODE = 4218
|
||||
|
||||
fun ensureScheduled(context: Context) {
|
||||
if (!WallpaperStore.enabled(context)) {
|
||||
if (!WallpaperStore.timedEnabled(context)) {
|
||||
cancel(context)
|
||||
return
|
||||
}
|
||||
@@ -23,11 +22,6 @@ object WallpaperScheduler {
|
||||
if (storedTrigger <= now || storedTrigger > now + interval) 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)
|
||||
@@ -47,11 +41,8 @@ object WallpaperScheduler {
|
||||
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)
|
||||
}
|
||||
val window = (minutes * 60_000L / 10).coerceIn(60_000L, 15 * 60_000L)
|
||||
alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME, trigger, window, operation)
|
||||
preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ object WallpaperStore {
|
||||
private const val KEY_CURRENT_ID = "current_entry_id"
|
||||
private const val KEY_INTERVAL = "interval_minutes"
|
||||
private const val CROP_PREFIX = "crop_"
|
||||
private const val INVALID_PREFIX = "invalid_"
|
||||
private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024
|
||||
private const val THUMBNAIL_CACHE_MAX_BYTES = 64L * 1024 * 1024
|
||||
private const val HOME_PREVIEW_LIMIT = 12
|
||||
private val thumbnailCache = LruCache<String, String>(48)
|
||||
|
||||
@@ -47,6 +50,8 @@ object WallpaperStore {
|
||||
private fun immichEntries(context: Context) = File(immichRoot(context), "entries").apply { mkdirs() }
|
||||
private fun immichPreviews(context: Context) = File(immichRoot(context), "previews").apply { mkdirs() }
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun renderedDirectory(context: Context) = File(context.cacheDir, "rendered-wallpapers").apply { mkdirs() }
|
||||
private fun thumbnailDirectory(context: Context) = File(context.cacheDir, "wallpaper-thumbnails").apply { mkdirs() }
|
||||
|
||||
private fun serverKey(serverUrl: String): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(serverUrl.toByteArray(Charsets.UTF_8)).take(6).joinToString("") { "%02x".format(it) }
|
||||
@@ -92,7 +97,9 @@ object WallpaperStore {
|
||||
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 enabled(context: Context) = intervalMinutes(context) != 0
|
||||
fun timedEnabled(context: Context) = intervalMinutes(context) > 0
|
||||
fun screenOnEnabled(context: Context) = intervalMinutes(context) == -1
|
||||
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
|
||||
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
|
||||
|
||||
@@ -102,8 +109,8 @@ object WallpaperStore {
|
||||
}
|
||||
|
||||
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()
|
||||
require(minutes == -1 || minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" }
|
||||
prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes != 0).commit()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -124,7 +131,7 @@ object WallpaperStore {
|
||||
previewItems.forEach { ids.put(it.id) }
|
||||
put("imageIds", ids)
|
||||
val previews = JSArray()
|
||||
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(it.preview)) }
|
||||
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) }
|
||||
put("imageUrls", previews)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +171,7 @@ object WallpaperStore {
|
||||
val crop = crop(context, entry)
|
||||
return JSObject().apply {
|
||||
put("id", entry.id)
|
||||
put("url", thumbnailDataUrl(entry.preview))
|
||||
put("url", thumbnailDataUrl(context, entry.preview))
|
||||
put("selected", selected)
|
||||
put("cropMode", crop.mode)
|
||||
put("cropZoom", crop.zoom)
|
||||
@@ -294,9 +301,16 @@ object WallpaperStore {
|
||||
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 })
|
||||
}
|
||||
|
||||
private fun thumbnailDataUrl(file: File): String {
|
||||
private fun thumbnailDataUrl(context: Context, file: File): String {
|
||||
val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}"
|
||||
thumbnailCache.get(cacheKey)?.let { return it }
|
||||
val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg")
|
||||
if (cached.isFile) {
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
val result = "data:image/jpeg;base64," + Base64.encodeToString(cached.readBytes(), Base64.NO_WRAP)
|
||||
thumbnailCache.put(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
var sample = 1
|
||||
@@ -304,7 +318,9 @@ object WallpaperStore {
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
|
||||
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)
|
||||
val bytes = out.toByteArray()
|
||||
runCatching { cached.writeBytes(bytes); trimDirectory(thumbnailDirectory(context), THUMBNAIL_CACHE_MAX_BYTES, cached) }
|
||||
"data:image/jpeg;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
}
|
||||
thumbnailCache.put(cacheKey, result)
|
||||
return result
|
||||
@@ -314,15 +330,52 @@ object WallpaperStore {
|
||||
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}")
|
||||
}
|
||||
|
||||
private fun digest(value: String): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun renderedFile(context: Context, entry: Entry): File {
|
||||
val metrics = context.resources.displayMetrics
|
||||
val sourceVersion = when (entry) {
|
||||
is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
|
||||
is Entry.Immich -> entry.assetId
|
||||
}
|
||||
val key = "${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}"
|
||||
return File(renderedDirectory(context), "${digest(key)}.jpg")
|
||||
}
|
||||
|
||||
private fun sourceVersion(entry: Entry): String = when (entry) {
|
||||
is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
|
||||
is Entry.Immich -> entry.assetId
|
||||
}
|
||||
|
||||
private fun validImage(file: File): Boolean {
|
||||
if (!file.isFile || file.length() <= 0) return false
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
return bounds.outWidth > 0 && bounds.outHeight > 0
|
||||
}
|
||||
|
||||
private fun trimDirectory(directory: File, maxBytes: Long, protected: File) {
|
||||
val files = directory.listFiles()?.filter { it.isFile }?.sortedBy { it.lastModified() } ?: return
|
||||
var bytes = files.sumOf { it.length() }
|
||||
for (file in files) {
|
||||
if (bytes <= maxBytes) break
|
||||
if (file != protected) {
|
||||
val length = file.length()
|
||||
if (file.delete()) bytes -= length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun applyNext(context: Context): Boolean {
|
||||
fun applyNext(context: Context, automatic: Boolean = false): Boolean {
|
||||
val items = entries(context)
|
||||
if (items.isEmpty()) return false
|
||||
val previous = currentIndex(context, items)
|
||||
val candidates = if (shuffle(context) && items.size > 1) {
|
||||
items.indices.filter { it != previous }.shuffled() + previous
|
||||
} else (1..items.size).map { (previous + it).mod(items.size) }
|
||||
return applyCandidates(context, items, candidates)
|
||||
return applyCandidates(context, items, candidates, automatic)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -330,37 +383,58 @@ object WallpaperStore {
|
||||
val items = entries(context)
|
||||
val index = items.indexOfFirst { it.id == id }
|
||||
if (index < 0) return false
|
||||
return applyCandidates(context, items, listOf(index))
|
||||
return applyCandidates(context, items, listOf(index), automatic = false)
|
||||
}
|
||||
|
||||
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>): Boolean {
|
||||
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>, automatic: Boolean): Boolean {
|
||||
val unavailableServers = mutableSetOf<String>()
|
||||
for (index in candidates) {
|
||||
val entry = items[index]
|
||||
val sourceFile = when (entry) {
|
||||
is Entry.Local -> entry.file
|
||||
is Entry.Immich -> {
|
||||
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, entry.serverUrl !in unavailableServers)
|
||||
if (source == null) {
|
||||
unavailableServers.add(entry.serverUrl)
|
||||
continue
|
||||
val renderedFile = renderedFile(context, entry)
|
||||
var rendered = if (validImage(renderedFile)) {
|
||||
renderedFile.setLastModified(System.currentTimeMillis())
|
||||
BitmapFactory.decodeFile(renderedFile.absolutePath)
|
||||
} else null
|
||||
if (rendered == null) {
|
||||
renderedFile.delete()
|
||||
if (prefs(context).getString(INVALID_PREFIX + entry.id, null) == sourceVersion(entry)) continue
|
||||
val sourceFile = when (entry) {
|
||||
is Entry.Local -> entry.file
|
||||
is Entry.Immich -> {
|
||||
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context))
|
||||
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
|
||||
if (source == null) {
|
||||
if (allowNetwork) unavailableServers.add(entry.serverUrl)
|
||||
continue
|
||||
}
|
||||
source
|
||||
}
|
||||
source
|
||||
}
|
||||
val bitmap = decodeForScreen(context, sourceFile)
|
||||
if (bitmap == null) {
|
||||
prefs(context).edit().putString(INVALID_PREFIX + entry.id, sourceVersion(entry)).apply()
|
||||
continue
|
||||
}
|
||||
val generated = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull()
|
||||
bitmap.recycle()
|
||||
if (generated == null) continue
|
||||
runCatching {
|
||||
renderedFile.outputStream().use { generated.compress(Bitmap.CompressFormat.JPEG, 92, it) }
|
||||
trimDirectory(renderedDirectory(context), RENDER_CACHE_MAX_BYTES, renderedFile)
|
||||
}
|
||||
rendered = generated
|
||||
}
|
||||
val bitmap = decodeForScreen(context, sourceFile) ?: continue
|
||||
val rendered = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull()
|
||||
if (rendered == null) { bitmap.recycle(); continue }
|
||||
val wallpaper = rendered ?: continue
|
||||
try {
|
||||
val manager = WallpaperManager.getInstance(context)
|
||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
|
||||
else manager.setBitmap(rendered)
|
||||
prefs(context).edit().putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
|
||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
|
||||
else manager.setBitmap(wallpaper)
|
||||
prefs(context).edit().remove(INVALID_PREFIX + entry.id).putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
|
||||
return true
|
||||
} catch (_: Exception) {
|
||||
// Try the next usable entry without changing the current selection.
|
||||
} finally {
|
||||
rendered.recycle(); bitmap.recycle()
|
||||
wallpaper.recycle()
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
|
||||
<string name="wallpaper_service_text">Das Motiv wechselt nach dem eingestellten Intervall.</string>
|
||||
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string>
|
||||
<string name="wallpaper_service_text">Das nächste Motiv wird beim Ausschalten des Displays vorbereitet.</string>
|
||||
<string name="wallpaper_channel_name">Bildwechsel bei Display-Aktivierung</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +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>
|
||||
<string name="wallpaper_service_text">El fondo cambia cuando se enciende la pantalla.</string>
|
||||
<string name="wallpaper_channel_name">Rotación al encender la pantalla</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow est actif</string>
|
||||
<string name="wallpaper_service_text">Le fond d’écran change selon l’intervalle défini.</string>
|
||||
<string name="wallpaper_channel_name">Rotation automatique des fonds d’écran</string>
|
||||
<string name="wallpaper_service_text">Le fond d’écran change lorsque l’écran s’allume.</string>
|
||||
<string name="wallpaper_channel_name">Rotation à l’allumage de l’écran</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow è attivo</string>
|
||||
<string name="wallpaper_service_text">Lo sfondo cambia all’intervallo configurato.</string>
|
||||
<string name="wallpaper_channel_name">Rotazione automatica degli sfondi</string>
|
||||
<string name="wallpaper_service_text">Lo sfondo cambia quando si accende lo schermo.</string>
|
||||
<string name="wallpaper_channel_name">Rotazione all’accensione dello schermo</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow は実行中です</string>
|
||||
<string name="wallpaper_service_text">設定した間隔で壁紙が切り替わります。</string>
|
||||
<string name="wallpaper_channel_name">壁紙の自動切り替え</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 실행 중</string>
|
||||
<string name="wallpaper_service_text">설정한 간격에 따라 배경화면이 변경됩니다.</string>
|
||||
<string name="wallpaper_channel_name">자동 배경화면 변경</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 actief</string>
|
||||
<string name="wallpaper_service_text">De achtergrond wisselt volgens het ingestelde interval.</string>
|
||||
<string name="wallpaper_channel_name">Automatisch achtergronden wisselen</string>
|
||||
<string name="wallpaper_service_text">De achtergrond wisselt wanneer het scherm aangaat.</string>
|
||||
<string name="wallpaper_channel_name">Wisselen bij scherminschakeling</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +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>
|
||||
<string name="wallpaper_service_text">Tapeta zmienia się po włączeniu ekranu.</string>
|
||||
<string name="wallpaper_channel_name">Zmiana po włączeniu ekranu</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +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>
|
||||
<string name="wallpaper_service_text">O fundo muda quando o ecrã é ligado.</string>
|
||||
<string name="wallpaper_channel_name">Rotação ao ligar o ecrã</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="wallpaper_service_title">WallpaperFlow 正在运行</string>
|
||||
<string name="wallpaper_service_text">壁纸会按照设定的间隔更换。</string>
|
||||
<string name="wallpaper_channel_name">自动更换壁纸</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 at the configured interval.</string>
|
||||
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string>
|
||||
<string name="wallpaper_service_text">The next wallpaper is prepared when the screen turns off.</string>
|
||||
<string name="wallpaper_channel_name">Screen-on wallpaper rotation</string>
|
||||
</resources>
|
||||
|
||||
@@ -64,7 +64,7 @@ pub(crate) async fn set_setting<R: Runtime>(
|
||||
app.wallpaper().set_setting(SettingRequest { name, value })
|
||||
}
|
||||
#[command]
|
||||
pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: usize) -> Result<WallpaperState> {
|
||||
pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: i32) -> Result<WallpaperState> {
|
||||
app.wallpaper().set_interval(IntervalRequest { minutes })
|
||||
}
|
||||
#[command]
|
||||
|
||||
@@ -96,7 +96,7 @@ impl<R: Runtime> Wallpaper<R> {
|
||||
pub fn set_interval(&self, payload: IntervalRequest) -> crate::Result<WallpaperState> {
|
||||
let mut state = Self::demo();
|
||||
state.interval_minutes = payload.minutes;
|
||||
state.enabled = payload.minutes > 0;
|
||||
state.enabled = payload.minutes != 0;
|
||||
Ok(state)
|
||||
}
|
||||
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct WallpaperState {
|
||||
pub image_count: usize,
|
||||
pub enabled: bool,
|
||||
pub interval_minutes: usize,
|
||||
pub interval_minutes: i32,
|
||||
pub shuffle: bool,
|
||||
pub lock_screen_only: bool,
|
||||
pub current_index: usize,
|
||||
@@ -24,7 +24,7 @@ pub struct SettingRequest {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntervalRequest {
|
||||
pub minutes: usize,
|
||||
pub minutes: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
Reference in New Issue
Block a user