Compare commits

...

4 Commits

Author SHA1 Message Date
mertalev f987c5569d avoid fatal error 2026-03-13 17:41:22 -05:00
mertalev d903f38aad update url 2026-03-13 17:33:02 -05:00
mertalev a970b207d7 fix rebase 2026-03-11 12:54:38 -05:00
mertalev 3d9be2477b use shared session for widgets 2026-03-11 12:46:46 -05:00
22 changed files with 249 additions and 563 deletions
@@ -63,7 +63,9 @@ object HttpClientManager {
private var initialized = false private var initialized = false
private val clientChangedListeners = mutableListOf<() -> Unit>() 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 appContext: Context
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
@@ -79,6 +81,9 @@ object HttpClientManager {
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
val serverUrl: String? get() = if (initialized) prefs.getString(PREFS_SERVER_URLS, null)
?.let { Json.decodeFromString<List<String>>(it).firstOrNull() } else null
fun initialize(context: Context) { fun initialize(context: Context) {
if (initialized) return if (initialized) return
synchronized(this) { synchronized(this) {
@@ -163,11 +168,6 @@ object HttpClientManager {
private var clientGlobalRef: Long = 0L private var clientGlobalRef: Long = 0L
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun getClientPointer(): Long { fun getClientPointer(): Long {
if (clientGlobalRef == 0L) { if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client) clientGlobalRef = NativeBuffer.createGlobalRef(client)
@@ -32,14 +32,18 @@ data class Request(
) )
@RequiresApi(Build.VERSION_CODES.Q) @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, _ -> return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) { if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height)) val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample) decoder.setTargetSampleSize(sample)
} }
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE decoder.allocator = allocator
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) decoder.setTargetColorSpace(colorspace)
} }
} }
@@ -228,7 +232,11 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap { private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled() signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 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 { } else {
val ref = val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig() Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()
@@ -1,6 +1,10 @@
package app.alextran.immich.images package app.alextran.immich.images
import android.content.Context 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.CancellationSignal
import android.os.OperationCanceledException import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.INITIAL_BUFFER_SIZE
@@ -12,11 +16,11 @@ import kotlinx.coroutines.*
import okhttp3.Cache import okhttp3.Cache
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import org.chromium.net.CronetException import org.chromium.net.CronetException
import org.chromium.net.UrlRequest import org.chromium.net.UrlRequest
@@ -33,6 +37,21 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors 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 private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
@@ -52,7 +71,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage( override fun requestImage(
url: String, url: String,
requestId: Long, 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<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
) { ) {
val signal = CancellationSignal() 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 appContext: Context
private lateinit var cacheDir: File private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher private lateinit var fetcher: ImageFetcher
@@ -148,7 +167,7 @@ private object ImageFetcherManager {
} }
} }
private sealed interface ImageFetcher { internal sealed interface ImageFetcher {
fun fetch( fun fetch(
url: String, url: String,
signal: CancellationSignal, signal: CancellationSignal,
@@ -161,7 +180,7 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit) fun clearCache(onCleared: (Result<Long>) -> Unit)
} }
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context private val ctx = context
private var engine: CronetEngine private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4) 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 var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() { Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
@@ -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, private val client: OkHttpClient,
) : ImageFetcher { ) : ImageFetcher {
private val stateLock = Any() private val stateLock = Any()
@@ -374,7 +393,7 @@ private class OkHttpImageFetcher private constructor(
fun create(cacheDir: File): OkHttpImageFetcher { fun create(cacheDir: File): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp") val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder() val client = HttpClientManager.client.newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.build() .build()
@@ -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
}
@@ -1,18 +1,12 @@
package app.alextran.immich.widget package app.alextran.immich.widget
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.util.Log import android.util.Log
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.glance.* import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.* 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 java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition 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") 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 widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId) val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID] // clear state and go to "login" if no credentials
if (!ImmichAPI.isLoggedIn(context)) {
val serverConfig = ImmichAPI.getServerConfig(context) val currentAssetId = widgetConfig[kAssetId]
if (!currentAssetId.isNullOrEmpty()) {
// clear any image caches and go to "login" state if no credentials updateWidget(glanceId, "", "", "immich://", WidgetState.LOG_IN)
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
} }
return Result.success() return Result.success()
} }
// fetch new image
val entry = when (widgetType) { val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig) WidgetType.RANDOM -> fetchRandom(widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig) WidgetType.MEMORIES -> fetchMemory()
} }
// clear current image if it exists updateWidget(glanceId, entry.assetId, entry.subtitle, entry.deeplink)
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)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
@@ -147,28 +110,25 @@ class ImageDownloadWorker(
private suspend fun updateWidget( private suspend fun updateWidget(
glanceId: GlanceId, glanceId: GlanceId,
imageUUID: String, assetId: String,
subtitle: String?, subtitle: String?,
deeplink: String?, deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS widgetState: WidgetState = WidgetState.SUCCESS
) { ) {
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis() prefs[kNow] = System.currentTimeMillis()
prefs[kImageUUID] = imageUUID prefs[kAssetId] = assetId
prefs[kWidgetState] = widgetState.toString() prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: "" prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: "" prefs[kDeeplinkURL] = deeplink ?: ""
} }
PhotoWidget().update(context,glanceId) PhotoWidget().update(context, glanceId)
} }
private suspend fun fetchRandom( private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences widgetConfig: Preferences
): WidgetEntry { ): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters() val filters = SearchFilters()
val albumId = widgetConfig[kSelectedAlbum] val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName] val showSubtitle = widgetConfig[kShowAlbumName]
@@ -182,31 +142,27 @@ class ImageDownloadWorker(
filters.albumIds = listOf(albumId) filters.albumIds = listOf(albumId)
} }
var randomSearch = api.fetchSearchResults(filters) var randomSearch = ImmichAPI.fetchSearchResults(filters)
// handle an empty album, fallback to random // handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) { if (randomSearch.isEmpty() && albumId != null) {
randomSearch = api.fetchSearchResults(SearchFilters()) randomSearch = ImmichAPI.fetchSearchResults(SearchFilters())
subtitle = "" subtitle = ""
} }
val random = randomSearch.first() val random = randomSearch.first()
val image = api.fetchImage(random) ImmichAPI.fetchImage(random).free() // warm the HTTP disk cache
return WidgetEntry( return WidgetEntry(
image, random.id,
subtitle, subtitle,
assetDeeplink(random) assetDeeplink(random)
) )
} }
private suspend fun fetchMemory( private suspend fun fetchMemory(): WidgetEntry {
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val today = LocalDate.now() val today = LocalDate.now()
val memories = api.fetchMemory(today) val memories = ImmichAPI.fetchMemory(today)
val asset: Asset val asset: Asset
var subtitle: String? = null var subtitle: String? = null
@@ -219,26 +175,15 @@ class ImageDownloadWorker(
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago" subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else { } else {
val filters = SearchFilters(size=1) 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( return WidgetEntry(
image, asset.id,
subtitle, subtitle,
assetDeeplink(asset) 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)
}
}
} }
@@ -1,122 +1,97 @@
package app.alextran.immich.widget package app.alextran.immich.widget
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.os.CancellationSignal
import android.graphics.BitmapFactory import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.images.ImageFetcherManager
import app.alextran.immich.widget.model.* import app.alextran.immich.widget.model.*
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter import okhttp3.MediaType.Companion.toMediaType
import java.net.HttpURLConnection import okhttp3.Request
import java.net.URL import okhttp3.RequestBody.Companion.toRequestBody
import java.net.URLEncoder
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class ImmichAPI(cfg: ServerConfig) { object ImmichAPI {
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<String, String> = HashMap<String, String>()
if (customHeadersJSON.isNotBlank()) {
val stringMapType = object : TypeToken<Map<String, String>>() {}.type
customHeaders = Gson().fromJson(customHeadersJSON, stringMapType)
}
return ServerConfig(
serverURL,
sessionKey,
customHeaders
)
}
}
private val gson = Gson() 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<Pair<String, String>> = emptyList()): URL { private fun initialize(context: Context) {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}") HttpClientManager.initialize(context)
ImageFetcherManager.initialize(context)
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return URL(urlString.toString())
} }
private fun HttpURLConnection.applyCustomHeaders() { fun isLoggedIn(context: Context): Boolean {
serverConfig.customHeaders.forEach { (key, value) -> initialize(context)
setRequestProperty(key, value) return HttpClientManager.serverUrl != null
}
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = 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<Asset> = withContext(Dispatchers.IO) { suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random") val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply { val body = gson.toJson(filters).toRequestBody("application/json".toMediaType())
requestMethod = "POST" val request = Request.Builder().url(url).post(body).build()
setRequestProperty("Content-Type", "application/json")
applyCustomHeaders()
doOutput = true HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<Asset>>() {}.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<List<Asset>>() {}.type
gson.fromJson(response, type)
} }
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) { suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE) val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601)) val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply { val request = Request.Builder().url(url).get().build()
requestMethod = "GET"
applyCustomHeaders()
}
val response = connection.inputStream.bufferedReader().readText() HttpClientManager.client.newCall(request).execute().use { response ->
val type = object : TypeToken<List<MemoryResult>>() {}.type val responseBody = response.body?.string() ?: throw Exception("Empty response")
gson.fromJson(response, type) val type = object : TypeToken<List<MemoryResult>>() {}.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 url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
val connection = url.openConnection() val signal = CancellationSignal()
val data = connection.getInputStream().readBytes() cont.invokeOnCancellation { signal.cancel() }
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data") ImageFetcherManager.fetch(
url,
signal,
onSuccess = { buffer -> cont.resume(buffer) },
onFailure = { e -> cont.resumeWithException(e) }
)
} }
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) { suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums") val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply { val request = Request.Builder().url(url).get().build()
requestMethod = "GET"
applyCustomHeaders()
}
val response = connection.inputStream.bufferedReader().readText() HttpClientManager.client.newCall(request).execute().use { response ->
val type = object : TypeToken<List<Album>>() {}.type val responseBody = response.body?.string() ?: throw Exception("Empty response")
gson.fromJson(response, type) val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(responseBody, type)
}
} }
} }
@@ -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)
}
}
}
}
@@ -2,12 +2,12 @@ package app.alextran.immich.widget
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.util.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.* import androidx.glance.appwidget.*
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.* import androidx.glance.*
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.layout.* import androidx.glance.layout.*
@@ -18,30 +18,28 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider import androidx.glance.unit.ColorProvider
import app.alextran.immich.R import app.alextran.immich.R
import app.alextran.immich.images.decodeBitmap
import app.alextran.immich.widget.model.* import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() { class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent { val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val prefs = currentState<MutablePreferences>() val assetId = state[kAssetId]
val subtitle = state[kSubtitleText]
val deeplinkURL = state[kDeeplinkURL]?.toUri()
val widgetState = state[kWidgetState]
val imageUUID = prefs[kImageUUID] val bitmap = if (!assetId.isNullOrEmpty() && ImmichAPI.isLoggedIn(context)) {
val subtitle = prefs[kSubtitleText] try {
val deeplinkURL = prefs[kDeeplinkURL]?.toUri() ImmichAPI.fetchImage(Asset(assetId, AssetType.IMAGE)).decodeBitmap(Size(500, 500))
val widgetState = prefs[kWidgetState] } catch (e: Exception) {
var bitmap: Bitmap? = null 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)
}
} }
} else null
provideContent {
// WIDGET CONTENT // WIDGET CONTENT
Box( Box(
@@ -4,14 +4,11 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.* import app.alextran.immich.widget.model.*
import kotlinx.coroutines.CoroutineScope import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RandomReceiver : GlanceAppWidgetReceiver() { abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget() override val glanceAppWidget = PhotoWidget()
override fun onUpdate( override fun onUpdate(
@@ -22,25 +19,25 @@ class RandomReceiver : GlanceAppWidgetReceiver() {
super.onUpdate(context, appWidgetManager, appWidgetIds) super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID -> appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM) ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
} }
} }
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false) 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) val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update // Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) { if (fromMainApp) {
glanceIds.forEach { widgetID -> glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM) ImageDownloadWorker.singleShot(context, widgetID, widgetType)
} }
} }
// make sure the periodic jobs are running // make sure the periodic jobs are running
glanceIds.forEach { widgetID -> glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM) ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
} }
super.onReceive(context, intent) super.onReceive(context, intent)
@@ -48,10 +45,12 @@ class RandomReceiver : GlanceAppWidgetReceiver() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) { override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds) super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch { appWidgetIds.forEach { id ->
appWidgetIds.forEach { id -> ImageDownloadWorker.cancel(context, id)
ImageDownloadWorker.cancel(context, id)
}
} }
} }
} }
class MemoryReceiver : WidgetReceiver(WidgetType.MEMORIES)
class RandomReceiver : WidgetReceiver(WidgetType.RANDOM)
@@ -71,22 +71,18 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId,
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// get albums from server // get albums from server
val serverCfg = ImmichAPI.getServerConfig(context) if (!ImmichAPI.isLoggedIn(context)) {
if (serverCfg == null) {
state = WidgetConfigState.LOG_IN state = WidgetConfigState.LOG_IN
return@LaunchedEffect return@LaunchedEffect
} }
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE" val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None" val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem> var albumItems: List<DropdownItem>
try { try {
albumItems = api.fetchAlbums().map { albumItems = ImmichAPI.fetchAlbums().map {
DropdownItem(it.albumName, it.id) DropdownItem(it.albumName, it.id)
} }
@@ -1,6 +1,5 @@
package app.alextran.immich.widget.model package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.* import androidx.datastore.preferences.core.*
// MARK: Immich Entities // MARK: Immich Entities
@@ -50,19 +49,13 @@ enum class WidgetConfigState {
} }
data class WidgetEntry ( data class WidgetEntry (
val image: Bitmap, val assetId: String,
val subtitle: String?, val subtitle: String?,
val deeplink: String? val deeplink: String?
) )
data class ServerConfig(
val serverEndpoint: String,
val sessionKey: String,
val customHeaders: Map<String, String>
)
// MARK: Widget State Keys // MARK: Widget State Keys
val kImageUUID = stringPreferencesKey("uuid") val kAssetId = stringPreferencesKey("assetId")
val kSubtitleText = stringPreferencesKey("subtitle") val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now") val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state") val kWidgetState = stringPreferencesKey("state")
@@ -75,10 +68,6 @@ const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId" const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp" const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String { fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}" return "immich://asset?id=${asset.id}"
} }
@@ -140,6 +140,13 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
A872EC0CA71550E4AB04E049 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Shared;
sourceTree = "<group>";
};
B231F52D2E93A44A00BC45D1 /* Core */ = { B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
@@ -257,6 +264,7 @@
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
0FB772A5B9601143383626CA /* Pods */, 0FB772A5B9601143383626CA /* Pods */,
1754452DD81DA6620E279E51 /* Frameworks */, 1754452DD81DA6620E279E51 /* Frameworks */,
A872EC0CA71550E4AB04E049 /* Shared */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -362,6 +370,7 @@
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */, F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
B231F52D2E93A44A00BC45D1 /* Core */, B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FEE084F22EC172080045228E /* Schemas */, FEE084F22EC172080045228E /* Schemas */,
@@ -384,6 +393,7 @@
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */, F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
); );
name = WidgetExtension; name = WidgetExtension;
@@ -1,5 +1,7 @@
import Foundation import Foundation
#if canImport(native_video_player)
import native_video_player import native_video_player
#endif
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers" 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. /// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
class URLSessionManager: NSObject { class URLSessionManager: NSObject {
static let shared = URLSessionManager() static let shared = URLSessionManager()
private(set) var session: URLSession private(set) var session: URLSession
let delegate: URLSessionManagerDelegate let delegate: URLSessionManagerDelegate
private static let cacheDir: URL = { private static let cacheDir: URL = {
@@ -144,7 +146,7 @@ class URLSessionManager: NSObject {
} }
} }
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { private static func buildSession(delegate: URLSessionDelegate) -> URLSession {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.urlCache = urlCache config.urlCache = urlCache
config.httpCookieStorage = cookieStorage config.httpCookieStorage = cookieStorage
@@ -168,7 +170,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) { ) {
handleChallenge(session, challenge, completionHandler) handleChallenge(session, challenge, completionHandler)
} }
func urlSession( func urlSession(
_ session: URLSession, _ session: URLSession,
task: URLSessionTask, task: URLSessionTask,
@@ -177,7 +179,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) { ) {
handleChallenge(session, challenge, completionHandler, task: task) handleChallenge(session, challenge, completionHandler, task: task)
} }
func handleChallenge( func handleChallenge(
_ session: URLSession, _ session: URLSession,
_ challenge: URLAuthenticationChallenge, _ challenge: URLAuthenticationChallenge,
@@ -190,7 +192,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
default: completionHandler(.performDefaultHandling, nil) default: completionHandler(.performDefaultHandling, nil)
} }
} }
private func handleClientCertificate( private func handleClientCertificate(
_ session: URLSession, _ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
@@ -200,21 +202,23 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
kSecAttrLabel as String: CLIENT_CERT_LABEL, kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true, kSecReturnRef as String: true,
] ]
var item: CFTypeRef? var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item) let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let identity = item { if status == errSecSuccess, let identity = item {
let credential = URLCredential(identity: identity as! SecIdentity, let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil, certificates: nil,
persistence: .forSession) persistence: .forSession)
#if canImport(native_video_player)
if #available(iOS 15, *) { if #available(iOS 15, *) {
VideoProxyServer.shared.session = session VideoProxyServer.shared.session = session
} }
#endif
return completion(.useCredential, credential) return completion(.useCredential, credential)
} }
completion(.performDefaultHandling, nil) completion(.performDefaultHandling, nil)
} }
private func handleBasicAuth( private func handleBasicAuth(
_ session: URLSession, _ session: URLSession,
task: URLSessionTask?, task: URLSessionTask?,
@@ -226,9 +230,11 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
else { else {
return completion(.performDefaultHandling, nil) return completion(.performDefaultHandling, nil)
} }
#if canImport(native_video_player)
if #available(iOS 15, *) { if #available(iOS 15, *) {
VideoProxyServer.shared.session = session VideoProxyServer.shared.session = session
} }
#endif
let credential = URLCredential(user: user, password: password, persistence: .forSession) let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential) completion(.useCredential, credential)
} }
+19 -59
View File
@@ -9,6 +9,7 @@ struct ImageEntry: TimelineEntry {
var metadata: Metadata = Metadata() var metadata: Metadata = Metadata()
struct Metadata: Codable { struct Metadata: Codable {
var assetId: String? = nil
var subtitle: String? = nil var subtitle: String? = nil
var error: WidgetError? = nil var error: WidgetError? = nil
var deepLink: URL? = nil var deepLink: URL? = nil
@@ -33,80 +34,39 @@ struct ImageEntry: TimelineEntry {
date: entryDate, date: entryDate,
image: image, image: image,
metadata: EntryMetadata( metadata: EntryMetadata(
assetId: asset.id,
subtitle: subtitle, subtitle: subtitle,
deepLink: asset.deepLink deepLink: asset.deepLink
) )
) )
} }
func cache(for key: String) throws { static func saveLast(for key: String, metadata: Metadata) {
if let containerURL = FileManager.default.containerURL( if let data = try? JSONEncoder().encode(metadata) {
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP UserDefaults.group.set(data, forKey: "widget_last_\(key)")
) {
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 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( static func handleError(
for key: String, for key: String,
api: ImmichAPI? = nil,
error: WidgetError = .fetchFailed error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> { ) async -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry( // Try to show the last image from the URL cache for transient failures
date: Date.now, if error == .fetchFailed, let api = api,
image: nil, let data = UserDefaults.group.data(forKey: "widget_last_\(key)"),
metadata: EntryMetadata(error: error) 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))
// 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)
{ {
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
)
} }
} }
+26 -77
View File
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
let IMMICH_SHARE_GROUP = "group.app.immich.share" // Constants and session configuration are in Shared/SharedURLSession.swift
enum WidgetError: Error, Codable { enum WidgetError: Error, Codable {
case noLogin case noLogin
@@ -104,87 +104,48 @@ struct Album: Codable, Equatable {
// MARK: API // MARK: API
class ImmichAPI { class ImmichAPI {
typealias CustomHeaders = [String:String] let serverEndpoint: String
struct ServerConfig {
let serverEndpoint: String
let sessionKey: String
let customHeaders: CustomHeaders
}
let serverConfig: ServerConfig
init() async throws { init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here guard let serverURLs = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY),
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP), let serverURL = serverURLs.first,
let serverURL = defaults.string(forKey: "widget_server_url"), !serverURL.isEmpty
let sessionKey = defaults.string(forKey: "widget_auth_token")
else { else {
throw WidgetError.noLogin throw WidgetError.noLogin
} }
if serverURL == "" || sessionKey == "" { serverEndpoint = serverURL
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
)
} }
private func buildRequestURL( private func buildRequestURL(
serverConfig: ServerConfig,
endpoint: String, endpoint: String,
params: [URLQueryItem] = [] params: [URLQueryItem] = []
) -> URL? { ) throws(FetchError) -> URL? {
guard let baseURL = URL(string: serverConfig.serverEndpoint) else { guard let baseURL = URL(string: serverEndpoint) else {
fatalError("Invalid base URL") throw FetchError.invalidURL
} }
// Combine the base URL and API path
let fullPath = baseURL.appendingPathComponent( let fullPath = baseURL.appendingPathComponent(
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/")) endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
) )
// Add the session key as a query parameter
var components = URLComponents( var components = URLComponents(
url: fullPath, url: fullPath,
resolvingAgainstBaseURL: false resolvingAgainstBaseURL: false
) )
components?.queryItems = [ if !params.isEmpty {
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey) components?.queryItems = params
] }
components?.queryItems?.append(contentsOf: params)
return components?.url 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) func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws async throws
-> [Asset] -> [Asset]
{ {
// get URL
guard guard
let searchURL = buildRequestURL( let searchURL = try buildRequestURL(endpoint: "/search/random")
serverConfig: serverConfig,
endpoint: "/search/random"
)
else { else {
throw URLError(.badURL) throw URLError(.badURL)
} }
@@ -193,20 +154,15 @@ class ImmichAPI {
request.httpMethod = "POST" request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(filters) request.httpBody = try JSONEncoder().encode(filters)
request.setValue("application/json", forHTTPHeaderField: "Content-Type") 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) return try JSONDecoder().decode([Asset].self, from: data)
} }
func fetchMemory(for date: Date) async throws -> [MemoryResult] { func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())] let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
guard guard
let searchURL = buildRequestURL( let searchURL = try buildRequestURL(
serverConfig: serverConfig,
endpoint: "/memories", endpoint: "/memories",
params: memoryParams params: memoryParams
) )
@@ -216,11 +172,8 @@ class ImmichAPI {
var request = URLRequest(url: searchURL) var request = URLRequest(url: searchURL)
request.httpMethod = "GET" request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSessionManager.shared.session.data(for: request)
// decode data
return try JSONDecoder().decode([MemoryResult].self, from: data) return try JSONDecoder().decode([MemoryResult].self, from: data)
} }
@@ -229,8 +182,7 @@ class ImmichAPI {
let assetEndpoint = "/assets/" + asset.id + "/thumbnail" let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard guard
let fetchURL = buildRequestURL( let fetchURL = try buildRequestURL(
serverConfig: serverConfig,
endpoint: assetEndpoint, endpoint: assetEndpoint,
params: thumbnailParams params: thumbnailParams
) )
@@ -238,9 +190,13 @@ class ImmichAPI {
throw .invalidURL throw .invalidURL
} }
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) let request = URLRequest(url: fetchURL)
else { guard let (data, _) = try? await URLSessionManager.shared.session.data(for: request) else {
throw .invalidURL throw .fetchFailed
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
throw .invalidImage
} }
let decodeOptions: [NSString: Any] = [ let decodeOptions: [NSString: Any] = [
@@ -263,23 +219,16 @@ class ImmichAPI {
} }
func fetchAlbums() async throws -> [Album] { func fetchAlbums() async throws -> [Album] {
// get URL
guard guard
let searchURL = buildRequestURL( let searchURL = try buildRequestURL(endpoint: "/albums")
serverConfig: serverConfig,
endpoint: "/albums"
)
else { else {
throw URLError(.badURL) throw URLError(.badURL)
} }
var request = URLRequest(url: searchURL) var request = URLRequest(url: searchURL)
request.httpMethod = "GET" 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) return try JSONDecoder().decode([Album].self, from: data)
} }
} }
@@ -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))
}
}
}
@@ -24,14 +24,14 @@ struct ImmichMemoryProvider: TimelineProvider {
Task { Task {
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
completion( completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
) )
return return
} }
guard let memories = try? await api.fetchMemory(for: Date.now) guard let memories = try? await api.fetchMemory(for: Date.now)
else { else {
completion(ImageEntry.handleError(for: cacheKey).entries.first!) completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
return return
} }
@@ -58,7 +58,7 @@ struct ImmichMemoryProvider: TimelineProvider {
dateOffset: 0 dateOffset: 0
) )
else { else {
completion(ImageEntry.handleError(for: cacheKey).entries.first!) completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
return return
} }
@@ -78,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
completion( completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin) await ImageEntry.handleError(for: cacheKey, error: .noLogin)
) )
return return
} }
@@ -129,20 +129,20 @@ struct ImmichMemoryProvider: TimelineProvider {
// Load or save a cached asset for when network conditions are bad // Load or save a cached asset for when network conditions are bad
if search.count == 0 { if search.count == 0 {
completion( completion(
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
) )
return return
} }
entries.append(contentsOf: search) entries.append(contentsOf: search)
} catch { } catch {
completion(ImageEntry.handleError(for: cacheKey)) completion(await ImageEntry.handleError(for: cacheKey, api: api))
return return
} }
} }
// cache the last image // save the last asset for fallback
try? entries.last!.cache(for: cacheKey) ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
completion(Timeline(entries: entries, policy: .atEnd)) completion(Timeline(entries: entries, policy: .atEnd))
} }
@@ -65,7 +65,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let cacheKey = "random_none_\(context.family.rawValue)" let cacheKey = "random_none_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else { 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! .first!
} }
@@ -79,7 +79,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
dateOffset: 0 dateOffset: 0
) )
else { else {
return ImageEntry.handleError(for: cacheKey).entries.first! return await ImageEntry.handleError(for: cacheKey, api: api).entries.first!
} }
return entry return entry
@@ -102,7 +102,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// If we don't have a server config, return an entry with an error // If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
return ImageEntry.handleError(for: cacheKey, error: .noLogin) return await ImageEntry.handleError(for: cacheKey, error: .noLogin)
} }
// build entries // build entries
@@ -119,16 +119,16 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// Load or save a cached asset for when network conditions are bad // Load or save a cached asset for when network conditions are bad
if search.count == 0 { if search.count == 0 {
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) return await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
} }
entries.append(contentsOf: search) entries.append(contentsOf: search)
} catch { } catch {
return ImageEntry.handleError(for: cacheKey) return await ImageEntry.handleError(for: cacheKey, api: api)
} }
// cache the last image // save the last asset for fallback
try? entries.last!.cache(for: cacheKey) ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
return Timeline(entries: entries, policy: .atEnd) return Timeline(entries: entries, policy: .atEnd)
} }
-6
View File
@@ -33,12 +33,6 @@ const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64; 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 // add widget identifiers here for new widgets
// these are used to force a widget refresh // these are used to force a widget refresh
// (iOSName, androidFQDN) // (iOSName, androidFQDN)
+4 -5
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_udid/flutter_udid.dart'; import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -87,9 +89,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async { Future<void> logout() async {
try { try {
await _secureStorageService.delete(kSecuredPinCode); await _secureStorageService.delete(kSecuredPinCode);
await _widgetService.clearCredentials();
await _authService.logout(); await _authService.logout();
unawaited(_widgetService.refreshWidgets());
await _ref.read(backgroundUploadServiceProvider).cancel(); await _ref.read(backgroundUploadServiceProvider).cancel();
_ref.read(foregroundUploadServiceProvider).cancel(); _ref.read(foregroundUploadServiceProvider).cancel();
} finally { } finally {
@@ -126,9 +127,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await Store.put(StoreKey.accessToken, accessToken); await Store.put(StoreKey.accessToken, accessToken);
await _apiService.updateHeaders(); await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint); unawaited(_widgetService.refreshWidgets());
final customHeaders = Store.tryGet(StoreKey.customHeaders);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
// Get the deviceid from the store if it exists, otherwise generate a new one // Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
@@ -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<void> saveData(String key, String value) async {
await HomeWidget.saveWidgetData<String>(key, value);
}
Future<void> refresh(String iosName, String androidName) async {
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
}
Future<void> setAppGroupId(String appGroupId) async {
await HomeWidget.setAppGroupId(appGroupId);
}
}
+4 -31
View File
@@ -1,42 +1,15 @@
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/repositories/widget.repository.dart';
final widgetServiceProvider = Provider((ref) { final widgetServiceProvider = Provider((_) => const WidgetService());
return WidgetService(ref.watch(widgetRepositoryProvider));
});
class WidgetService { class WidgetService {
final WidgetRepository _repository; const WidgetService();
const WidgetService(this._repository);
Future<void> 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<void> 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);
}
Future<void> refreshWidgets() async { Future<void> refreshWidgets() async {
for (final (iOSName, androidName) in kWidgetNames) { for (final (iOSName, androidName) in kWidgetNames) {
await _repository.refresh(iOSName, androidName); await HomeWidget.updateWidget(iOSName: iOSName, qualifiedAndroidName: androidName);
} }
} }
} }