mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
use shared session for widgets
This commit is contained in:
@@ -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
|
||||
}
|
||||
+21
-76
@@ -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(
|
||||
|
||||
+12
-13
@@ -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)
|
||||
+2
-6
@@ -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}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user