This commit is contained in:
Muhammad Naeem Khan 2025-10-15 22:04:39 -07:00 committed by GitHub
commit 34cf793a1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1895 additions and 3 deletions

View file

@ -1234,6 +1234,34 @@
"link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account",
"list": "List",
"live_wallpaper_setting_manage": "Configure live wallpaper",
"live_wallpaper_setting_open_picker": "Open system wallpaper picker",
"live_wallpaper_setting_open_picker_failed": "Couldn't open wallpaper picker",
"live_wallpaper_setting_open_picker_success": "Opening wallpaper picker…",
"live_wallpaper_setting_title": "Live wallpaper",
"live_wallpaper_setup_allow_cellular": "Allow cellular data",
"live_wallpaper_setup_allow_cellular_subtitle": "Fetch new photos even when not on WiFi",
"live_wallpaper_setup_disable_button": "Disable live wallpaper",
"live_wallpaper_setup_disabled_message": "Live wallpaper disabled",
"live_wallpaper_setup_enable_button": "Set live wallpaper",
"live_wallpaper_setup_enabled_message": "Live wallpaper updated",
"live_wallpaper_setup_error_loading_people": "Couldn't load your people",
"live_wallpaper_setup_minutes": "minutes",
"live_wallpaper_setup_no_results": "No people match your search",
"live_wallpaper_setup_open_picker": "Set from system settings",
"live_wallpaper_setup_refresh_button": "Refresh now",
"live_wallpaper_setup_refresh_failed": "Couldn't request refresh ({error})",
"live_wallpaper_setup_refresh_triggered": "Wallpaper refresh requested",
"live_wallpaper_setup_rotation_label": "Rotate every",
"live_wallpaper_setup_search_hint": "Search people",
"live_wallpaper_setup_select_people_first": "Select at least one person to continue",
"live_wallpaper_setup_title": "Live wallpaper",
"live_wallpaper_setup_unknown_person": "Unnamed",
"live_wallpaper_setup_update_button": "Update wallpaper",
"live_wallpaper_status_error": "Wallpaper error: {error}",
"live_wallpaper_status_error_generic": "Wallpaper error. Try again shortly.",
"live_wallpaper_status_not_supported": "Live wallpaper isn't supported on this device",
"live_wallpaper_status_syncing": "Syncing wallpaper settings…",
"loading": "Loading",
"loading_search_results_failed": "Loading search results failed",
"local": "Local",

View file

@ -19,6 +19,12 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.SET_WALLPAPER_HINTS" />
<uses-feature
android:name="android.software.live_wallpaper"
android:required="false" />
<!-- Foreground service permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -38,6 +44,23 @@
android:exported="false"
android:foregroundServiceType="dataSync|shortService" />
<service
android:name=".wallpaper.ImmichWallpaperService"
android:permission="android.permission.BIND_WALLPAPER"
android:exported="true">
<intent-filter>
<action android:name="android.service.wallpaper.WallpaperService" />
</intent-filter>
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/immich_wallpaper" />
</service>
<activity
android:name=".wallpaper.ImmichWallpaperSettingsActivity"
android:exported="true"
android:theme="@style/Theme.Material3.DayNight.NoActionBar" />
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />

View file

@ -14,6 +14,8 @@ import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.wallpaper.WallpaperHostApi
import app.alextran.immich.wallpaper.WallpaperHostApiImpl
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@ -38,6 +40,7 @@ class MainActivity : FlutterFragmentActivity() {
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
WallpaperHostApi.setUp(messenger, WallpaperHostApiImpl(ctx))
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())

View file

@ -0,0 +1,321 @@
package app.alextran.immich.wallpaper
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.net.ConnectivityManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.service.wallpaper.WallpaperService
import android.util.Log
import android.view.SurfaceHolder
import androidx.core.content.getSystemService
import app.alextran.immich.widget.ImmichAPI
import app.alextran.immich.widget.model.SearchFilters
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import kotlin.math.max
class ImmichWallpaperService : WallpaperService() {
override fun onCreateEngine(): Engine {
return ImmichWallpaperEngine()
}
private inner class ImmichWallpaperEngine : Engine(), SharedPreferences.OnSharedPreferenceChangeListener {
private val appContext: Context = this@ImmichWallpaperService.applicationContext
private val store = WallpaperPreferencesStore(appContext)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val handler = Handler(Looper.getMainLooper())
private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
private val previewFile = File(appContext.filesDir, "immich_wallpaper_preview.jpg")
private var isVisibleToUser = false
private var lastRefreshToken = store.getRefreshToken()
private var refreshJob: Job? = null
private val refreshRunnable = Runnable { refreshWallpaper() }
override fun onCreate(surfaceHolder: SurfaceHolder) {
super.onCreate(surfaceHolder)
setTouchEventsEnabled(false)
store.registerListener(this)
refreshWallpaper(force = true)
}
override fun onDestroy() {
handler.removeCallbacks(refreshRunnable)
store.unregisterListener(this)
refreshJob?.cancel()
refreshJob = null
scope.cancel()
super.onDestroy()
}
override fun onVisibilityChanged(visible: Boolean) {
val wasVisible = isVisibleToUser
isVisibleToUser = visible
if (visible) {
val preferences = store.getPreferences()
if (preferences?.rotationMode == "lockUnlock" && !wasVisible) {
// Only refresh for lock/unlock mode when becoming visible after being hidden
Log.d(TAG, "Refreshing wallpaper on unlock (lockUnlock mode)")
refreshWallpaper(force = true)
} else if (preferences?.rotationMode != "lockUnlock") {
// For other modes, just resume regular scheduling without forcing refresh
Log.d(TAG, "Resuming wallpaper rotation (${preferences?.rotationMode} mode)")
refreshWallpaper(force = false)
}
} else {
Log.d(TAG, "Wallpaper service hidden")
handler.removeCallbacks(refreshRunnable)
refreshJob?.cancel()
refreshJob = null
}
}
override fun onSurfaceDestroyed(holder: SurfaceHolder) {
handler.removeCallbacks(refreshRunnable)
refreshJob?.cancel()
refreshJob = null
super.onSurfaceDestroyed(holder)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
KEY_ENABLED, KEY_ROTATION_MINUTES, KEY_ROTATION_MODE, KEY_PERSON_IDS, KEY_ALLOW_CELLULAR -> refreshWallpaper(force = true)
KEY_REFRESH_TOKEN -> {
val nextToken = store.getRefreshToken()
if (nextToken != lastRefreshToken) {
lastRefreshToken = nextToken
refreshWallpaper(force = true)
}
}
}
}
private fun refreshWallpaper(force: Boolean = false) {
if (!isVisibleToUser && !force) {
return
}
val preferences = store.getPreferences()
if (preferences == null || !preferences.enabled) {
handler.removeCallbacks(refreshRunnable)
return
}
// Check if we should fetch a new image or just show cached
val shouldFetchNew = force || shouldFetchNewImage(preferences)
Log.d(TAG, "refreshWallpaper: force=$force, shouldFetchNew=$shouldFetchNew, mode=${preferences.rotationMode}")
refreshJob?.cancel()
refreshJob = scope.launch {
val cached = withContext(Dispatchers.IO) { loadCachedBitmap() }
if (cached != null) {
drawBitmap(WallpaperImageResult(cached))
cached.recycle()
}
// Only fetch new image if needed
if (!shouldFetchNew) {
Log.d(TAG, "Using cached image, not fetching new wallpaper")
scheduleNext(preferences)
return@launch
}
if (!preferences.allowCellularData && isActiveNetworkMetered()) {
store.setLastError("Waiting for WiFi to download wallpaper")
scheduleNext(preferences)
return@launch
}
try {
Log.d(TAG, "Fetching new wallpaper image")
val result = withContext(Dispatchers.IO) { fetchNext(preferences) }
if (result != null) {
drawBitmap(result)
store.setLastError(null)
result.bitmap.recycle()
}
} catch (error: Exception) {
Log.e(TAG, "Failed to update wallpaper", error)
store.setLastError(error.message ?: "Unknown error")
} finally {
scheduleNext(preferences)
}
}
}
private fun scheduleNext(preferences: WallpaperPreferencesMessage) {
handler.removeCallbacks(refreshRunnable)
if (!isVisibleToUser) {
Log.d(TAG, "Not scheduling next refresh - service not visible")
return
}
// Don't schedule for lock/unlock mode - it will be handled by visibility changes
if (preferences.rotationMode == "lockUnlock") {
Log.d(TAG, "Not scheduling next refresh - using lockUnlock mode")
return
}
val delayMs = when (preferences.rotationMode) {
"minutes" -> preferences.rotationMinutes * 60_000L
"hours" -> preferences.rotationMinutes * 60_000L * 60L
"daily" -> 24L * 60L * 60L * 1000L
else -> max(preferences.rotationMinutes, 1L) * 60_000L
}
Log.d(TAG, "Scheduling next wallpaper refresh in ${delayMs / 1000}s (mode: ${preferences.rotationMode})")
handler.postDelayed(refreshRunnable, delayMs)
}
private fun shouldFetchNewImage(preferences: WallpaperPreferencesMessage): Boolean {
// Always fetch for lock/unlock mode
if (preferences.rotationMode == "lockUnlock") {
return true
}
val lastRefreshAt = store.getLastRefreshAt()
if (lastRefreshAt == 0L) {
// No previous refresh, fetch new image
return true
}
val currentTime = System.currentTimeMillis()
val timeSinceLastRefresh = currentTime - lastRefreshAt
val rotationIntervalMs = when (preferences.rotationMode) {
"minutes" -> preferences.rotationMinutes * 60_000L
"hours" -> preferences.rotationMinutes * 60_000L * 60L
"daily" -> 24L * 60L * 60L * 1000L
else -> max(preferences.rotationMinutes, 1L) * 60_000L
}
Log.d(TAG, "Time since last refresh: ${timeSinceLastRefresh / 1000}s, interval: ${rotationIntervalMs / 1000}s")
return timeSinceLastRefresh >= rotationIntervalMs
}
private fun isActiveNetworkMetered(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return false
}
val connectivity = appContext.getSystemService<ConnectivityManager>()
return connectivity?.isActiveNetworkMetered == true
}
private fun cacheBitmap(bitmap: Bitmap) {
runCatching {
FileOutputStream(previewFile).use { output ->
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 85, output)) {
throw IOException("Failed to compress bitmap")
}
output.flush()
}
}.onFailure { error ->
Log.w(TAG, "Failed to cache wallpaper preview", error)
}
}
private fun loadCachedBitmap(): Bitmap? {
if (!previewFile.exists() || previewFile.length() == 0L) {
return null
}
return runCatching { BitmapFactory.decodeFile(previewFile.absolutePath) }.getOrNull()
}
private suspend fun fetchNext(preferences: WallpaperPreferencesMessage): WallpaperImageResult? {
val config = ImmichAPI.getServerConfig(appContext)
?: throw IllegalStateException("Open the app to link your server first")
val api = ImmichAPI(config)
if (preferences.personIds.isNotEmpty()) {
val shuffledIds = preferences.personIds.shuffled()
for (personId in shuffledIds) {
val result = fetchForPerson(api, personId)
if (result != null) {
return result
}
}
throw IllegalStateException("Couldn't find any photos for the selected people")
}
return fetchForPerson(api, null)
?: throw IllegalStateException("Couldn't find any photos in your library yet")
}
private suspend fun fetchForPerson(api: ImmichAPI, personId: String?): WallpaperImageResult? {
val filters = SearchFilters()
if (personId != null) {
filters.personIds = listOf(personId)
filters.withPeople = true
}
val assets = api.fetchSearchResults(filters)
if (assets.isEmpty()) {
return null
}
val asset = assets.random()
val bitmap = api.fetchImage(asset)
cacheBitmap(bitmap)
store.setLastAssetId(asset.id)
return WallpaperImageResult(bitmap)
}
private fun drawBitmap(result: WallpaperImageResult) {
val holder = surfaceHolder ?: return
var canvas: Canvas? = null
try {
canvas = holder.lockCanvas()
if (canvas != null) {
val bitmap = result.bitmap
canvas.drawColor(Color.BLACK)
val scale = max(
canvas.width.toFloat() / bitmap.width,
canvas.height.toFloat() / bitmap.height
)
val dx = (canvas.width - bitmap.width * scale) / 2f
val dy = (canvas.height - bitmap.height * scale) / 2f
val matrix = Matrix().apply {
postScale(scale, scale)
postTranslate(dx, dy)
}
canvas.drawBitmap(bitmap, matrix, paint)
}
} finally {
if (canvas != null) {
holder.unlockCanvasAndPost(canvas)
}
}
}
}
private data class WallpaperImageResult(val bitmap: android.graphics.Bitmap)
companion object {
private const val TAG = "ImmichWallpaperService"
}
}

View file

@ -0,0 +1,31 @@
package app.alextran.immich.wallpaper
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Button
import app.alextran.immich.MainActivity
import app.alextran.immich.R
class ImmichWallpaperSettingsActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Directly open Immich settings without showing intermediate screen
openImmichSettings()
}
private fun openImmichSettings() {
val intent = Intent(Intent.ACTION_VIEW, SETTINGS_URI, this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
finish()
}
companion object {
private val SETTINGS_URI: Uri = Uri.parse("immich://live-wallpaper/settings")
}
}

View file

@ -0,0 +1,267 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.wallpaper
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object WallpaperApiPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
/** Generated class from Pigeon that represents data sent in messages. */
data class WallpaperPreferencesMessage (
val enabled: Boolean,
val personIds: List<String>,
val rotationMinutes: Long,
val rotationMode: String,
val allowCellularData: Boolean
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): WallpaperPreferencesMessage {
val enabled = pigeonVar_list[0] as Boolean
val personIds = pigeonVar_list[1] as List<String>
val rotationMinutes = pigeonVar_list[2] as Long
val rotationMode = pigeonVar_list[3] as String
val allowCellularData = pigeonVar_list[4] as Boolean
return WallpaperPreferencesMessage(enabled, personIds, rotationMinutes, rotationMode, allowCellularData)
}
}
fun toList(): List<Any?> {
return listOf(
enabled,
personIds,
rotationMinutes,
rotationMode,
allowCellularData,
)
}
override fun equals(other: Any?): Boolean {
if (other !is WallpaperPreferencesMessage) {
return false
}
if (this === other) {
return true
}
return WallpaperApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class WallpaperStatusMessage (
val isSupported: Boolean,
val isActive: Boolean,
val lastError: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): WallpaperStatusMessage {
val isSupported = pigeonVar_list[0] as Boolean
val isActive = pigeonVar_list[1] as Boolean
val lastError = pigeonVar_list[2] as String?
return WallpaperStatusMessage(isSupported, isActive, lastError)
}
}
fun toList(): List<Any?> {
return listOf(
isSupported,
isActive,
lastError,
)
}
override fun equals(other: Any?): Boolean {
if (other !is WallpaperStatusMessage) {
return false
}
if (this === other) {
return true
}
return WallpaperApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class WallpaperApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
WallpaperPreferencesMessage.fromList(it)
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
WallpaperStatusMessage.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is WallpaperPreferencesMessage -> {
stream.write(129)
writeValue(stream, value.toList())
}
is WallpaperStatusMessage -> {
stream.write(130)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface WallpaperHostApi {
fun getStatus(): WallpaperStatusMessage
fun setPreferences(preferences: WallpaperPreferencesMessage)
fun requestRefresh()
fun openSystemWallpaperPicker(): Boolean
companion object {
/** The codec used by WallpaperHostApi. */
val codec: MessageCodec<Any?> by lazy {
WallpaperApiPigeonCodec()
}
/** Sets up an instance of `WallpaperHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: WallpaperHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.getStatus$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getStatus())
} catch (exception: Throwable) {
WallpaperApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.setPreferences$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val preferencesArg = args[0] as WallpaperPreferencesMessage
val wrapped: List<Any?> = try {
api.setPreferences(preferencesArg)
listOf(null)
} catch (exception: Throwable) {
WallpaperApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.requestRefresh$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.requestRefresh()
listOf(null)
} catch (exception: Throwable) {
WallpaperApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.openSystemWallpaperPicker$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.openSystemWallpaperPicker())
} catch (exception: Throwable) {
WallpaperApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View file

@ -0,0 +1,52 @@
package app.alextran.immich.wallpaper
import android.app.WallpaperManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
class WallpaperHostApiImpl(private val context: Context) : WallpaperHostApi {
private val store = WallpaperPreferencesStore(context)
override fun getStatus(): WallpaperStatusMessage {
val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LIVE_WALLPAPER)
val manager = WallpaperManager.getInstance(context)
val component = ComponentName(context, ImmichWallpaperService::class.java)
val isActive = runCatching { manager.wallpaperInfo?.component == component }.getOrDefault(false)
return WallpaperStatusMessage(
isSupported = supported,
isActive = isActive,
lastError = store.getLastError()
)
}
override fun setPreferences(preferences: WallpaperPreferencesMessage) {
store.save(preferences)
if (!preferences.enabled) {
store.setLastError(null)
}
}
override fun requestRefresh() {
store.bumpRefreshToken()
}
override fun openSystemWallpaperPicker(): Boolean {
val component = ComponentName(context, ImmichWallpaperService::class.java)
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
Intent(WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER).apply {
putExtra(WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT, component)
}
} else {
Intent(WallpaperManager.ACTION_LIVE_WALLPAPER_CHOOSER)
}.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
return runCatching { context.startActivity(intent) }.isSuccess
}
}

View file

@ -0,0 +1,117 @@
package app.alextran.immich.wallpaper
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import org.json.JSONArray
import org.json.JSONException
import app.alextran.immich.wallpaper.WallpaperPreferencesMessage
/** Convenience wrapper around [WallpaperPreferencesMessage] persistence. */
private const val PREF_NAME = "immich_live_wallpaper"
internal const val KEY_ENABLED = "enabled"
internal const val KEY_PERSON_IDS = "person_ids"
internal const val KEY_ROTATION_MINUTES = "rotation_minutes"
internal const val KEY_ROTATION_MODE = "rotation_mode"
internal const val KEY_ALLOW_CELLULAR = "allow_cellular"
internal const val KEY_LAST_ERROR = "last_error"
internal const val KEY_REFRESH_TOKEN = "refresh_token"
internal const val KEY_LAST_ASSET_ID = "last_asset_id"
internal const val KEY_LAST_REFRESH_AT = "last_refresh_at"
class WallpaperPreferencesStore(private val context: Context) {
private val prefs: SharedPreferences by lazy {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
fun save(preferences: WallpaperPreferencesMessage) {
prefs.edit {
putBoolean(KEY_ENABLED, preferences.enabled)
putString(KEY_PERSON_IDS, preferences.personIds.toJson())
putLong(KEY_ROTATION_MINUTES, preferences.rotationMinutes)
putString(KEY_ROTATION_MODE, preferences.rotationMode)
putBoolean(KEY_ALLOW_CELLULAR, preferences.allowCellularData)
}
bumpRefreshToken()
}
fun getPreferences(): WallpaperPreferencesMessage? {
if (!prefs.contains(KEY_ENABLED)) {
return null
}
val enabled = prefs.getBoolean(KEY_ENABLED, false)
val personIds = (prefs.getString(KEY_PERSON_IDS, null) ?: "[]").toStringList()
val rotationMinutes = prefs.getLong(KEY_ROTATION_MINUTES, 15)
val rotationMode = prefs.getString(KEY_ROTATION_MODE, "minutes") ?: "minutes"
val allowCellular = prefs.getBoolean(KEY_ALLOW_CELLULAR, true)
return WallpaperPreferencesMessage(
enabled = enabled,
personIds = personIds,
rotationMinutes = rotationMinutes,
rotationMode = rotationMode,
allowCellularData = allowCellular
)
}
fun getLastError(): String? = prefs.getString(KEY_LAST_ERROR, null)
fun setLastError(message: String?) {
prefs.edit {
if (message.isNullOrBlank()) {
remove(KEY_LAST_ERROR)
} else {
putString(KEY_LAST_ERROR, message)
}
}
}
fun bumpRefreshToken() {
prefs.edit {
putLong(KEY_REFRESH_TOKEN, System.currentTimeMillis())
}
}
fun getRefreshToken(): Long = prefs.getLong(KEY_REFRESH_TOKEN, 0)
fun setLastAssetId(assetId: String) {
prefs.edit {
putString(KEY_LAST_ASSET_ID, assetId)
putLong(KEY_LAST_REFRESH_AT, System.currentTimeMillis())
}
}
fun getLastAssetId(): String? = prefs.getString(KEY_LAST_ASSET_ID, null)
fun getLastRefreshAt(): Long = prefs.getLong(KEY_LAST_REFRESH_AT, 0)
fun registerListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
fun unregisterListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
private fun List<String>.toJson(): String {
val array = JSONArray()
forEach { array.put(it) }
return array.toString()
}
private fun String.toStringList(): List<String> {
return try {
val array = JSONArray(this)
val results = mutableListOf<String>()
for (i in 0 until array.length()) {
results.add(array.optString(i))
}
results
} catch (_: JSONException) {
emptyList()
}
}
}

View file

@ -18,7 +18,9 @@ data class SearchFilters(
var type: AssetType = AssetType.IMAGE,
val size: Int = 1,
var albumIds: List<String> = listOf(),
var isFavorite: Boolean? = null
var isFavorite: Boolean? = null,
var personIds: List<String> = listOf(),
var withPeople: Boolean? = null
)
data class MemoryResult(

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center_horizontal">
<TextView
android:id="@+id/wallpaper_settings_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/wallpaper_settings_title"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
android:textAlignment="center"
android:paddingBottom="12dp" />
<TextView
android:id="@+id/wallpaper_settings_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/wallpaper_settings_description"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textAlignment="center"
android:paddingBottom="24dp" />
<Button
android:id="@+id/wallpaper_settings_open_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/wallpaper_settings_open_button" />
<Button
android:id="@+id/wallpaper_settings_back_button"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/wallpaper_settings_back_button" />
</LinearLayout>

View file

@ -1,8 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Immich</string>
<string name="memory_widget_title">Memories</string>
<string name="random_widget_title">Random</string>
<string name="memory_widget_description">See memories from Immich.</string>
<string name="random_widget_description">View a random image from your library or a specific album.</string>
<string name="wallpaper_settings_title">Immich live wallpaper</string>
<string name="wallpaper_settings_description">Preview the wallpaper, then jump back into Immich to choose who appears.</string>
<string name="wallpaper_settings_open_button">Open Immich settings</string>
<string name="wallpaper_settings_back_button">Back to wallpaper preview</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:thumbnail="@mipmap/ic_launcher"
android:settingsActivity="app.alextran.immich.wallpaper.ImmichWallpaperSettingsActivity" />

View file

@ -0,0 +1,142 @@
import 'dart:convert';
const _defaultRotationMinutes = 30;
enum RotationMode { lockUnlock, minutes, hours, daily }
extension RotationModeExtension on RotationMode {
String get label {
switch (this) {
case RotationMode.lockUnlock:
return 'Lock/Unlock';
case RotationMode.minutes:
return 'Minute';
case RotationMode.hours:
return 'Hour';
case RotationMode.daily:
return 'Daily';
}
}
Duration get duration {
switch (this) {
case RotationMode.lockUnlock:
return Duration.zero; // Special case for lock/unlock
case RotationMode.minutes:
return const Duration(minutes: 1);
case RotationMode.hours:
return const Duration(hours: 1);
case RotationMode.daily:
return const Duration(days: 1);
}
}
}
class LiveWallpaperPreferences {
const LiveWallpaperPreferences({
required this.enabled,
required this.personIds,
required this.rotationInterval,
required this.rotationMode,
required this.allowCellularData,
this.lastAssetId,
this.lastUpdated,
});
const LiveWallpaperPreferences.defaults()
: enabled = false,
personIds = const [],
rotationInterval = const Duration(minutes: _defaultRotationMinutes),
rotationMode = RotationMode.minutes,
allowCellularData = false,
lastAssetId = null,
lastUpdated = null;
final bool enabled;
final List<String> personIds;
final Duration rotationInterval;
final RotationMode rotationMode;
final bool allowCellularData;
final String? lastAssetId;
final DateTime? lastUpdated;
bool get hasSelection => personIds.isNotEmpty;
LiveWallpaperPreferences copyWith({
bool? enabled,
List<String>? personIds,
Duration? rotationInterval,
RotationMode? rotationMode,
bool? allowCellularData,
String? lastAssetId,
DateTime? lastUpdated,
}) {
return LiveWallpaperPreferences(
enabled: enabled ?? this.enabled,
personIds: personIds ?? this.personIds,
rotationInterval: rotationInterval ?? this.rotationInterval,
rotationMode: rotationMode ?? this.rotationMode,
allowCellularData: allowCellularData ?? this.allowCellularData,
lastAssetId: lastAssetId ?? this.lastAssetId,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'enabled': enabled,
'personIds': personIds,
'rotationMinutes': rotationInterval.inMinutes,
'rotationMode': rotationMode.name,
'allowCellularData': allowCellularData,
'lastAssetId': lastAssetId,
'lastUpdated': lastUpdated?.toIso8601String(),
};
}
String encode() => jsonEncode(toJson());
static LiveWallpaperPreferences decode(String? source) {
if (source == null || source.isEmpty) {
return const LiveWallpaperPreferences.defaults();
}
try {
final map = jsonDecode(source) as Map<String, dynamic>;
return LiveWallpaperPreferences(
enabled: map['enabled'] as bool? ?? false,
personIds: (map['personIds'] as List? ?? const []).cast<String>(),
rotationInterval: Duration(minutes: _parseInt(map['rotationMinutes'], _defaultRotationMinutes)),
rotationMode: _parseRotationMode(map['rotationMode'] as String?),
allowCellularData: map['allowCellularData'] as bool? ?? false,
lastAssetId: map['lastAssetId'] as String?,
lastUpdated: _parseDate(map['lastUpdated'] as String?),
);
} catch (_) {
return const LiveWallpaperPreferences.defaults();
}
}
static int _parseInt(Object? value, int fallback) {
if (value is int) return value;
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) return parsed;
}
return fallback;
}
static DateTime? _parseDate(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
static RotationMode _parseRotationMode(String? value) {
if (value == null) return RotationMode.minutes;
try {
return RotationMode.values.firstWhere((e) => e.name == value);
} catch (_) {
return RotationMode.minutes;
}
}
}

View file

@ -81,7 +81,10 @@ enum StoreKey<T> {
useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
shouldResetSync<bool>._(1007),
// Live wallpaper configuration
wallpaperPreferences<String>._(1008);
const StoreKey._(this.id);
final int id;

View file

@ -0,0 +1,34 @@
import 'dart:math';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
class LiveWallpaperAssetService {
LiveWallpaperAssetService(this._assetApiRepository);
final AssetApiRepository _assetApiRepository;
final Random _random = Random();
Future<Asset?> pickRandomAsset(List<String> personIds, {String? excludeRemoteId}) async {
if (personIds.isEmpty) {
return null;
}
final assets = await _assetApiRepository.search(personIds: personIds);
if (assets.isEmpty) {
return null;
}
final filtered = assets.where((asset) {
final matchesType = asset.type == AssetType.image;
final matchesRemoteId = excludeRemoteId == null || asset.remoteId != excludeRemoteId;
return matchesType && matchesRemoteId;
}).toList();
if (filtered.isEmpty) {
return null;
}
return filtered[_random.nextInt(filtered.length)];
}
}

View file

@ -0,0 +1,24 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
class LiveWallpaperPreferencesService {
LiveWallpaperPreferencesService(this._storeService);
final StoreService _storeService;
LiveWallpaperPreferences load() {
final raw = _storeService.tryGet(StoreKey.wallpaperPreferences);
return LiveWallpaperPreferences.decode(raw);
}
Future<void> save(LiveWallpaperPreferences preferences) {
return _storeService.put(StoreKey.wallpaperPreferences, preferences.encode());
}
Stream<LiveWallpaperPreferences> watch() {
return _storeService.watch(StoreKey.wallpaperPreferences).map(LiveWallpaperPreferences.decode);
}
}

View file

@ -0,0 +1,249 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/live_wallpaper_platform.provider.dart';
import 'package:immich_mobile/providers/live_wallpaper_preferences.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@RoutePage()
class LiveWallpaperSetupPage extends HookConsumerWidget {
const LiveWallpaperSetupPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final preferences = ref.watch(liveWallpaperPreferencesProvider);
final notifier = ref.watch(liveWallpaperPreferencesProvider.notifier);
final openPicker = ref.watch(openWallpaperPickerProvider);
final searchQuery = useState('');
final selected = useState<Set<String>>(preferences.personIds.toSet());
final rotationMode = useState<RotationMode>(preferences.rotationMode);
useEffect(() {
selected.value = preferences.personIds.toSet();
rotationMode.value = preferences.rotationMode;
return null;
}, [preferences.personIds, preferences.rotationMode]);
final peopleAsync = ref.watch(driftGetAllPeopleProvider);
void togglePerson(String personId) {
final next = Set<String>.from(selected.value);
if (next.contains(personId)) {
next.remove(personId);
} else {
next.add(personId);
}
selected.value = next;
notifier.setPersonIds(next.toList());
}
void onRotationChanged(RotationMode mode) {
rotationMode.value = mode;
notifier.setRotationMode(mode);
notifier.setRotationInterval(mode.duration);
}
void onAllowCellularChanged(bool value) {
notifier.setAllowCellular(value);
}
Future<void> onSetWallpaper() async {
if (selected.value.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('live_wallpaper_setup_select_people_first'.tr())));
return;
}
final wasEnabled = preferences.enabled;
await notifier.toggleEnabled(true);
try {
final opened = await openPicker();
if (!context.mounted) return;
final key = opened ? 'live_wallpaper_setting_open_picker_success' : 'live_wallpaper_setting_open_picker_failed';
if (!opened && !wasEnabled) {
await notifier.toggleEnabled(false);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(key.tr())));
} catch (error) {
if (!wasEnabled) {
await notifier.toggleEnabled(false);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('live_wallpaper_status_error'.tr(namedArgs: {'error': error.toString()}))),
);
}
}
}
return Scaffold(
appBar: AppBar(title: Text('live_wallpaper_setup_title'.tr())),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'live_wallpaper_setup_search_hint'.tr(),
border: const OutlineInputBorder(),
),
onChanged: (value) => searchQuery.value = value,
),
const SizedBox(height: 12),
Expanded(
child: peopleAsync.when(
data: (people) => _PeopleGrid(
people: _filterPeople(people, searchQuery.value),
selected: selected.value,
onPersonTap: togglePerson,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('live_wallpaper_setup_error_loading_people'.tr())),
),
),
const SizedBox(height: 16),
_RotationControl(mode: rotationMode.value, onChanged: onRotationChanged),
SwitchListTile.adaptive(
value: preferences.allowCellularData,
onChanged: onAllowCellularChanged,
title: Text('live_wallpaper_setup_allow_cellular'.tr()),
subtitle: Text('live_wallpaper_setup_allow_cellular_subtitle'.tr()),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.wallpaper),
label: Text('live_wallpaper_setup_enable_button'.tr()),
onPressed: onSetWallpaper,
),
),
],
),
),
);
}
static List<DriftPerson> _filterPeople(List<DriftPerson> people, String search) {
if (search.isEmpty) {
return people;
}
final lower = search.toLowerCase();
return people.where((person) => person.name.toLowerCase().contains(lower)).toList();
}
}
class _PeopleGrid extends StatelessWidget {
const _PeopleGrid({required this.people, required this.selected, required this.onPersonTap});
final List<DriftPerson> people;
final Set<String> selected;
final ValueChanged<String> onPersonTap;
@override
Widget build(BuildContext context) {
final headers = ApiService.getRequestHeaders();
if (people.isEmpty) {
return Center(child: Text('live_wallpaper_setup_no_results'.tr()));
}
return LayoutBuilder(
builder: (context, constraints) {
final estimated = (constraints.maxWidth / 110).floor();
final count = estimated.clamp(2, 4);
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: count,
childAspectRatio: 0.72,
crossAxisSpacing: 10,
mainAxisSpacing: 16,
),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
final isSelected = selected.contains(person.id);
final imageUrl = getFaceThumbnailUrl(person.id);
return GestureDetector(
onTap: () => onPersonTap(person.id),
child: Column(
children: [
Stack(
alignment: Alignment.topRight,
children: [
CircleAvatar(radius: 42, backgroundImage: NetworkImage(imageUrl, headers: headers)),
if (isSelected)
Container(
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: const Icon(Icons.check, size: 16, color: Colors.white),
),
],
),
const SizedBox(height: 8),
Text(
person.name.isEmpty ? 'live_wallpaper_setup_unknown_person'.tr() : person.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
);
},
);
},
);
}
}
class _RotationControl extends StatelessWidget {
const _RotationControl({required this.mode, required this.onChanged});
final RotationMode mode;
final ValueChanged<RotationMode> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('live_wallpaper_setup_rotation_label'.tr()),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: RotationMode.values.map((rotationMode) {
final isSelected = mode == rotationMode;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: FilterChip(
label: Text(rotationMode.label),
selected: isSelected,
onSelected: (_) => onChanged(rotationMode),
),
);
}).toList(),
),
),
],
);
}
}

290
mobile/lib/platform/wallpaper_api.g.dart generated Normal file
View file

@ -0,0 +1,290 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
PlatformException _createConnectionError(String channelName) {
return PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
}
bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
}
return a == b;
}
class WallpaperPreferencesMessage {
WallpaperPreferencesMessage({
required this.enabled,
required this.personIds,
required this.rotationMinutes,
required this.rotationMode,
required this.allowCellularData,
});
bool enabled;
List<String> personIds;
int rotationMinutes;
String rotationMode;
bool allowCellularData;
List<Object?> _toList() {
return <Object?>[
enabled,
personIds,
rotationMinutes,
rotationMode,
allowCellularData,
];
}
Object encode() {
return _toList(); }
static WallpaperPreferencesMessage decode(Object result) {
result as List<Object?>;
return WallpaperPreferencesMessage(
enabled: result[0]! as bool,
personIds: (result[1] as List<Object?>?)!.cast<String>(),
rotationMinutes: result[2]! as int,
rotationMode: result[3]! as String,
allowCellularData: result[4]! as bool,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! WallpaperPreferencesMessage || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
}
class WallpaperStatusMessage {
WallpaperStatusMessage({
required this.isSupported,
required this.isActive,
this.lastError,
});
bool isSupported;
bool isActive;
String? lastError;
List<Object?> _toList() {
return <Object?>[
isSupported,
isActive,
lastError,
];
}
Object encode() {
return _toList(); }
static WallpaperStatusMessage decode(Object result) {
result as List<Object?>;
return WallpaperStatusMessage(
isSupported: result[0]! as bool,
isActive: result[1]! as bool,
lastError: result[2] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! WallpaperStatusMessage || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is WallpaperPreferencesMessage) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else if (value is WallpaperStatusMessage) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return WallpaperPreferencesMessage.decode(readValue(buffer)!);
case 130:
return WallpaperStatusMessage.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class WallpaperHostApi {
/// Constructor for [WallpaperHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
WallpaperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<WallpaperStatusMessage> getStatus() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.getStatus$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as WallpaperStatusMessage?)!;
}
}
Future<void> setPreferences(WallpaperPreferencesMessage preferences) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.setPreferences$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[preferences]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<void> requestRefresh() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.requestRefresh$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<bool> openSystemWallpaperPicker() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.openSystemWallpaperPicker$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
}

View file

@ -0,0 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/live_wallpaper_preferences.provider.dart';
import 'package:immich_mobile/services/live_wallpaper_platform.service.dart';
import 'package:immich_mobile/platform/wallpaper_api.g.dart';
final liveWallpaperStatusProvider = FutureProvider<WallpaperStatusMessage>((ref) async {
final preferences = ref.watch(liveWallpaperPreferencesProvider);
final platformService = ref.watch(liveWallpaperPlatformServiceProvider);
await platformService.syncPreferences(preferences);
return platformService.getStatus();
});
final openWallpaperPickerProvider = Provider<Future<bool> Function()>((ref) {
final platformService = ref.watch(liveWallpaperPlatformServiceProvider);
return () => platformService.openSystemPicker();
});
final requestWallpaperRefreshProvider = Provider<Future<void> Function()>((ref) {
final platformService = ref.watch(liveWallpaperPlatformServiceProvider);
return () => platformService.requestRefresh();
});

View file

@ -0,0 +1,74 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
import 'package:immich_mobile/domain/services/live_wallpaper_preferences.service.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/domain/services/live_wallpaper_asset.service.dart';
final liveWallpaperPreferencesServiceProvider = Provider<LiveWallpaperPreferencesService>((ref) {
return LiveWallpaperPreferencesService(ref.watch(storeServiceProvider));
});
final liveWallpaperAssetServiceProvider = Provider<LiveWallpaperAssetService>((ref) {
return LiveWallpaperAssetService(ref.watch(assetApiRepositoryProvider));
});
final liveWallpaperPreferencesProvider =
StateNotifierProvider<LiveWallpaperPreferencesNotifier, LiveWallpaperPreferences>((ref) {
final service = ref.watch(liveWallpaperPreferencesServiceProvider);
return LiveWallpaperPreferencesNotifier(service);
});
class LiveWallpaperPreferencesNotifier extends StateNotifier<LiveWallpaperPreferences> {
LiveWallpaperPreferencesNotifier(this._service) : super(_service.load()) {
_subscription = _service.watch().listen((event) {
if (mounted) {
state = event;
}
});
}
final LiveWallpaperPreferencesService _service;
StreamSubscription<LiveWallpaperPreferences>? _subscription;
Future<void> toggleEnabled(bool enabled) async {
await _set(state.copyWith(enabled: enabled));
}
Future<void> setPersonIds(List<String> personIds) async {
await _set(state.copyWith(personIds: List.unmodifiable(personIds)));
}
Future<void> setRotationInterval(Duration interval) async {
await _set(state.copyWith(rotationInterval: interval));
}
Future<void> setRotationMode(RotationMode mode) async {
await _set(state.copyWith(rotationMode: mode));
}
Future<void> setAllowCellular(bool allow) async {
await _set(state.copyWith(allowCellularData: allow));
}
Future<void> setLastAsset(String? assetId, {DateTime? updatedAt}) async {
await _set(state.copyWith(lastAssetId: assetId, lastUpdated: updatedAt));
}
Future<void> reset() async {
await _set(const LiveWallpaperPreferences.defaults());
}
Future<void> _set(LiveWallpaperPreferences preferences) async {
state = preferences;
await _service.save(preferences);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}

View file

@ -44,6 +44,7 @@ import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/pages/common/tab_controller.page.dart';
import 'package:immich_mobile/pages/common/tab_shell.page.dart';
import 'package:immich_mobile/pages/common/live_wallpaper_setup.page.dart';
import 'package:immich_mobile/pages/editing/crop.page.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/pages/editing/filter.page.dart';
@ -233,6 +234,7 @@ class AppRouter extends RootStackRouter {
),
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: LiveWallpaperSetupRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
CustomRoute(

View file

@ -1822,6 +1822,22 @@ class LibraryRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [LiveWallpaperSetupPage]
class LiveWallpaperSetupRoute extends PageRouteInfo<void> {
const LiveWallpaperSetupRoute({List<PageRouteInfo>? children})
: super(LiveWallpaperSetupRoute.name, initialChildren: children);
static const String name = 'LiveWallpaperSetupRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const LiveWallpaperSetupPage();
},
);
}
/// generated route for
/// [LocalAlbumsPage]
class LocalAlbumsRoute extends PageRouteInfo<void> {

View file

@ -81,6 +81,7 @@ class DeepLinkService {
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
"live-wallpaper" => _buildLiveWallpaperDeepLink(link.uri.pathSegments),
_ => null,
};
@ -190,4 +191,11 @@ class DeepLinkService {
return AlbumViewerRoute(albumId: album.id);
}
}
PageRouteInfo<dynamic>? _buildLiveWallpaperDeepLink(List<String> pathSegments) {
if (pathSegments.isEmpty || pathSegments.first == 'settings') {
return const LiveWallpaperSetupRoute();
}
return const LiveWallpaperSetupRoute();
}
}

View file

@ -0,0 +1,34 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
import 'package:immich_mobile/platform/wallpaper_api.g.dart';
class LiveWallpaperPlatformService {
LiveWallpaperPlatformService(this._hostApi);
final WallpaperHostApi _hostApi;
Future<WallpaperStatusMessage> getStatus() {
return _hostApi.getStatus();
}
Future<void> syncPreferences(LiveWallpaperPreferences preferences) {
final message = WallpaperPreferencesMessage(
enabled: preferences.enabled,
personIds: preferences.personIds,
rotationMinutes: preferences.rotationInterval.inMinutes,
rotationMode: preferences.rotationMode.name,
allowCellularData: preferences.allowCellularData,
);
return _hostApi.setPreferences(message);
}
Future<void> requestRefresh() => _hostApi.requestRefresh();
Future<bool> openSystemPicker() => _hostApi.openSystemWallpaperPicker();
}
final liveWallpaperPlatformServiceProvider = Provider<LiveWallpaperPlatformService>((_) {
return LiveWallpaperPlatformService(WallpaperHostApi());
});

View file

@ -0,0 +1,53 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/live_wallpaper_platform.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
class LiveWallpaperSetting extends HookConsumerWidget {
const LiveWallpaperSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only show live wallpaper settings on Android
if (!Platform.isAndroid) {
return const SizedBox.shrink();
}
final statusAsync = ref.watch(liveWallpaperStatusProvider);
final statusText = statusAsync.when<String?>(
data: (status) {
if (!status.isSupported) {
return 'live_wallpaper_status_not_supported'.tr();
}
if (status.lastError != null && status.lastError!.isNotEmpty) {
return 'live_wallpaper_status_error'.tr(namedArgs: {'error': status.lastError!});
}
return null;
},
loading: () => 'live_wallpaper_status_syncing'.tr(),
error: (error, _) => 'live_wallpaper_status_error'.tr(namedArgs: {'error': error.toString()}),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: 'live_wallpaper_setting_title'.tr()),
Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text('live_wallpaper_setting_manage'.tr()),
subtitle: statusText == null ? null : Text(statusText, style: Theme.of(context).textTheme.bodySmall),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.pushRoute(const LiveWallpaperSetupRoute()),
),
),
],
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/haptic_setting.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/theme_setting.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/live_wallpaper_setting.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class PreferenceSetting extends StatelessWidget {
@ -8,7 +9,7 @@ class PreferenceSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
const preferenceSettings = [ThemeSetting(), HapticSetting()];
const preferenceSettings = [ThemeSetting(), HapticSetting(), LiveWallpaperSetting()];
return const SettingsSubPageScaffold(settings: preferenceSettings, showDivider: true);
}

View file

@ -0,0 +1,45 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/wallpaper_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.wallpaper'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
class WallpaperPreferencesMessage {
final bool enabled;
final List<String> personIds;
final int rotationMinutes;
final String rotationMode;
final bool allowCellularData;
const WallpaperPreferencesMessage({
required this.enabled,
required this.personIds,
required this.rotationMinutes,
required this.rotationMode,
required this.allowCellularData,
});
}
class WallpaperStatusMessage {
final bool isSupported;
final bool isActive;
final String? lastError;
const WallpaperStatusMessage({this.isSupported = true, this.isActive = false, this.lastError});
}
@HostApi()
abstract class WallpaperHostApi {
WallpaperStatusMessage getStatus();
void setPreferences(WallpaperPreferencesMessage preferences);
void requestRefresh();
bool openSystemWallpaperPicker();
}