diff --git a/i18n/en.json b/i18n/en.json index b142dc7fca..22354c2f8b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1231,6 +1231,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 Wi‑Fi", + "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", diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c6e04e5a10..8f40d988d7 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,12 @@ + + + + @@ -38,6 +44,23 @@ android:exported="false" android:foregroundServiceType="dataSync|shortService" /> + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 034f5ee72e..eee56d96f3 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -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()) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperService.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperService.kt new file mode 100644 index 0000000000..067d0d84f2 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperService.kt @@ -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 Wi‑Fi 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() + 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" + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperSettingsActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperSettingsActivity.kt new file mode 100644 index 0000000000..478dec22ae --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperSettingsActivity.kt @@ -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") + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt new file mode 100644 index 0000000000..b2e14889d8 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt @@ -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 { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + 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).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, + val rotationMinutes: Long, + val rotationMode: String, + val allowCellularData: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): WallpaperPreferencesMessage { + val enabled = pigeonVar_list[0] as Boolean + val personIds = pigeonVar_list[1] as List + 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 { + 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): 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 { + 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)?.let { + WallpaperPreferencesMessage.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.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 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(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.getStatus$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getStatus()) + } catch (exception: Throwable) { + WallpaperApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.setPreferences$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val preferencesArg = args[0] as WallpaperPreferencesMessage + val wrapped: List = try { + api.setPreferences(preferencesArg) + listOf(null) + } catch (exception: Throwable) { + WallpaperApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.requestRefresh$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.requestRefresh() + listOf(null) + } catch (exception: Throwable) { + WallpaperApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.openSystemWallpaperPicker$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.openSystemWallpaperPicker()) + } catch (exception: Throwable) { + WallpaperApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperHostApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperHostApiImpl.kt new file mode 100644 index 0000000000..0aed0c0971 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperHostApiImpl.kt @@ -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 + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperPreferencesStore.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperPreferencesStore.kt new file mode 100644 index 0000000000..3f77da5da7 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperPreferencesStore.kt @@ -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.toJson(): String { + val array = JSONArray() + forEach { array.put(it) } + return array.toString() + } + + private fun String.toStringList(): List { + return try { + val array = JSONArray(this) + val results = mutableListOf() + for (i in 0 until array.length()) { + results.add(array.optString(i)) + } + results + } catch (_: JSONException) { + emptyList() + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt index 545a1edc59..49a0787aa9 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt @@ -18,7 +18,9 @@ data class SearchFilters( var type: AssetType = AssetType.IMAGE, val size: Int = 1, var albumIds: List = listOf(), - var isFavorite: Boolean? = null + var isFavorite: Boolean? = null, + var personIds: List = listOf(), + var withPeople: Boolean? = null ) data class MemoryResult( diff --git a/mobile/android/app/src/main/res/layout/activity_wallpaper_settings.xml b/mobile/android/app/src/main/res/layout/activity_wallpaper_settings.xml new file mode 100644 index 0000000000..b7780bdb77 --- /dev/null +++ b/mobile/android/app/src/main/res/layout/activity_wallpaper_settings.xml @@ -0,0 +1,41 @@ + + + + + + + +