mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
wip widgets
This commit is contained in:
parent
30bd74ea60
commit
cba6fb0eb3
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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 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())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
// 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'),
|
||||||
];
|
];
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user