From 3d9be2477b7241b8969b1439db56db814754853a Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:06:39 -0500 Subject: [PATCH] use shared session for widgets --- .../alextran/immich/core/HttpClientManager.kt | 11 +- .../alextran/immich/images/LocalImagesImpl.kt | 16 +- .../immich/images/RemoteImagesImpl.kt | 37 +++-- .../app/alextran/immich/widget/BitmapUtils.kt | 33 ---- .../immich/widget/ImageDownloadWorker.kt | 97 +++--------- .../app/alextran/immich/widget/ImmichAPI.kt | 143 ++++++++---------- .../alextran/immich/widget/MemoryReceiver.kt | 58 ------- .../app/alextran/immich/widget/PhotoWidget.kt | 34 ++--- .../{RandomReceiver.kt => WidgetReceiver.kt} | 25 ++- .../widget/configure/RandomConfigure.kt | 8 +- .../app/alextran/immich/widget/model/Model.kt | 15 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 10 ++ .../Core => Shared}/URLSessionManager.swift | 84 +++++++++- mobile/ios/WidgetExtension/ImageEntry.swift | 78 +++------- mobile/ios/WidgetExtension/ImmichAPI.swift | 94 +++--------- .../ios/WidgetExtension/UIImage+Resize.swift | 23 --- .../widgets/MemoryWidget.swift | 16 +- .../widgets/RandomWidget.swift | 14 +- mobile/lib/constants/constants.dart | 6 - mobile/lib/providers/auth.provider.dart | 9 +- .../lib/repositories/widget.repository.dart | 20 --- mobile/lib/services/widget.service.dart | 35 +---- 22 files changed, 307 insertions(+), 559 deletions(-) delete mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt delete mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt rename mobile/android/app/src/main/kotlin/app/alextran/immich/widget/{RandomReceiver.kt => WidgetReceiver.kt} (67%) rename mobile/ios/{Runner/Core => Shared}/URLSessionManager.swift (78%) delete mode 100644 mobile/ios/WidgetExtension/UIImage+Resize.swift delete mode 100644 mobile/lib/repositories/widget.repository.dart diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 180ae4735d..499fd2b425 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -63,7 +63,9 @@ object HttpClientManager { private var initialized = false private val clientChangedListeners = mutableListOf<() -> Unit>() - private lateinit var client: OkHttpClient + @JvmStatic + lateinit var client: OkHttpClient + private set private lateinit var appContext: Context private lateinit var prefs: SharedPreferences @@ -79,6 +81,8 @@ object HttpClientManager { val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) + val serverUrl: String? get() = if (initialized) prefs.getString(PREFS_SERVER_URL, null) else null + fun initialize(context: Context) { if (initialized) return synchronized(this) { @@ -163,11 +167,6 @@ object HttpClientManager { private var clientGlobalRef: Long = 0L - @JvmStatic - fun getClient(): OkHttpClient { - return client - } - fun getClientPointer(): Long { if (clientGlobalRef == 0L) { clientGlobalRef = NativeBuffer.createGlobalRef(client) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index 3babad2e37..2ab2f33c07 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -32,14 +32,18 @@ data class Request( ) @RequiresApi(Build.VERSION_CODES.Q) -inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap { +fun ImageDecoder.Source.decodeBitmap( + target: Size = Size(0, 0), + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT, + colorspace: ColorSpace? = null +): Bitmap { return ImageDecoder.decodeBitmap(this) { decoder, info, _ -> if (target.width > 0 && target.height > 0) { val sample = max(1, min(info.size.width / target.width, info.size.height / target.height)) decoder.setTargetSampleSize(sample) } - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE - decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) + decoder.allocator = allocator + decoder.setTargetColorSpace(colorspace) } } @@ -228,7 +232,11 @@ class LocalImagesImpl(context: Context) : LocalImageApi { private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap { signal.throwIfCanceled() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ImageDecoder.createSource(resolver, uri).decodeBitmap(target) + ImageDecoder.createSource(resolver, uri).decodeBitmap( + target, + ImageDecoder.ALLOCATOR_SOFTWARE, + ColorSpace.get(ColorSpace.Named.SRGB) + ) } else { val ref = Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig() diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index b820b45425..05b4898de8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -1,6 +1,10 @@ package app.alextran.immich.images import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.os.Build import android.os.CancellationSignal import android.os.OperationCanceledException import app.alextran.immich.INITIAL_BUFFER_SIZE @@ -12,11 +16,11 @@ import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Call import okhttp3.Callback +import okhttp3.Credentials +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.Credentials -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest @@ -33,6 +37,21 @@ import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors +fun NativeByteBuffer.decodeBitmap(target: android.util.Size = android.util.Size(0, 0)): Bitmap { + try { + val byteBuffer = NativeBuffer.wrap(pointer, offset) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ImageDecoder.createSource(byteBuffer).decodeBitmap(target = target) + } else { + val bytes = ByteArray(offset) + byteBuffer.get(bytes) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: throw IOException("Failed to decode image") + } + } finally { + free() + } +} private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 @@ -52,7 +71,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { override fun requestImage( url: String, requestId: Long, - @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android + preferEncoded: Boolean, // always returns encoded; setting has no effect on Android callback: (Result?>) -> Unit ) { val signal = CancellationSignal() @@ -100,7 +119,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { } } -private object ImageFetcherManager { +object ImageFetcherManager { private lateinit var appContext: Context private lateinit var cacheDir: File private lateinit var fetcher: ImageFetcher @@ -148,7 +167,7 @@ private object ImageFetcherManager { } } -private sealed interface ImageFetcher { +internal sealed interface ImageFetcher { fun fetch( url: String, signal: CancellationSignal, @@ -161,7 +180,7 @@ private sealed interface ImageFetcher { fun clearCache(onCleared: (Result) -> Unit) } -private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { +internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { private val ctx = context private var engine: CronetEngine private val executor = Executors.newFixedThreadPool(4) @@ -341,7 +360,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } } - suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) { + private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) { var totalSize = 0L Files.walkFileTree(root, object : SimpleFileVisitor() { @@ -363,7 +382,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } } -private class OkHttpImageFetcher private constructor( +internal class OkHttpImageFetcher private constructor( private val client: OkHttpClient, ) : ImageFetcher { private val stateLock = Any() @@ -374,7 +393,7 @@ private class OkHttpImageFetcher private constructor( fun create(cacheDir: File): OkHttpImageFetcher { val dir = File(cacheDir, "okhttp") - val client = HttpClientManager.getClient().newBuilder() + val client = HttpClientManager.client.newBuilder() .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) .build() diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt deleted file mode 100644 index 9188df1700..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt +++ /dev/null @@ -1,33 +0,0 @@ -package app.alextran.immich.widget - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import java.io.File - -fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeFile(file.absolutePath, options) - - options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight) - options.inJustDecodeBounds = false - - return BitmapFactory.decodeFile(file.absolutePath, options) -} - -fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { - val (height: Int, width: Int) = options.run { outHeight to outWidth } - var inSampleSize = 1 - - if (height > reqHeight || width > reqWidth) { - val halfHeight: Int = height / 2 - val halfWidth: Int = width / 2 - - while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { - inSampleSize *= 2 - } - } - - return inSampleSize -} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt index 25a7ed99f1..e2e7dd5bd7 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt @@ -1,18 +1,12 @@ package app.alextran.immich.widget import android.content.Context -import android.graphics.Bitmap import android.util.Log import androidx.datastore.preferences.core.Preferences import androidx.glance.* import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.updateAppWidgetState import androidx.work.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream -import java.util.UUID import java.util.concurrent.TimeUnit import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition @@ -75,18 +69,8 @@ class ImageDownloadWorker( ) } - suspend fun cancel(context: Context, appWidgetId: Int) { + fun cancel(context: Context, appWidgetId: Int) { WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId") - - // delete cached image - val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) - val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) - val currentImgUUID = widgetConfig[kImageUUID] - - if (!currentImgUUID.isNullOrEmpty()) { - val file = File(context.cacheDir, imageFilename(currentImgUUID)) - file.delete() - } } } @@ -96,43 +80,22 @@ class ImageDownloadWorker( val widgetId = inputData.getInt(kWorkerWidgetID, -1) val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId) val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) - val currentImgUUID = widgetConfig[kImageUUID] - - val serverConfig = ImmichAPI.getServerConfig(context) - - // clear any image caches and go to "login" state if no credentials - if (serverConfig == null) { - if (!currentImgUUID.isNullOrEmpty()) { - deleteImage(currentImgUUID) - updateWidget( - glanceId, - "", - "", - "immich://", - WidgetState.LOG_IN - ) + // clear state and go to "login" if no credentials + if (!ImmichAPI.isLoggedIn(context)) { + val currentAssetId = widgetConfig[kAssetId] + if (!currentAssetId.isNullOrEmpty()) { + updateWidget(glanceId, "", "", "immich://", WidgetState.LOG_IN) } return Result.success() } - // fetch new image val entry = when (widgetType) { - WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig) - WidgetType.MEMORIES -> fetchMemory(serverConfig) + WidgetType.RANDOM -> fetchRandom(widgetConfig) + WidgetType.MEMORIES -> fetchMemory() } - // clear current image if it exists - if (!currentImgUUID.isNullOrEmpty()) { - deleteImage(currentImgUUID) - } - - // save a new image - val imgUUID = UUID.randomUUID().toString() - saveImage(entry.image, imgUUID) - - // trigger the update routine with new image uuid - updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink) + updateWidget(glanceId, entry.assetId, entry.subtitle, entry.deeplink) Result.success() } catch (e: Exception) { @@ -147,28 +110,25 @@ class ImageDownloadWorker( private suspend fun updateWidget( glanceId: GlanceId, - imageUUID: String, + assetId: String, subtitle: String?, deeplink: String?, widgetState: WidgetState = WidgetState.SUCCESS ) { updateAppWidgetState(context, glanceId) { prefs -> prefs[kNow] = System.currentTimeMillis() - prefs[kImageUUID] = imageUUID + prefs[kAssetId] = assetId prefs[kWidgetState] = widgetState.toString() prefs[kSubtitleText] = subtitle ?: "" prefs[kDeeplinkURL] = deeplink ?: "" } - PhotoWidget().update(context,glanceId) + PhotoWidget().update(context, glanceId) } private suspend fun fetchRandom( - serverConfig: ServerConfig, widgetConfig: Preferences ): WidgetEntry { - val api = ImmichAPI(serverConfig) - val filters = SearchFilters() val albumId = widgetConfig[kSelectedAlbum] val showSubtitle = widgetConfig[kShowAlbumName] @@ -182,31 +142,27 @@ class ImageDownloadWorker( filters.albumIds = listOf(albumId) } - var randomSearch = api.fetchSearchResults(filters) + var randomSearch = ImmichAPI.fetchSearchResults(filters) // handle an empty album, fallback to random if (randomSearch.isEmpty() && albumId != null) { - randomSearch = api.fetchSearchResults(SearchFilters()) + randomSearch = ImmichAPI.fetchSearchResults(SearchFilters()) subtitle = "" } val random = randomSearch.first() - val image = api.fetchImage(random) + ImmichAPI.fetchImage(random).free() // warm the HTTP disk cache return WidgetEntry( - image, + random.id, subtitle, assetDeeplink(random) ) } - private suspend fun fetchMemory( - serverConfig: ServerConfig - ): WidgetEntry { - val api = ImmichAPI(serverConfig) - + private suspend fun fetchMemory(): WidgetEntry { val today = LocalDate.now() - val memories = api.fetchMemory(today) + val memories = ImmichAPI.fetchMemory(today) val asset: Asset var subtitle: String? = null @@ -219,26 +175,15 @@ class ImageDownloadWorker( subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago" } else { val filters = SearchFilters(size=1) - asset = api.fetchSearchResults(filters).first() + asset = ImmichAPI.fetchSearchResults(filters).first() } - val image = api.fetchImage(asset) + ImmichAPI.fetchImage(asset).free() // warm the HTTP disk cache return WidgetEntry( - image, + asset.id, subtitle, assetDeeplink(asset) ) } - private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) { - val file = File(context.cacheDir, imageFilename(uuid)) - file.delete() - } - - private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) { - val file = File(context.cacheDir, imageFilename(uuid)) - FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) - } - } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt index c55db8da93..da9e52d882 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt @@ -1,122 +1,97 @@ package app.alextran.immich.widget import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory +import android.os.CancellationSignal +import app.alextran.immich.NativeByteBuffer +import app.alextran.immich.core.HttpClientManager +import app.alextran.immich.images.ImageFetcherManager import app.alextran.immich.widget.model.* import com.google.gson.Gson import com.google.gson.reflect.TypeToken -import es.antonborri.home_widget.HomeWidgetPlugin import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import java.io.OutputStreamWriter -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLEncoder +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.time.LocalDate import java.time.format.DateTimeFormatter +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException -class ImmichAPI(cfg: ServerConfig) { - - companion object { - fun getServerConfig(context: Context): ServerConfig? { - val prefs = HomeWidgetPlugin.getData(context) - - val serverURL = prefs.getString("widget_server_url", "") ?: "" - val sessionKey = prefs.getString("widget_auth_token", "") ?: "" - val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: "" - - if (serverURL.isBlank() || sessionKey.isBlank()) { - return null - } - - var customHeaders: Map = HashMap() - - if (customHeadersJSON.isNotBlank()) { - val stringMapType = object : TypeToken>() {}.type - customHeaders = Gson().fromJson(customHeadersJSON, stringMapType) - } - - return ServerConfig( - serverURL, - sessionKey, - customHeaders - ) - } - } - - +object ImmichAPI { private val gson = Gson() - private val serverConfig = cfg + private val serverEndpoint: String + get() = HttpClientManager.serverUrl ?: throw IllegalStateException("Not logged in") - private fun buildRequestURL(endpoint: String, params: List> = emptyList()): URL { - val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}") - - for ((key, value) in params) { - urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}") - } - - return URL(urlString.toString()) + private fun initialize(context: Context) { + HttpClientManager.initialize(context) + ImageFetcherManager.initialize(context) } - private fun HttpURLConnection.applyCustomHeaders() { - serverConfig.customHeaders.forEach { (key, value) -> - setRequestProperty(key, value) + fun isLoggedIn(context: Context): Boolean { + initialize(context) + return HttpClientManager.serverUrl != null + } + + private fun buildRequestURL(endpoint: String, params: List> = emptyList()): String { + val url = StringBuilder("$serverEndpoint$endpoint") + + if (params.isNotEmpty()) { + url.append("?") + url.append(params.joinToString("&") { (key, value) -> + "${java.net.URLEncoder.encode(key, "UTF-8")}=${java.net.URLEncoder.encode(value, "UTF-8")}" + }) } + + return url.toString() } suspend fun fetchSearchResults(filters: SearchFilters): List = withContext(Dispatchers.IO) { val url = buildRequestURL("/search/random") - val connection = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "POST" - setRequestProperty("Content-Type", "application/json") - applyCustomHeaders() + val body = gson.toJson(filters).toRequestBody("application/json".toMediaType()) + val request = Request.Builder().url(url).post(body).build() - doOutput = true + HttpClientManager.client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: throw Exception("Empty response") + val type = object : TypeToken>() {}.type + gson.fromJson(responseBody, type) } - - connection.outputStream.use { - OutputStreamWriter(it).use { writer -> - writer.write(gson.toJson(filters)) - writer.flush() - } - } - - val response = connection.inputStream.bufferedReader().readText() - val type = object : TypeToken>() {}.type - gson.fromJson(response, type) } suspend fun fetchMemory(date: LocalDate): List = withContext(Dispatchers.IO) { val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE) val url = buildRequestURL("/memories", listOf("for" to iso8601)) - val connection = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - applyCustomHeaders() - } + val request = Request.Builder().url(url).get().build() - val response = connection.inputStream.bufferedReader().readText() - val type = object : TypeToken>() {}.type - gson.fromJson(response, type) + HttpClientManager.client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: throw Exception("Empty response") + val type = object : TypeToken>() {}.type + gson.fromJson(responseBody, type) + } } - suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) { + suspend fun fetchImage(asset: Asset): NativeByteBuffer = suspendCancellableCoroutine { cont -> val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true")) - val connection = url.openConnection() - val data = connection.getInputStream().readBytes() - BitmapFactory.decodeByteArray(data, 0, data.size) - ?: throw Exception("Invalid image data") + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + + ImageFetcherManager.fetch( + url, + signal, + onSuccess = { buffer -> cont.resume(buffer) }, + onFailure = { e -> cont.resumeWithException(e) } + ) } suspend fun fetchAlbums(): List = withContext(Dispatchers.IO) { val url = buildRequestURL("/albums") - val connection = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - applyCustomHeaders() - } + val request = Request.Builder().url(url).get().build() - val response = connection.inputStream.bufferedReader().readText() - val type = object : TypeToken>() {}.type - gson.fromJson(response, type) + HttpClientManager.client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: throw Exception("Empty response") + val type = object : TypeToken>() {}.type + gson.fromJson(responseBody, type) + } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt deleted file mode 100644 index 63b32eb6f0..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt +++ /dev/null @@ -1,58 +0,0 @@ -package app.alextran.immich.widget - -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import androidx.glance.appwidget.GlanceAppWidgetReceiver -import app.alextran.immich.widget.model.* -import es.antonborri.home_widget.HomeWidgetPlugin -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class MemoryReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget = PhotoWidget() - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - - appWidgetIds.forEach { widgetID -> - ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES) - } - } - - override fun onReceive(context: Context, intent: Intent) { - val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false) - val provider = ComponentName(context, MemoryReceiver::class.java) - val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) - - // Launch coroutine to setup a single shot if the app requested the update - if (fromMainApp) { - glanceIds.forEach { widgetID -> - ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES) - } - } - - // make sure the periodic jobs are running - glanceIds.forEach { widgetID -> - ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES) - } - - super.onReceive(context, intent) - } - - override fun onDeleted(context: Context, appWidgetIds: IntArray) { - super.onDeleted(context, appWidgetIds) - CoroutineScope(Dispatchers.Default).launch { - appWidgetIds.forEach { id -> - ImageDownloadWorker.cancel(context, id) - } - } - } -} - diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt index b1a0a9de31..2dd950120b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt @@ -2,12 +2,12 @@ package app.alextran.immich.widget import android.content.Context import android.content.Intent -import android.graphics.Bitmap +import android.util.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.* import androidx.core.net.toUri -import androidx.datastore.preferences.core.MutablePreferences import androidx.glance.appwidget.* +import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.* import androidx.glance.action.clickable import androidx.glance.layout.* @@ -18,30 +18,28 @@ import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider import app.alextran.immich.R +import app.alextran.immich.images.decodeBitmap import app.alextran.immich.widget.model.* -import java.io.File class PhotoWidget : GlanceAppWidget() { override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition override suspend fun provideGlance(context: Context, id: GlanceId) { - provideContent { - val prefs = currentState() + val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id) + val assetId = state[kAssetId] + val subtitle = state[kSubtitleText] + val deeplinkURL = state[kDeeplinkURL]?.toUri() + val widgetState = state[kWidgetState] - val imageUUID = prefs[kImageUUID] - val subtitle = prefs[kSubtitleText] - val deeplinkURL = prefs[kDeeplinkURL]?.toUri() - val widgetState = prefs[kWidgetState] - var bitmap: Bitmap? = null - - if (imageUUID != null) { - // fetch a random photo from server - val file = File(context.cacheDir, imageFilename(imageUUID)) - - if (file.exists()) { - bitmap = loadScaledBitmap(file, 500, 500) - } + val bitmap = if (!assetId.isNullOrEmpty() && ImmichAPI.isLoggedIn(context)) { + try { + ImmichAPI.fetchImage(Asset(assetId, AssetType.IMAGE)).decodeBitmap(Size(500, 500)) + } catch (e: Exception) { + null } + } else null + + provideContent { // WIDGET CONTENT Box( diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetReceiver.kt similarity index 67% rename from mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetReceiver.kt index a7662181bc..cc832a4366 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetReceiver.kt @@ -4,14 +4,11 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import android.content.Intent -import es.antonborri.home_widget.HomeWidgetPlugin import androidx.glance.appwidget.GlanceAppWidgetReceiver import app.alextran.immich.widget.model.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import es.antonborri.home_widget.HomeWidgetPlugin -class RandomReceiver : GlanceAppWidgetReceiver() { +abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWidgetReceiver() { override val glanceAppWidget = PhotoWidget() override fun onUpdate( @@ -22,25 +19,25 @@ class RandomReceiver : GlanceAppWidgetReceiver() { super.onUpdate(context, appWidgetManager, appWidgetIds) appWidgetIds.forEach { widgetID -> - ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM) + ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType) } } override fun onReceive(context: Context, intent: Intent) { val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false) - val provider = ComponentName(context, RandomReceiver::class.java) + val provider = ComponentName(context, this::class.java) val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) // Launch coroutine to setup a single shot if the app requested the update if (fromMainApp) { glanceIds.forEach { widgetID -> - ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM) + ImageDownloadWorker.singleShot(context, widgetID, widgetType) } } // make sure the periodic jobs are running glanceIds.forEach { widgetID -> - ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM) + ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType) } super.onReceive(context, intent) @@ -48,10 +45,12 @@ class RandomReceiver : GlanceAppWidgetReceiver() { override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) - CoroutineScope(Dispatchers.Default).launch { - appWidgetIds.forEach { id -> - ImageDownloadWorker.cancel(context, id) - } + appWidgetIds.forEach { id -> + ImageDownloadWorker.cancel(context, id) } } } + +class MemoryReceiver : WidgetReceiver(WidgetType.MEMORIES) + +class RandomReceiver : WidgetReceiver(WidgetType.RANDOM) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt index 83e404a8f1..e01fbfb981 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt @@ -71,22 +71,18 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, LaunchedEffect(Unit) { // get albums from server - val serverCfg = ImmichAPI.getServerConfig(context) - - if (serverCfg == null) { + if (!ImmichAPI.isLoggedIn(context)) { state = WidgetConfigState.LOG_IN return@LaunchedEffect } - val api = ImmichAPI(serverCfg) - val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE" val currentAlbumName = currentState[kSelectedAlbumName] ?: "None" var albumItems: List try { - albumItems = api.fetchAlbums().map { + albumItems = ImmichAPI.fetchAlbums().map { DropdownItem(it.albumName, it.id) } 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..4fbcb00790 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 @@ -1,6 +1,5 @@ package app.alextran.immich.widget.model -import android.graphics.Bitmap import androidx.datastore.preferences.core.* // MARK: Immich Entities @@ -50,19 +49,13 @@ enum class WidgetConfigState { } data class WidgetEntry ( - val image: Bitmap, + val assetId: String, val subtitle: String?, val deeplink: String? ) -data class ServerConfig( - val serverEndpoint: String, - val sessionKey: String, - val customHeaders: Map -) - // MARK: Widget State Keys -val kImageUUID = stringPreferencesKey("uuid") +val kAssetId = stringPreferencesKey("assetId") val kSubtitleText = stringPreferencesKey("subtitle") val kNow = longPreferencesKey("now") val kWidgetState = stringPreferencesKey("state") @@ -75,10 +68,6 @@ const val kWorkerWidgetType = "widgetType" const val kWorkerWidgetID = "widgetId" const val kTriggeredFromApp = "triggeredFromApp" -fun imageFilename(id: String): String { - return "widget_image_$id.jpg" -} - fun assetDeeplink(asset: Asset): String { return "immich://asset?id=${asset.id}" } diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 22a7abcbac..6d25f8a88b 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -140,6 +140,13 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A872EC0CA71550E4AB04E049 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = Shared; + sourceTree = ""; + }; B231F52D2E93A44A00BC45D1 /* Core */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -257,6 +264,7 @@ 97C146EF1CF9000F007C117D /* Products */, 0FB772A5B9601143383626CA /* Pods */, 1754452DD81DA6620E279E51 /* Frameworks */, + A872EC0CA71550E4AB04E049 /* Shared */, ); sourceTree = ""; }; @@ -362,6 +370,7 @@ F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + A872EC0CA71550E4AB04E049 /* Shared */, B231F52D2E93A44A00BC45D1 /* Core */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, FEE084F22EC172080045228E /* Schemas */, @@ -384,6 +393,7 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + A872EC0CA71550E4AB04E049 /* Shared */, F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */, ); name = WidgetExtension; diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Shared/URLSessionManager.swift similarity index 78% rename from mobile/ios/Runner/Core/URLSessionManager.swift rename to mobile/ios/Shared/URLSessionManager.swift index 9868d4eb59..d7eb649865 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Shared/URLSessionManager.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(native_video_player) import native_video_player +#endif let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let HEADERS_KEY = "immich.request_headers" @@ -36,7 +38,7 @@ extension UserDefaults { /// Old sessions are kept alive by Dart's FFI retain until all isolates release them. class URLSessionManager: NSObject { static let shared = URLSessionManager() - + private(set) var session: URLSession let delegate: URLSessionManagerDelegate private static let cacheDir: URL = { @@ -144,7 +146,71 @@ class URLSessionManager: NSObject { } } - private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { + static func setServerUrls(_ urls: [String]) { + guard urls != serverUrls else { return } + serverUrls = urls + UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY) + syncAuthCookies() + } + + @objc private static func cookiesDidChange(_ notification: Notification) { + guard !isSyncing, !serverUrls.isEmpty else { return } + syncAuthCookies() + } + + private static func syncAuthCookies() { + let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host }) + let allCookies = cookieStorage.cookies ?? [] + let now = Date() + + let serverAuthCookies = allCookies.filter { + AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain) + } + + var sourceCookies: [String: HTTPCookie] = [:] + for cookie in serverAuthCookies { + if cookie.expiresDate.map({ $0 > now }) ?? true { + sourceCookies[cookie.name] = cookie + } + } + + isSyncing = true + defer { isSyncing = false } + + if sourceCookies.isEmpty { + for cookie in serverAuthCookies { + cookieStorage.deleteCookie(cookie) + } + return + } + + for serverUrl in serverUrls { + guard let url = URL(string: serverUrl), let domain = url.host else { continue } + let isSecure = serverUrl.hasPrefix("https") + + for (_, source) in sourceCookies { + if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) { + continue + } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: source.name, + .value: source.value, + .domain: domain, + .path: "/", + .expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60), + ] + if isSecure { properties[.secure] = "TRUE" } + if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" } + + if let cookie = HTTPCookie(properties: properties) { + cookieStorage.setCookie(cookie) + } + } + } + } + + private static func buildSession(delegate: URLSessionDelegate) -> URLSession { let config = URLSessionConfiguration.default config.urlCache = urlCache config.httpCookieStorage = cookieStorage @@ -168,7 +234,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler) } - + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -177,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler, task: task) } - + func handleChallenge( _ session: URLSession, _ challenge: URLAuthenticationChallenge, @@ -190,7 +256,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb default: completionHandler(.performDefaultHandling, nil) } } - + private func handleClientCertificate( _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void @@ -200,21 +266,23 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb kSecAttrLabel as String: CLIENT_CERT_LABEL, kSecReturnRef as String: true, ] - + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecSuccess, let identity = item { let credential = URLCredential(identity: identity as! SecIdentity, certificates: nil, persistence: .forSession) + #if canImport(native_video_player) if #available(iOS 15, *) { VideoProxyServer.shared.session = session } + #endif return completion(.useCredential, credential) } completion(.performDefaultHandling, nil) } - + private func handleBasicAuth( _ session: URLSession, task: URLSessionTask?, @@ -226,9 +294,11 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb else { return completion(.performDefaultHandling, nil) } + #if canImport(native_video_player) if #available(iOS 15, *) { VideoProxyServer.shared.session = session } + #endif let credential = URLCredential(user: user, password: password, persistence: .forSession) completion(.useCredential, credential) } diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift index ee371703a8..e188a9bbc5 100644 --- a/mobile/ios/WidgetExtension/ImageEntry.swift +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -9,6 +9,7 @@ struct ImageEntry: TimelineEntry { var metadata: Metadata = Metadata() struct Metadata: Codable { + var assetId: String? = nil var subtitle: String? = nil var error: WidgetError? = nil var deepLink: URL? = nil @@ -33,80 +34,39 @@ struct ImageEntry: TimelineEntry { date: entryDate, image: image, metadata: EntryMetadata( + assetId: asset.id, subtitle: subtitle, deepLink: asset.deepLink ) ) } - func cache(for key: String) throws { - if let containerURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP - ) { - let imageURL = containerURL.appendingPathComponent("\(key)_image.png") - let metadataURL = containerURL.appendingPathComponent( - "\(key)_metadata.json" - ) - - // build metadata JSON - let entryMetadata = try JSONEncoder().encode(self.metadata) - - // write to disk - try self.image?.pngData()?.write(to: imageURL, options: .atomic) - try entryMetadata.write(to: metadataURL, options: .atomic) + static func saveLast(for key: String, metadata: Metadata) { + if let data = try? JSONEncoder().encode(metadata) { + UserDefaults.group.set(data, forKey: "widget_last_\(key)") } } - static func loadCached(for key: String, at date: Date = Date.now) - -> ImageEntry? - { - if let containerURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP - ) { - let imageURL = containerURL.appendingPathComponent("\(key)_image.png") - let metadataURL = containerURL.appendingPathComponent( - "\(key)_metadata.json" - ) - - guard let imageData = try? Data(contentsOf: imageURL), - let metadataJSON = try? Data(contentsOf: metadataURL), - let decodedMetadata = try? JSONDecoder().decode( - Metadata.self, - from: metadataJSON - ) - else { - return nil - } - - return ImageEntry( - date: date, - image: UIImage(data: imageData), - metadata: decodedMetadata - ) - } - - return nil - } - static func handleError( for key: String, + api: ImmichAPI? = nil, error: WidgetError = .fetchFailed - ) -> Timeline { - var timelineEntry = ImageEntry( - date: Date.now, - image: nil, - metadata: EntryMetadata(error: error) - ) - - // use cache if generic failed error - // we want to show the other errors to the user since without intervention, - // it will never succeed - if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key) + ) async -> Timeline { + // Try to show the last image from the URL cache for transient failures + if error == .fetchFailed, let api = api, + let data = UserDefaults.group.data(forKey: "widget_last_\(key)"), + let cached = try? JSONDecoder().decode(Metadata.self, from: data), + let assetId = cached.assetId, + let image = try? await api.fetchImage(asset: Asset(id: assetId, type: .image)) { - timelineEntry = cachedEntry + let entry = ImageEntry(date: Date.now, image: image, metadata: cached) + return Timeline(entries: [entry], policy: .atEnd) } - return Timeline(entries: [timelineEntry], policy: .atEnd) + return Timeline( + entries: [ImageEntry(date: Date.now, metadata: Metadata(error: error))], + policy: .atEnd + ) } } diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift index 6ae2d502f8..d63e9a592d 100644 --- a/mobile/ios/WidgetExtension/ImmichAPI.swift +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI import WidgetKit -let IMMICH_SHARE_GROUP = "group.app.immich.share" +// Constants and session configuration are in Shared/SharedURLSession.swift enum WidgetError: Error, Codable { case noLogin @@ -104,87 +104,47 @@ struct Album: Codable, Equatable { // MARK: API class ImmichAPI { - typealias CustomHeaders = [String:String] - struct ServerConfig { - let serverEndpoint: String - let sessionKey: String - let customHeaders: CustomHeaders - } - - let serverConfig: ServerConfig + let serverEndpoint: String init() async throws { - // fetch the credentials from the UserDefaults store that dart placed here - guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP), - let serverURL = defaults.string(forKey: "widget_server_url"), - let sessionKey = defaults.string(forKey: "widget_auth_token") + guard let serverURL = UserDefaults.group.string(forKey: SERVER_URL_KEY), + !serverURL.isEmpty else { throw WidgetError.noLogin } - if serverURL == "" || sessionKey == "" { - throw WidgetError.noLogin - } - - // custom headers come in the form of KV pairs in JSON - var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "") - var customHeaders: CustomHeaders = [:] - - if customHeadersJSON != "", - let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) { - customHeaders = parsedHeaders - } - - serverConfig = ServerConfig( - serverEndpoint: serverURL, - sessionKey: sessionKey, - customHeaders: customHeaders - ) + serverEndpoint = serverURL } private func buildRequestURL( - serverConfig: ServerConfig, endpoint: String, params: [URLQueryItem] = [] ) -> URL? { - guard let baseURL = URL(string: serverConfig.serverEndpoint) else { + guard let baseURL = URL(string: serverEndpoint) else { fatalError("Invalid base URL") } - // Combine the base URL and API path let fullPath = baseURL.appendingPathComponent( endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ) - // Add the session key as a query parameter var components = URLComponents( url: fullPath, resolvingAgainstBaseURL: false ) - components?.queryItems = [ - URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey) - ] - components?.queryItems?.append(contentsOf: params) + if !params.isEmpty { + components?.queryItems = params + } return components?.url } - - func applyCustomHeaders(for request: inout URLRequest) { - for (header, value) in serverConfig.customHeaders { - request.addValue(value, forHTTPHeaderField: header) - } - } func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter) async throws -> [Asset] { - // get URL guard - let searchURL = buildRequestURL( - serverConfig: serverConfig, - endpoint: "/search/random" - ) + let searchURL = buildRequestURL(endpoint: "/search/random") else { throw URLError(.badURL) } @@ -193,20 +153,15 @@ class ImmichAPI { request.httpMethod = "POST" request.httpBody = try JSONEncoder().encode(filters) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - applyCustomHeaders(for: &request) - - let (data, _) = try await URLSession.shared.data(for: request) - // decode data + let (data, _) = try await URLSessionManager.shared.session.data(for: request) return try JSONDecoder().decode([Asset].self, from: data) } func fetchMemory(for date: Date) async throws -> [MemoryResult] { - // get URL let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())] guard let searchURL = buildRequestURL( - serverConfig: serverConfig, endpoint: "/memories", params: memoryParams ) @@ -216,11 +171,8 @@ class ImmichAPI { var request = URLRequest(url: searchURL) request.httpMethod = "GET" - applyCustomHeaders(for: &request) - let (data, _) = try await URLSession.shared.data(for: request) - - // decode data + let (data, _) = try await URLSessionManager.shared.session.data(for: request) return try JSONDecoder().decode([MemoryResult].self, from: data) } @@ -230,7 +182,6 @@ class ImmichAPI { guard let fetchURL = buildRequestURL( - serverConfig: serverConfig, endpoint: assetEndpoint, params: thumbnailParams ) @@ -238,9 +189,13 @@ class ImmichAPI { throw .invalidURL } - guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) - else { - throw .invalidURL + let request = URLRequest(url: fetchURL) + guard let (data, _) = try? await URLSessionManager.shared.session.data(for: request) else { + throw .fetchFailed + } + + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { + throw .invalidImage } let decodeOptions: [NSString: Any] = [ @@ -263,23 +218,16 @@ class ImmichAPI { } func fetchAlbums() async throws -> [Album] { - // get URL guard - let searchURL = buildRequestURL( - serverConfig: serverConfig, - endpoint: "/albums" - ) + let searchURL = buildRequestURL(endpoint: "/albums") else { throw URLError(.badURL) } var request = URLRequest(url: searchURL) request.httpMethod = "GET" - applyCustomHeaders(for: &request) - - let (data, _) = try await URLSession.shared.data(for: request) - // decode data + let (data, _) = try await URLSessionManager.shared.session.data(for: request) return try JSONDecoder().decode([Album].self, from: data) } } diff --git a/mobile/ios/WidgetExtension/UIImage+Resize.swift b/mobile/ios/WidgetExtension/UIImage+Resize.swift deleted file mode 100644 index 030f354ca4..0000000000 --- a/mobile/ios/WidgetExtension/UIImage+Resize.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Utils.swift -// Runner -// -// Created by Alex Tran and Brandon Wees on 6/16/25. -// -import UIKit - -extension UIImage { - /// Crops the image to ensure width and height do not exceed maxSize. - /// Keeps original aspect ratio and crops excess equally from edges (center crop). - func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? { - let canvas = CGSize( - width: width, - height: CGFloat(ceil(width / size.width * size.height)) - ) - let format = imageRendererFormat - format.opaque = isOpaque - return UIGraphicsImageRenderer(size: canvas, format: format).image { - _ in draw(in: CGRect(origin: .zero, size: canvas)) - } - } -} diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift index 22414fbec4..58e6be30a7 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -24,14 +24,14 @@ struct ImmichMemoryProvider: TimelineProvider { Task { guard let api = try? await ImmichAPI() else { completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! + await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! ) return } guard let memories = try? await api.fetchMemory(for: Date.now) else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) + completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!) return } @@ -58,7 +58,7 @@ struct ImmichMemoryProvider: TimelineProvider { dateOffset: 0 ) else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) + completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!) return } @@ -78,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider { guard let api = try? await ImmichAPI() else { completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin) + await ImageEntry.handleError(for: cacheKey, error: .noLogin) ) return } @@ -129,20 +129,20 @@ struct ImmichMemoryProvider: TimelineProvider { // Load or save a cached asset for when network conditions are bad if search.count == 0 { completion( - ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) ) return } entries.append(contentsOf: search) } catch { - completion(ImageEntry.handleError(for: cacheKey)) + completion(await ImageEntry.handleError(for: cacheKey, api: api)) return } } - // cache the last image - try? entries.last!.cache(for: cacheKey) + // save the last asset for fallback + ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata) completion(Timeline(entries: entries, policy: .atEnd)) } diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index 37f3c5e596..87c2d35093 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -65,7 +65,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { let cacheKey = "random_none_\(context.family.rawValue)" guard let api = try? await ImmichAPI() else { - return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries + return await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries .first! } @@ -79,7 +79,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { dateOffset: 0 ) else { - return ImageEntry.handleError(for: cacheKey).entries.first! + return await ImageEntry.handleError(for: cacheKey, api: api).entries.first! } return entry @@ -102,7 +102,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { // If we don't have a server config, return an entry with an error guard let api = try? await ImmichAPI() else { - return ImageEntry.handleError(for: cacheKey, error: .noLogin) + return await ImageEntry.handleError(for: cacheKey, error: .noLogin) } // build entries @@ -119,16 +119,16 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { // Load or save a cached asset for when network conditions are bad if search.count == 0 { - return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + return await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) } entries.append(contentsOf: search) } catch { - return ImageEntry.handleError(for: cacheKey) + return await ImageEntry.handleError(for: cacheKey, api: api) } - // cache the last image - try? entries.last!.cache(for: cacheKey) + // save the last asset for fallback + ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata) return Timeline(entries: entries, policy: .atEnd) } diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 9d28941b8f..524f37d514 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -33,12 +33,6 @@ const int kTimelineNoneSegmentSize = 120; const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadOppositeSize = 64; -// Widget keys -const String appShareGroupId = "group.app.immich.share"; -const String kWidgetAuthToken = "widget_auth_token"; -const String kWidgetServerEndpoint = "widget_server_url"; -const String kWidgetCustomHeaders = "widget_custom_headers"; - // add widget identifiers here for new widgets // these are used to force a widget refresh // (iOSName, androidFQDN) diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 825d9e7bc8..03d7c33e64 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -87,9 +89,8 @@ class AuthNotifier extends StateNotifier { Future logout() async { try { await _secureStorageService.delete(kSecuredPinCode); - await _widgetService.clearCredentials(); - await _authService.logout(); + unawaited(_widgetService.refreshWidgets()); await _ref.read(backgroundUploadServiceProvider).cancel(); _ref.read(foregroundUploadServiceProvider).cancel(); } finally { @@ -126,9 +127,7 @@ class AuthNotifier extends StateNotifier { await Store.put(StoreKey.accessToken, accessToken); await _apiService.updateHeaders(); - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final customHeaders = Store.tryGet(StoreKey.customHeaders); - await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders); + unawaited(_widgetService.refreshWidgets()); // Get the deviceid from the store if it exists, otherwise generate a new one String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; diff --git a/mobile/lib/repositories/widget.repository.dart b/mobile/lib/repositories/widget.repository.dart deleted file mode 100644 index f21d31e1ec..0000000000 --- a/mobile/lib/repositories/widget.repository.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:home_widget/home_widget.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -final widgetRepositoryProvider = Provider((_) => const WidgetRepository()); - -class WidgetRepository { - const WidgetRepository(); - - Future saveData(String key, String value) async { - await HomeWidget.saveWidgetData(key, value); - } - - Future refresh(String iosName, String androidName) async { - await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName); - } - - Future setAppGroupId(String appGroupId) async { - await HomeWidget.setAppGroupId(appGroupId); - } -} diff --git a/mobile/lib/services/widget.service.dart b/mobile/lib/services/widget.service.dart index 23ec0aa770..5f22979e72 100644 --- a/mobile/lib/services/widget.service.dart +++ b/mobile/lib/services/widget.service.dart @@ -1,42 +1,15 @@ +import 'package:home_widget/home_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/repositories/widget.repository.dart'; -final widgetServiceProvider = Provider((ref) { - return WidgetService(ref.watch(widgetRepositoryProvider)); -}); +final widgetServiceProvider = Provider((_) => const WidgetService()); class WidgetService { - final WidgetRepository _repository; - - const WidgetService(this._repository); - - Future writeCredentials(String serverURL, String sessionKey, String? customHeaders) async { - await _repository.setAppGroupId(appShareGroupId); - await _repository.saveData(kWidgetServerEndpoint, serverURL); - await _repository.saveData(kWidgetAuthToken, sessionKey); - - if (customHeaders != null && customHeaders.isNotEmpty) { - await _repository.saveData(kWidgetCustomHeaders, customHeaders); - } - - // wait 3 seconds to ensure the widget is updated, dont block - Future.delayed(const Duration(seconds: 3), refreshWidgets); - } - - Future clearCredentials() async { - await _repository.setAppGroupId(appShareGroupId); - await _repository.saveData(kWidgetServerEndpoint, ""); - await _repository.saveData(kWidgetAuthToken, ""); - await _repository.saveData(kWidgetCustomHeaders, ""); - - // wait 3 seconds to ensure the widget is updated, dont block - Future.delayed(const Duration(seconds: 3), refreshWidgets); - } + const WidgetService(); Future refreshWidgets() async { for (final (iOSName, androidName) in kWidgetNames) { - await _repository.refresh(iOSName, androidName); + await HomeWidget.updateWidget(iOSName: iOSName, qualifiedAndroidName: androidName); } } }