mirror of
https://github.com/immich-app/immich.git
synced 2026-05-16 20:42:12 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f987c5569d | |||
| d903f38aad | |||
| a970b207d7 | |||
| 3d9be2477b |
@@ -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
|
|
||||||
}
|
|
||||||
+21
-76
@@ -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(
|
||||||
|
|||||||
+12
-13
@@ -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)
|
||||||
+2
-6
@@ -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;
|
||||||
|
|||||||
+13
-7
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user