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);
}
}
}