You've already forked immich
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:
@ -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
|
||||
|
@ -145,7 +145,18 @@
|
||||
|
||||
<!-- Widgets -->
|
||||
<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">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package app.alextran.immich.widget
|
||||
|
||||
import HomeWidgetGlanceWidgetReceiver
|
||||
|
||||
class MemoryReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() {
|
||||
override val glanceAppWidget = RandomWidget()
|
||||
}
|
||||
|
@ -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()
|
||||
// )
|
||||
}
|
||||
}
|
@ -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<HomeWidgetGlanceState>
|
||||
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<HomeWidgetGlanceState>().preferences
|
||||
|
||||
val serverURL = prefs.getString("widget_auth_token", "")
|
||||
val sessionKey = prefs.getString("widget_auth_token", "")
|
||||
|
||||
|
||||
PhotoWidget(imageURI = null, error = null, subtitle = id.hashCode().toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
)
|
@ -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<String> 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'),
|
||||
];
|
||||
|
@ -10,8 +10,11 @@ class WidgetRepository {
|
||||
await HomeWidget.saveWidgetData<String>(key, value);
|
||||
}
|
||||
|
||||
Future<void> refresh(String name) async {
|
||||
await HomeWidget.updateWidget(name: name, iOSName: name);
|
||||
Future<void> refresh(String iosName, String androidName) async {
|
||||
await HomeWidget.updateWidget(
|
||||
iOSName: iosName,
|
||||
qualifiedAndroidName: androidName,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAppGroupId(String appGroupId) async {
|
||||
|
@ -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<void> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user