add memory widget and cleanup of codebase

This commit is contained in:
bwees 2025-07-02 15:58:09 -05:00
parent 6b0be03a4b
commit ada0f984b8
No known key found for this signature in database
9 changed files with 125 additions and 93 deletions

View File

@ -155,16 +155,16 @@
android:resource="@xml/random_widget" /> android:resource="@xml/random_widget" />
</receiver> </receiver>
<!-- <receiver--> <receiver
<!-- android:name=".widget.MemoryReceiver"--> 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" />
<!-- </intent-filter>--> </intent-filter>
<!-- <meta-data--> <meta-data
<!-- android:name="android.appwidget.provider"--> android:name="android.appwidget.provider"
<!-- android:resource="@xml/widget" />--> android:resource="@xml/memory_widget" />
<!-- </receiver>--> </receiver>
<activity android:name=".widget.configure.RandomConfigure" <activity android:name=".widget.configure.RandomConfigure"
android:exported="true" android:exported="true"

View File

@ -16,6 +16,7 @@ import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import java.time.LocalDate
class ImageDownloadWorker( class ImageDownloadWorker(
private val context: Context, private val context: Context,
@ -84,8 +85,8 @@ class ImageDownloadWorker(
val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "") val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "")
val widgetId = inputData.getInt(kWorkerWidgetID, -1) val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId) val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = currentState[kImageUUID] val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context) val serverConfig = ImmichAPI.getServerConfig(context)
@ -93,15 +94,16 @@ class ImageDownloadWorker(
if (serverConfig == null) { if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) { if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID) deleteImage(currentImgUUID)
updateWidget(widgetType, glanceId, "", WidgetState.LOG_IN) updateWidget(glanceId, "", "", WidgetState.LOG_IN)
} }
return Result.success() return Result.success()
} }
// fetch new image // fetch new image
val newBitmap = when (widgetType) { val (newBitmap, subtitle) = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig, currentState) WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
} }
// clear current image if it exists // clear current image if it exists
@ -110,10 +112,11 @@ class ImageDownloadWorker(
} }
// save a new image // save a new image
val imgUUID = saveImage(newBitmap) val imgUUID = UUID.randomUUID().toString()
saveImage(newBitmap, imgUUID)
// trigger the update routine with new image uuid // trigger the update routine with new image uuid
updateWidget(widgetType, glanceId, imgUUID) updateWidget(glanceId, imgUUID, subtitle)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
@ -126,32 +129,65 @@ class ImageDownloadWorker(
} }
} }
private suspend fun updateWidget(type: WidgetType, glanceId: GlanceId, imageUUID: String, widgetState: WidgetState = WidgetState.SUCCESS) { private suspend fun updateWidget(
glanceId: GlanceId,
imageUUID: String,
subtitle: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis() prefs[kNow] = System.currentTimeMillis()
prefs[kImageUUID] = imageUUID prefs[kImageUUID] = imageUUID
prefs[kWidgetState] = widgetState.toString() prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
} }
when (type) { PhotoWidget().update(context,glanceId)
WidgetType.RANDOM -> RandomWidget().update(context,glanceId)
}
} }
private suspend fun fetchRandom(serverConfig: ServerConfig, widgetData: Preferences): Bitmap { private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): Pair<Bitmap, String?> {
val api = ImmichAPI(serverConfig) val api = ImmichAPI(serverConfig)
val filters = SearchFilters(AssetType.IMAGE, size=1) val filters = SearchFilters(AssetType.IMAGE, size=1)
val albumId = widgetData[kSelectedAlbum] val albumId = widgetConfig[kSelectedAlbum]
val albumName = widgetConfig[kSelectedAlbumName]
if (albumId != null) { if (albumId != null) {
filters.albumIds = listOf(albumId) filters.albumIds = listOf(albumId)
} }
val random = api.fetchSearchResults(filters) val random = api.fetchSearchResults(filters).first()
val image = api.fetchImage(random[0]) val image = api.fetchImage(random)
return image return Pair(image, albumName)
}
private suspend fun fetchMemory(
serverConfig: ServerConfig
): Pair<Bitmap, String?> {
val api = ImmichAPI(serverConfig)
val today = LocalDate.now()
val memories = api.fetchMemory(today)
val asset: SearchResult
var subtitle: String? = null
if (memories.isNotEmpty()) {
// pick a random asset from a random memory
val memory = memories.random()
asset = memory.assets.random()
subtitle = "${today.year-memory.data.year} years ago"
} else {
val filters = SearchFilters(AssetType.IMAGE, size=1)
asset = api.fetchSearchResults(filters).first()
}
val image = api.fetchImage(asset)
return Pair(image, subtitle)
} }
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) { private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
@ -159,13 +195,10 @@ class ImageDownloadWorker(
file.delete() file.delete()
} }
private suspend fun saveImage(bitmap: Bitmap): String = withContext(Dispatchers.IO) { private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val uuid = UUID.randomUUID().toString()
val file = File(context.cacheDir, imageFilename(uuid)) val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out -> FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
} }
uuid
} }
} }

View File

@ -14,6 +14,8 @@ import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
@ -71,8 +73,8 @@ class ImmichAPI(cfg: ServerConfig) {
gson.fromJson(response, type) gson.fromJson(response, type)
} }
suspend fun fetchMemory(date: Date): List<MemoryResult> = withContext(Dispatchers.IO) { suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(date) val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601)) val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply { val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"

View File

@ -1,8 +1,22 @@
package app.alextran.immich.widget package app.alextran.immich.widget
import HomeWidgetGlanceWidgetReceiver import HomeWidgetGlanceWidgetReceiver
import android.appwidget.AppWidgetManager
import android.content.Context
class MemoryReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() { class MemoryReceiver : HomeWidgetGlanceWidgetReceiver<PhotoWidget>() {
override val glanceAppWidget = RandomWidget() override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
}
} }

View File

@ -40,12 +40,7 @@ data class Album(
// MARK: Widget Specific // MARK: Widget Specific
enum class WidgetType { enum class WidgetType {
RANDOM; RANDOM, MEMORIES;
val widgetClass: Class<out GlanceAppWidget>
get() = when (this) {
RANDOM -> RandomWidget::class.java
}
} }
enum class WidgetState { enum class WidgetState {
@ -60,6 +55,7 @@ val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now") val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state") val kWidgetState = stringPreferencesKey("state")
val kSelectedAlbum = stringPreferencesKey("albumID") val kSelectedAlbum = stringPreferencesKey("albumID")
val kSelectedAlbumName = stringPreferencesKey("albumName")
val kShowAlbumName = booleanPreferencesKey("showAlbumName") val kShowAlbumName = booleanPreferencesKey("showAlbumName")
const val kWorkerWidgetType = "widgetType" const val kWorkerWidgetType = "widgetType"

View File

@ -1,41 +0,0 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
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
@Composable
fun PhotoView(image: Bitmap?, subtitle: String?, loggedIn: Boolean) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color.White) // your color here
) {
if (image != null) {
Image(
provider = ImageProvider(image),
contentDescription = "Widget Image",
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
if (subtitle != null)
Text(subtitle)
} else {
Image(
provider = ImageProvider(R.drawable.splash),
contentDescription = null,
)
}
}
}

View File

@ -2,42 +2,63 @@ package app.alextran.immich.widget
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.appwidget.* import androidx.glance.appwidget.*
import androidx.glance.* import androidx.glance.*
import androidx.glance.layout.Box
import androidx.glance.layout.ContentScale
import androidx.glance.layout.fillMaxSize
import androidx.glance.state.GlanceStateDefinition import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.Text
import app.alextran.immich.R
import java.io.File import java.io.File
class RandomWidget : GlanceAppWidget() { class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent { provideContent {
val prefs = currentState<MutablePreferences>() val prefs = currentState<MutablePreferences>()
val imageUUID = prefs[kImageUUID]
val imageUUID = prefs[kImageUUID]
val subtitle: String? = prefs[kSubtitleText] val subtitle: String? = prefs[kSubtitleText]
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
var loggedIn = true
if (imageUUID != null) { if (imageUUID != null) {
// fetch a random photo from server // fetch a random photo from server
val file = File(context.cacheDir, imageFilename(id)) val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) { if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500) bitmap = loadScaledBitmap(file, 500, 500)
} }
} else if (ImmichAPI.getServerConfig(context) == null) {
loggedIn = false
} }
PhotoView(image = bitmap, subtitle = subtitle, loggedIn = loggedIn) // WIDGET CONTENT
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color.White)
) {
if (bitmap != null) {
Image(
provider = ImageProvider(bitmap),
contentDescription = "Widget Image",
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
if (subtitle != null)
Text(subtitle)
} else {
Image(
provider = ImageProvider(R.drawable.splash),
contentDescription = null,
)
}
}
} }
} }
override suspend fun onDelete(context: Context, glanceId: GlanceId) { override suspend fun onDelete(context: Context, glanceId: GlanceId) {
super.onDelete(context, glanceId) super.onDelete(context, glanceId)

View File

@ -5,8 +5,8 @@ import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
class RandomReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() { class RandomReceiver : HomeWidgetGlanceWidgetReceiver<PhotoWidget>() {
override val glanceAppWidget = RandomWidget() override val glanceAppWidget = PhotoWidget()
override fun onUpdate( override fun onUpdate(
context: Context, context: Context,

View File

@ -0,0 +1,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1200000"
/>