use shared session for widgets

This commit is contained in:
mertalev
2026-03-07 14:06:39 -05:00
parent 0a79dd1228
commit 3d9be2477b
22 changed files with 307 additions and 559 deletions
@@ -63,7 +63,9 @@ object HttpClientManager {
private var initialized = false
private val clientChangedListeners = mutableListOf<() -> Unit>()
private lateinit var client: OkHttpClient
@JvmStatic
lateinit var client: OkHttpClient
private set
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
@@ -79,6 +81,8 @@ object HttpClientManager {
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
val serverUrl: String? get() = if (initialized) prefs.getString(PREFS_SERVER_URL, null) else null
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
@@ -163,11 +167,6 @@ object HttpClientManager {
private var clientGlobalRef: Long = 0L
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
@@ -32,14 +32,18 @@ data class Request(
)
@RequiresApi(Build.VERSION_CODES.Q)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
fun ImageDecoder.Source.decodeBitmap(
target: Size = Size(0, 0),
allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT,
colorspace: ColorSpace? = null
): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
decoder.allocator = allocator
decoder.setTargetColorSpace(colorspace)
}
}
@@ -228,7 +232,11 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
ImageDecoder.createSource(resolver, uri).decodeBitmap(
target,
ImageDecoder.ALLOCATOR_SOFTWARE,
ColorSpace.get(ColorSpace.Named.SRGB)
)
} else {
val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()
@@ -1,6 +1,10 @@
package app.alextran.immich.images
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE
@@ -12,11 +16,11 @@ import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
@@ -33,6 +37,21 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
fun NativeByteBuffer.decodeBitmap(target: android.util.Size = android.util.Size(0, 0)): Bitmap {
try {
val byteBuffer = NativeBuffer.wrap(pointer, offset)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(byteBuffer).decodeBitmap(target = target)
} else {
val bytes = ByteArray(offset)
byteBuffer.get(bytes)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IOException("Failed to decode image")
}
} finally {
free()
}
}
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
@@ -52,7 +71,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage(
url: String,
requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
@@ -100,7 +119,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
}
private object ImageFetcherManager {
object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
@@ -148,7 +167,7 @@ private object ImageFetcherManager {
}
}
private sealed interface ImageFetcher {
internal sealed interface ImageFetcher {
fun fetch(
url: String,
signal: CancellationSignal,
@@ -161,7 +180,7 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
@@ -341,7 +360,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
}
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<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,
) : ImageFetcher {
private val stateLock = Any()
@@ -374,7 +393,7 @@ private class OkHttpImageFetcher private constructor(
fun create(cacheDir: File): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder()
val client = HttpClientManager.client.newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.build()
@@ -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
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
@@ -75,18 +69,8 @@ class ImageDownloadWorker(
)
}
suspend fun cancel(context: Context, appWidgetId: Int) {
fun cancel(context: Context, appWidgetId: Int) {
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
// delete cached image
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
if (!currentImgUUID.isNullOrEmpty()) {
val file = File(context.cacheDir, imageFilename(currentImgUUID))
file.delete()
}
}
}
@@ -96,43 +80,22 @@ class ImageDownloadWorker(
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context)
// clear any image caches and go to "login" state if no credentials
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
// clear state and go to "login" if no credentials
if (!ImmichAPI.isLoggedIn(context)) {
val currentAssetId = widgetConfig[kAssetId]
if (!currentAssetId.isNullOrEmpty()) {
updateWidget(glanceId, "", "", "immich://", WidgetState.LOG_IN)
}
return Result.success()
}
// fetch new image
val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
WidgetType.RANDOM -> fetchRandom(widgetConfig)
WidgetType.MEMORIES -> fetchMemory()
}
// clear current image if it exists
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
}
// save a new image
val imgUUID = UUID.randomUUID().toString()
saveImage(entry.image, imgUUID)
// trigger the update routine with new image uuid
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
updateWidget(glanceId, entry.assetId, entry.subtitle, entry.deeplink)
Result.success()
} catch (e: Exception) {
@@ -147,28 +110,25 @@ class ImageDownloadWorker(
private suspend fun updateWidget(
glanceId: GlanceId,
imageUUID: String,
assetId: String,
subtitle: String?,
deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis()
prefs[kImageUUID] = imageUUID
prefs[kAssetId] = assetId
prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: ""
}
PhotoWidget().update(context,glanceId)
PhotoWidget().update(context, glanceId)
}
private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters()
val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName]
@@ -182,31 +142,27 @@ class ImageDownloadWorker(
filters.albumIds = listOf(albumId)
}
var randomSearch = api.fetchSearchResults(filters)
var randomSearch = ImmichAPI.fetchSearchResults(filters)
// handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) {
randomSearch = api.fetchSearchResults(SearchFilters())
randomSearch = ImmichAPI.fetchSearchResults(SearchFilters())
subtitle = ""
}
val random = randomSearch.first()
val image = api.fetchImage(random)
ImmichAPI.fetchImage(random).free() // warm the HTTP disk cache
return WidgetEntry(
image,
random.id,
subtitle,
assetDeeplink(random)
)
}
private suspend fun fetchMemory(
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
private suspend fun fetchMemory(): WidgetEntry {
val today = LocalDate.now()
val memories = api.fetchMemory(today)
val memories = ImmichAPI.fetchMemory(today)
val asset: Asset
var subtitle: String? = null
@@ -219,26 +175,15 @@ class ImageDownloadWorker(
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else {
val filters = SearchFilters(size=1)
asset = api.fetchSearchResults(filters).first()
asset = ImmichAPI.fetchSearchResults(filters).first()
}
val image = api.fetchImage(asset)
ImmichAPI.fetchImage(asset).free() // warm the HTTP disk cache
return WidgetEntry(
image,
asset.id,
subtitle,
assetDeeplink(asset)
)
}
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
file.delete()
}
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}
@@ -1,122 +1,97 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.CancellationSignal
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.images.ImageFetcherManager
import app.alextran.immich.widget.model.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class ImmichAPI(cfg: ServerConfig) {
companion object {
fun getServerConfig(context: Context): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) {
return null
}
var customHeaders: Map<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
)
}
}
object ImmichAPI {
private val gson = Gson()
private val serverConfig = cfg
private val serverEndpoint: String
get() = HttpClientManager.serverUrl ?: throw IllegalStateException("Not logged in")
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return URL(urlString.toString())
private fun initialize(context: Context) {
HttpClientManager.initialize(context)
ImageFetcherManager.initialize(context)
}
private fun HttpURLConnection.applyCustomHeaders() {
serverConfig.customHeaders.forEach { (key, value) ->
setRequestProperty(key, value)
fun isLoggedIn(context: Context): Boolean {
initialize(context)
return HttpClientManager.serverUrl != null
}
private fun buildRequestURL(endpoint: String, params: List<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) {
val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
applyCustomHeaders()
val body = gson.toJson(filters).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(url).post(body).build()
doOutput = true
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<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) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val request = Request.Builder().url(url).get().build()
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<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 connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
ImageFetcherManager.fetch(
url,
signal,
onSuccess = { buffer -> cont.resume(buffer) },
onFailure = { e -> cont.resumeWithException(e) }
)
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val request = Request.Builder().url(url).get().build()
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<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.Intent
import android.graphics.Bitmap
import android.util.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.*
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.*
import androidx.glance.action.clickable
import androidx.glance.layout.*
@@ -18,30 +18,28 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import app.alextran.immich.R
import app.alextran.immich.images.decodeBitmap
import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<MutablePreferences>()
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val assetId = state[kAssetId]
val subtitle = state[kSubtitleText]
val deeplinkURL = state[kDeeplinkURL]?.toUri()
val widgetState = state[kWidgetState]
val imageUUID = prefs[kImageUUID]
val subtitle = prefs[kSubtitleText]
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
val widgetState = prefs[kWidgetState]
var bitmap: Bitmap? = null
if (imageUUID != null) {
// fetch a random photo from server
val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
val bitmap = if (!assetId.isNullOrEmpty() && ImmichAPI.isLoggedIn(context)) {
try {
ImmichAPI.fetchImage(Asset(assetId, AssetType.IMAGE)).decodeBitmap(Size(500, 500))
} catch (e: Exception) {
null
}
} else null
provideContent {
// WIDGET CONTENT
Box(
@@ -4,14 +4,11 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import es.antonborri.home_widget.HomeWidgetPlugin
class RandomReceiver : GlanceAppWidgetReceiver() {
abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
@@ -22,25 +19,25 @@ class RandomReceiver : GlanceAppWidgetReceiver() {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
val provider = ComponentName(context, RandomReceiver::class.java)
val provider = ComponentName(context, this::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
ImageDownloadWorker.singleShot(context, widgetID, widgetType)
}
}
// make sure the periodic jobs are running
glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
}
super.onReceive(context, intent)
@@ -48,10 +45,12 @@ class RandomReceiver : GlanceAppWidgetReceiver() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
class MemoryReceiver : WidgetReceiver(WidgetType.MEMORIES)
class RandomReceiver : WidgetReceiver(WidgetType.RANDOM)
@@ -71,22 +71,18 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId,
LaunchedEffect(Unit) {
// get albums from server
val serverCfg = ImmichAPI.getServerConfig(context)
if (serverCfg == null) {
if (!ImmichAPI.isLoggedIn(context)) {
state = WidgetConfigState.LOG_IN
return@LaunchedEffect
}
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem>
try {
albumItems = api.fetchAlbums().map {
albumItems = ImmichAPI.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}
@@ -1,6 +1,5 @@
package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.*
// MARK: Immich Entities
@@ -50,19 +49,13 @@ enum class WidgetConfigState {
}
data class WidgetEntry (
val image: Bitmap,
val assetId: String,
val subtitle: String?,
val deeplink: String?
)
data class ServerConfig(
val serverEndpoint: String,
val sessionKey: String,
val customHeaders: Map<String, String>
)
// MARK: Widget State Keys
val kImageUUID = stringPreferencesKey("uuid")
val kAssetId = stringPreferencesKey("assetId")
val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state")
@@ -75,10 +68,6 @@ const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}"
}