From a482a5a535563cc77f7d3ac73f08a072d3f2e86e Mon Sep 17 00:00:00 2001 From: bwees Date: Thu, 26 Jun 2025 18:49:59 -0500 Subject: [PATCH] more wip changes --- mobile/android/app/build.gradle | 7 +- .../immich/widget/ImageDownloadWorker.kt | 145 +++++------------- .../app/alextran/immich/widget/ImmichAPI.kt | 85 ++++++++++ .../app/alextran/immich/widget/Model.kt | 56 +++++++ .../app/alextran/immich/widget/PhotoWidget.kt | 47 ++---- .../alextran/immich/widget/RandomWidget.kt | 32 +++- .../app/alextran/immich/widget/WidgetEntry.kt | 3 - 7 files changed, 229 insertions(+), 146 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 8a278eacd3..3b52984b9a 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -99,6 +99,7 @@ dependencies { def serialization_version = '1.8.1' def compose_version = '1.1.1' def coil_version = '3.2.0' + def gson_version = '2.10.1' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" @@ -113,12 +114,10 @@ dependencies { //Glance Widget implementation "androidx.glance:glance-appwidget:$compose_version" + implementation("com.google.code.gson:gson:$gson_version") implementation("io.coil-kt.coil3:coil-compose:$coil_version") - implementation("io.coil-kt.coil3:coil-network-okhttp:$coil_version") { - // Exclude OkHttp to avoid conflicts with the one used by Flutter - exclude group: 'com.squareup.okhttp3', module: 'okhttp' - } + implementation("io.coil-kt.coil3:coil-network-okhttp:$coil_version") } // This is uncommented in F-Droid build script diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt index 2c0e3a688b..615f42523a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt @@ -21,29 +21,19 @@ import android.content.Intent import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.pm.PackageManager +import android.graphics.Bitmap import android.util.Log -import androidx.compose.ui.unit.DpSize import androidx.core.content.FileProvider.getUriForFile -import androidx.glance.GlanceId +import androidx.glance.* import androidx.glance.appwidget.GlanceAppWidgetManager -import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.updateAll -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import coil.annotation.ExperimentalCoilApi -import coil.imageLoader -import coil.memory.MemoryCache -import coil.request.ErrorResult -import coil.request.ImageRequest -import com.example.android.appwidget.glance.toPx -import java.time.Duration -import kotlin.math.roundToInt - +import androidx.work.* +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import kotlin.random.Random class ImageDownloadWorker( private val context: Context, @@ -54,39 +44,23 @@ class ImageDownloadWorker( private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName - fun enqueue(context: Context, size: DpSize, glanceId: GlanceId, force: Boolean = false) { + fun enqueue(context: Context, glanceId: GlanceId, config: WidgetConfig) { val manager = WorkManager.getInstance(context) val requestBuilder = OneTimeWorkRequestBuilder().apply { addTag(glanceId.toString()) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) setInputData( Data.Builder() - .putFloat("width", size.width.value.toPx) - .putFloat("height", size.height.value.toPx) - .putBoolean("force", force) + .putString("config", Gson().toJson(config)) + .putInt("glanceId", glanceId.hashCode()) .build() ) } - val workPolicy = if (force) { - ExistingWorkPolicy.REPLACE - } else { - ExistingWorkPolicy.KEEP - } manager.enqueueUniqueWork( - uniqueWorkName + size.width + size.height, - workPolicy, - requestBuilder.build() - ) - - // Temporary workaround to avoid WM provider to disable itself and trigger an - // app widget update - manager.enqueueUniqueWork( - "$uniqueWorkName-workaround", + uniqueWorkName + glanceId.hashCode(), ExistingWorkPolicy.KEEP, - OneTimeWorkRequestBuilder().apply { - setInitialDelay(Duration.ofDays(365)) - }.build() + requestBuilder.build() ) } @@ -100,11 +74,17 @@ class ImageDownloadWorker( override suspend fun doWork(): Result { return try { - val width = inputData.getFloat("width", 0f) - val height = inputData.getFloat("height", 0f) - val force = inputData.getBoolean("force", false) - val uri = getRandomImage(width, height, force) - updateImageWidget(width, height, uri) + val configString = inputData.getString("config") + val config = Gson().fromJson(configString, WidgetConfig::class.java) + val glanceId = inputData.getInt("glanceId", -1) + + if (glanceId == -1) { + Result.failure() + } + + fetchImage(config, glanceId) + updateWidget(config, glanceId) + Result.success() } catch (e: Exception) { Log.e(uniqueWorkName, "Error while loading image", e) @@ -118,72 +98,31 @@ class ImageDownloadWorker( } } - private suspend fun updateImageWidget(width: Float, height: Float, uri: String) { + private suspend fun updateWidget(config: WidgetConfig, glanceId: Int) { val manager = GlanceAppWidgetManager(context) - val glanceIds = manager.getGlanceIds(ImageGlanceWidget::class.java) - glanceIds.forEach { glanceId -> - updateAppWidgetState(context, glanceId) { prefs -> - prefs[ImageGlanceWidget.getImageKey(width, height)] = uri - prefs[ImageGlanceWidget.sourceKey] = "Picsum Photos" - prefs[ImageGlanceWidget.sourceUrlKey] = "https://picsum.photos/" + val glanceIds = manager.getGlanceIds(config.widgetType.widgetClass) + + for (id in glanceIds) { + if (id.hashCode() == glanceId) { + config.widgetType.widgetClass.getDeclaredConstructor().newInstance().updateAll(context) + break } } - ImageGlanceWidget().updateAll(context) } - /** - * Use Coil and Picsum Photos to randomly load images into the cache based on the provided - * size. This method returns the path of the cached image, which you can send to the widget. - */ - @OptIn(ExperimentalCoilApi::class) - private suspend fun getRandomImage(width: Float, height: Float, force: Boolean): String { - val url = "https://picsum.photos/${width.roundToInt()}/${height.roundToInt()}" - val request = ImageRequest.Builder(context) - .data(url) - .build() + private suspend fun fetchImage(config: WidgetConfig, glanceId: Int) { + val api = ImmichAPI(config.credentials) - // Request the image to be loaded and throw error if it failed - with(context.imageLoader) { - if (force) { - diskCache?.remove(url) - memoryCache?.remove(MemoryCache.Key(url)) - } - val result = execute(request) - if (result is ErrorResult) { - throw result.throwable - } - } + val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1)) + val image = api.fetchImage(random[0]) - // Get the path of the loaded image from DiskCache. - val path = context.imageLoader.diskCache?.get(url)?.use { snapshot -> - val imageFile = snapshot.data.toFile() + saveImage(image, glanceId) + } - // Use the FileProvider to create a content URI - val contentUri = getUriForFile( - context, - "com.example.android.appwidget.fileprovider", - imageFile - ) - - // Find the current launcher everytime to ensure it has read permissions - val resolveInfo = context.packageManager.resolveActivity( - Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) }, - PackageManager.MATCH_DEFAULT_ONLY - ) - val launcherName = resolveInfo?.activityInfo?.packageName - if (launcherName != null) { - context.grantUriPermission( - launcherName, - contentUri, - FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_PERSISTABLE_URI_PERMISSION - ) - } - - // return the path - contentUri.toString() - } - return requireNotNull(path) { - "Couldn't find cached file" + private suspend fun saveImage(bitmap: Bitmap, glanceId: Int) = withContext(Dispatchers.IO) { + val file = File(context.cacheDir, "widget_image_$glanceId.jpg") + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt new file mode 100644 index 0000000000..d22a8a8a30 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt @@ -0,0 +1,85 @@ +package app.alextran.immich.widget + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.text.SimpleDateFormat +import java.util.* + + +class ImmichAPI(cfg: ServerConfig) { + + private val gson = Gson() + private val serverConfig = cfg + + private fun buildRequestURL(endpoint: String, params: List> = emptyList()): URL { + val baseUrl = URL(serverConfig.serverEndpoint) + val urlString = StringBuilder("${baseUrl.protocol}://${baseUrl.host}:${baseUrl.port}$endpoint?sessionKey=${URLEncoder.encode(serverConfig.sessionKey, "UTF-8")}") + + for ((key, value) in params) { + urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}") + } + + return URL(urlString.toString()) + } + + suspend fun fetchSearchResults(filters: SearchFilters): List = withContext(Dispatchers.IO) { + val url = buildRequestURL("/search/random") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json") + doOutput = true + } + + connection.outputStream.use { + OutputStreamWriter(it).use { writer -> + writer.write(gson.toJson(filters)) + writer.flush() + } + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } + + suspend fun fetchMemory(date: Date): List = withContext(Dispatchers.IO) { + val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(date) + val url = buildRequestURL("/memories", listOf("for" to iso8601)) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } + + suspend fun fetchImage(asset: SearchResult): Bitmap = withContext(Dispatchers.IO) { + val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview")) + val connection = url.openConnection() + val data = connection.getInputStream().readBytes() + BitmapFactory.decodeByteArray(data, 0, data.size) + ?: throw Exception("Invalid image data") + } + + suspend fun fetchAlbums(): List = withContext(Dispatchers.IO) { + val url = buildRequestURL("/albums") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt new file mode 100644 index 0000000000..ad2ba2ec8a --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt @@ -0,0 +1,56 @@ +package app.alextran.immich.widget + +import androidx.glance.appwidget.GlanceAppWidget + +sealed class WidgetError : Exception() { + data object NoLogin : WidgetError() + data object FetchFailed : WidgetError() + data object Unknown : WidgetError() + data object AlbumNotFound : WidgetError() +} + +enum class AssetType { + IMAGE, VIDEO, AUDIO, OTHER +} + +data class SearchResult( + val id: String, + val type: AssetType +) + +data class SearchFilters( + var type: AssetType = AssetType.IMAGE, + val size: Int, + var albumIds: List = listOf() +) + +data class MemoryResult( + val id: String, + var assets: List, + val type: String, + val data: MemoryData +) { + data class MemoryData(val year: Int) +} + +data class Album( + val id: String, + val albumName: String +) + +enum class WidgetType { + RANDOM; + + val widgetClass: Class + get() = when (this) { + RANDOM -> RandomWidget::class.java + } +} + +data class WidgetConfig( + val widgetType: WidgetType, + val params: Map, + val credentials: ServerConfig +) + +data class ServerConfig(val serverEndpoint: String, val sessionKey: String) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt index ea197f2473..614ba7458f 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt @@ -1,8 +1,6 @@ package app.alextran.immich.widget import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.glance.GlanceModifier @@ -12,45 +10,28 @@ import androidx.glance.background import androidx.glance.layout.Box import androidx.glance.layout.ContentScale import androidx.glance.layout.fillMaxSize -import androidx.glance.text.Text import app.alextran.immich.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.InputStream -import java.net.HttpURLConnection -import java.net.URL - - -suspend fun downloadBitmap(urlString: String): Bitmap? = - withContext(Dispatchers.IO) { - try { - val url = URL(urlString) - val connection = url.openConnection() as HttpURLConnection - connection.doInput = true - connection.connect() - val input: InputStream = connection.inputStream - BitmapFactory.decodeStream(input) - } catch (e: Exception) { - e.printStackTrace() - null - } - } - @Composable -fun PhotoWidget(imageURI: Uri?, error: String?, subtitle: String?) { +fun PhotoWidget(image: Bitmap?, error: String?, subtitle: String?) { Box( modifier = GlanceModifier .fillMaxSize() .background(Color.White) // your color here ) { - Text(subtitle ?: "WTF is this") -// Image( -// provider = ImageProvider(R.drawable.splash), -// contentDescription = null, -// contentScale = ContentScale.Crop, -// modifier = GlanceModifier.fillMaxSize() -// ) + if (image != null) { + Image( + provider = ImageProvider(image), + contentDescription = "Widget Image" + ) + } else { + Image( + provider = ImageProvider(R.drawable.splash), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = GlanceModifier.fillMaxSize() + ) + } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt index 40889690ee..e3de9421d1 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt @@ -5,6 +5,9 @@ import HomeWidgetGlanceStateDefinition import android.content.Context import androidx.glance.appwidget.* import androidx.glance.* +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.GlanceStateDefinition @@ -13,17 +16,40 @@ class RandomWidget : GlanceAppWidget() { get() = HomeWidgetGlanceStateDefinition() override suspend fun provideGlance(context: Context, id: GlanceId) { - val bitmap = downloadBitmap("https://picsum.photos/600") - // fetch a random photo from server provideContent { + val prefs = currentState().preferences val serverURL = prefs.getString("widget_auth_token", "") val sessionKey = prefs.getString("widget_auth_token", "") - PhotoWidget(imageURI = null, error = null, subtitle = id.hashCode().toString()) + + PhotoWidget(image = null, error = null, subtitle = id.hashCode().toString()) + } + } + + override suspend fun onDelete(context: Context, glanceId: GlanceId) { + super.onDelete(context, glanceId) + ImageDownloadWorker.cancel(context, glanceId) + } +} + +class RefreshAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + // Clear the state to show loading screen + updateAppWidgetState(context, glanceId) { prefs -> + prefs.clear() + } + ImageGlanceWidget().update(context, glanceId) + + parameters. + + // Enqueue a job for each size the widget can be shown in the current state + // (i.e landscape/portrait) + GlanceAppWidgetManager(context).getAppWidgetSizes(glanceId).forEach { size -> + ImageWorker.enqueue(context, size, glanceId, force = true) } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt index 3b0e7ed02a..94b8704650 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt @@ -1,8 +1,5 @@ package app.alextran.immich.widget -enum class WidgetError { - NO_LOGIN, FETCH_FAILED, UNKNOWN, ALBUM_NOT_FOUND -} data class WidgetEntry( val imageURI: String? = null,