From cba6fb0eb31425e67d255556946ef183828cf768 Mon Sep 17 00:00:00 2001 From: bwees Date: Fri, 20 Jun 2025 08:52:05 -0500 Subject: [PATCH] wip widgets --- mobile/android/app/build.gradle | 10 +- .../android/app/src/main/AndroidManifest.xml | 15 +- .../immich/widget/ImageDownloadWorker.kt | 189 ++++++++++++++++++ .../alextran/immich/widget/MemoryReceiver.kt | 8 + .../app/alextran/immich/widget/PhotoWidget.kt | 56 ++++++ .../alextran/immich/widget/RandomWidget.kt | 34 ++-- .../app/alextran/immich/widget/WidgetEntry.kt | 11 + mobile/lib/constants/constants.dart | 7 +- .../lib/repositories/widget.repository.dart | 7 +- mobile/lib/services/widget.service.dart | 7 +- 10 files changed, 310 insertions(+), 34 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index eb179ae2d4..8a278eacd3 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -97,6 +97,8 @@ dependencies { def guava_version = '33.3.1-android' def glide_version = '4.16.0' def serialization_version = '1.8.1' + def compose_version = '1.1.1' + def coil_version = '3.2.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" @@ -110,7 +112,13 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' //Glance Widget - implementation "androidx.glance:glance-appwidget:1.1.1" + implementation "androidx.glance:glance-appwidget:$compose_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' + } } // This is uncommented in F-Droid build script diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index b9ccf8201a..e4da00c13d 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -145,7 +145,18 @@ + + + + + + + @@ -167,4 +178,4 @@ - \ No newline at end of file + 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 new file mode 100644 index 0000000000..2c0e3a688b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt @@ -0,0 +1,189 @@ +package app.alextran.immich.widget + +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.content.Context +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.util.Log +import androidx.compose.ui.unit.DpSize +import androidx.core.content.FileProvider.getUriForFile +import androidx.glance.GlanceId +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 + + +class ImageDownloadWorker( + private val context: Context, + workerParameters: WorkerParameters +) : CoroutineWorker(context, workerParameters) { + + companion object { + + private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName + + fun enqueue(context: Context, size: DpSize, glanceId: GlanceId, force: Boolean = false) { + 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) + .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", + ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder().apply { + setInitialDelay(Duration.ofDays(365)) + }.build() + ) + } + + /** + * Cancel any ongoing worker + */ + fun cancel(context: Context, glanceId: GlanceId) { + WorkManager.getInstance(context).cancelAllWorkByTag(glanceId.toString()) + } + } + + 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) + Result.success() + } catch (e: Exception) { + Log.e(uniqueWorkName, "Error while loading image", e) + if (runAttemptCount < 10) { + // Exponential backoff strategy will avoid the request to repeat + // too fast in case of failures. + Result.retry() + } else { + Result.failure() + } + } + } + + private suspend fun updateImageWidget(width: Float, height: Float, uri: String) { + 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/" + } + } + 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() + + // 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 + } + } + + // Get the path of the loaded image from DiskCache. + val path = context.imageLoader.diskCache?.get(url)?.use { snapshot -> + val imageFile = snapshot.data.toFile() + + // 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" + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt new file mode 100644 index 0000000000..b92bfbcf82 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt @@ -0,0 +1,8 @@ +package app.alextran.immich.widget + +import HomeWidgetGlanceWidgetReceiver + +class MemoryReceiver : HomeWidgetGlanceWidgetReceiver() { + override val glanceAppWidget = RandomWidget() +} + 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 new file mode 100644 index 0000000000..ea197f2473 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt @@ -0,0 +1,56 @@ +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 +import androidx.glance.Image +import androidx.glance.ImageProvider +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?) { + + 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() +// ) + } +} 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 9f6db5c835..40889690ee 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 @@ -3,35 +3,27 @@ package app.alextran.immich.widget import HomeWidgetGlanceState import HomeWidgetGlanceStateDefinition import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import androidx.glance.appwidget.* import androidx.glance.* -import androidx.glance.layout.* -import androidx.glance.state.* -import androidx.glance.text.* +import androidx.glance.state.GlanceStateDefinition + class RandomWidget : GlanceAppWidget() { - override val stateDefinition: GlanceStateDefinition<*> + override val stateDefinition: GlanceStateDefinition get() = HomeWidgetGlanceStateDefinition() override suspend fun provideGlance(context: Context, id: GlanceId) { - provideContent { - GlanceContent(context, currentState()) - } - } + val bitmap = downloadBitmap("https://picsum.photos/600") - @Composable - private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) { - val prefs = currentState.preferences - val counter = prefs.getInt("counter", 0) - Box(modifier = GlanceModifier.background(Color.White).padding(16.dp)) { - Column() { - Text( - counter.toString() - ) - } + // 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()) } } } 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 new file mode 100644 index 0000000000..3b0e7ed02a --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/WidgetEntry.kt @@ -0,0 +1,11 @@ +package app.alextran.immich.widget + +enum class WidgetError { + NO_LOGIN, FETCH_FAILED, UNKNOWN, ALBUM_NOT_FOUND +} + +data class WidgetEntry( + val imageURI: String? = null, + val error: WidgetError? = null, + val subtitle: String? = null +) diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 6d98152efc..c37498ea3e 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -28,7 +28,8 @@ const String appShareGroupId = "group.app.immich.share"; // add widget identifiers here for new widgets // these are used to force a widget refresh -const List kWidgetNames = [ - 'com.immich.widget.random', - 'com.immich.widget.memory', +// (iOSName, androidFQDN) +const List<(String, String)> kWidgetNames = [ + ('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'), + ('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'), ]; diff --git a/mobile/lib/repositories/widget.repository.dart b/mobile/lib/repositories/widget.repository.dart index be314a281e..09532f4b78 100644 --- a/mobile/lib/repositories/widget.repository.dart +++ b/mobile/lib/repositories/widget.repository.dart @@ -10,8 +10,11 @@ class WidgetRepository { await HomeWidget.saveWidgetData(key, value); } - Future refresh(String name) async { - await HomeWidget.updateWidget(name: name, iOSName: name); + Future refresh(String iosName, String androidName) async { + await HomeWidget.updateWidget( + iOSName: iosName, + qualifiedAndroidName: androidName, + ); } Future setAppGroupId(String appGroupId) async { diff --git a/mobile/lib/services/widget.service.dart b/mobile/lib/services/widget.service.dart index 02ddedbe89..fb2022784f 100644 --- a/mobile/lib/services/widget.service.dart +++ b/mobile/lib/services/widget.service.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/repositories/widget.repository.dart'; @@ -33,10 +32,8 @@ class WidgetService { } Future refreshWidgets() async { - if (Platform.isAndroid) return; - - for (final name in kWidgetNames) { - await _repository.refresh(name); + for (final (iOSName, androidName) in kWidgetNames) { + await _repository.refresh(iOSName, androidName); } } }