1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-14 07:04:24 +02:00

wip widgets

This commit is contained in:
bwees
2025-06-20 08:52:05 -05:00
parent fe9ef348b9
commit c0a2cd39e0
10 changed files with 310 additions and 34 deletions

View File

@ -97,6 +97,8 @@ dependencies {
def guava_version = '33.3.1-android' def guava_version = '33.3.1-android'
def glide_version = '4.16.0' def glide_version = '4.16.0'
def serialization_version = '1.8.1' 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.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_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' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
//Glance Widget //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 // This is uncommented in F-Droid build script

View File

@ -145,7 +145,18 @@
<!-- Widgets --> <!-- Widgets -->
<receiver <receiver
android:name="app.alextran.immich.widget.RandomWidget" android:name=".widget.RandomReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget" />
</receiver>
<receiver
android:name=".widget.MemoryReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@ -167,4 +178,4 @@
<data android:scheme="geo" /> <data android:scheme="geo" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -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<ImageDownloadWorker>().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<ImageWorker>().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"
}
}
}

View File

@ -0,0 +1,8 @@
package app.alextran.immich.widget
import HomeWidgetGlanceWidgetReceiver
class MemoryReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() {
override val glanceAppWidget = RandomWidget()
}

View File

@ -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()
// )
}
}

View File

@ -3,35 +3,27 @@ package app.alextran.immich.widget
import HomeWidgetGlanceState import HomeWidgetGlanceState
import HomeWidgetGlanceStateDefinition import HomeWidgetGlanceStateDefinition
import android.content.Context 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.appwidget.*
import androidx.glance.* import androidx.glance.*
import androidx.glance.layout.* import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.*
import androidx.glance.text.*
class RandomWidget : GlanceAppWidget() { class RandomWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<*> override val stateDefinition: GlanceStateDefinition<HomeWidgetGlanceState>
get() = HomeWidgetGlanceStateDefinition() get() = HomeWidgetGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent { val bitmap = downloadBitmap("https://picsum.photos/600")
GlanceContent(context, currentState())
}
}
@Composable // fetch a random photo from server
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) { provideContent {
val prefs = currentState.preferences val prefs = currentState<HomeWidgetGlanceState>().preferences
val counter = prefs.getInt("counter", 0)
Box(modifier = GlanceModifier.background(Color.White).padding(16.dp)) { val serverURL = prefs.getString("widget_auth_token", "")
Column() { val sessionKey = prefs.getString("widget_auth_token", "")
Text(
counter.toString()
) PhotoWidget(imageURI = null, error = null, subtitle = id.hashCode().toString())
}
} }
} }
} }

View File

@ -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
)

View File

@ -28,7 +28,8 @@ const String appShareGroupId = "group.app.immich.share";
// add widget identifiers here for new widgets // add widget identifiers here for new widgets
// these are used to force a widget refresh // these are used to force a widget refresh
const List<String> kWidgetNames = [ // (iOSName, androidFQDN)
'com.immich.widget.random', const List<(String, String)> kWidgetNames = [
'com.immich.widget.memory', ('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'),
('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'),
]; ];

View File

@ -10,8 +10,11 @@ class WidgetRepository {
await HomeWidget.saveWidgetData<String>(key, value); await HomeWidget.saveWidgetData<String>(key, value);
} }
Future<void> refresh(String name) async { Future<void> refresh(String iosName, String androidName) async {
await HomeWidget.updateWidget(name: name, iOSName: name); await HomeWidget.updateWidget(
iOSName: iosName,
qualifiedAndroidName: androidName,
);
} }
Future<void> setAppGroupId(String appGroupId) async { Future<void> setAppGroupId(String appGroupId) async {

View File

@ -1,4 +1,3 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/repositories/widget.repository.dart'; import 'package:immich_mobile/repositories/widget.repository.dart';
@ -33,10 +32,8 @@ class WidgetService {
} }
Future<void> refreshWidgets() async { Future<void> refreshWidgets() async {
if (Platform.isAndroid) return; for (final (iOSName, androidName) in kWidgetNames) {
await _repository.refresh(iOSName, androidName);
for (final name in kWidgetNames) {
await _repository.refresh(name);
} }
} }
} }