latest changes

This commit is contained in:
bwees 2025-07-01 11:23:43 -05:00
parent a482a5a535
commit d8f3627beb
No known key found for this signature in database
9 changed files with 168 additions and 103 deletions

View File

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

View File

@ -0,0 +1,33 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}

View File

@ -16,23 +16,31 @@ package app.alextran.immich.widget
* limitations under the License.
*/
import HomeWidgetGlanceState
import android.appwidget.AppWidgetManager
import android.content.ComponentName
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.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.FileProvider.getUriForFile
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.glance.state.GlanceStateDefinition
import androidx.work.*
import com.google.gson.Gson
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class ImageDownloadWorker(
@ -44,53 +52,74 @@ class ImageDownloadWorker(
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
fun enqueue(context: Context, glanceId: GlanceId, config: WidgetConfig) {
fun enqueue(context: Context, appWidgetId: Int, config: WidgetConfig) {
val manager = WorkManager.getInstance(context)
val requestBuilder = OneTimeWorkRequestBuilder<ImageDownloadWorker>().apply {
addTag(glanceId.toString())
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
setInputData(
Data.Builder()
.putString("config", Gson().toJson(config))
.putInt("glanceId", glanceId.hashCode())
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
20, TimeUnit.MINUTES
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
}
.setInputData(
Data.Builder()
.putString("config", Gson().toJson(config))
.putInt("widgetId", appWidgetId)
.build()
)
.addTag(appWidgetId.toString())
.build()
manager.enqueueUniqueWork(
uniqueWorkName + glanceId.hashCode(),
ExistingWorkPolicy.KEEP,
requestBuilder.build()
manager.enqueueUniquePeriodicWork(
"$uniqueWorkName-$appWidgetId",
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
/**
* Cancel any ongoing worker
*/
fun cancel(context: Context, glanceId: GlanceId) {
WorkManager.getInstance(context).cancelAllWorkByTag(glanceId.toString())
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(glanceId)
WorkManager.getInstance(context).cancelAllWorkByTag(appWidgetId.toString())
}
}
private fun getServerConfig(): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
if (serverURL == "" || sessionKey == "") {
return null
}
return ServerConfig(
serverURL,
sessionKey
)
}
override suspend fun doWork(): Result {
return try {
val configString = inputData.getString("config")
val config = Gson().fromJson(configString, WidgetConfig::class.java)
val glanceId = inputData.getInt("glanceId", -1)
val widgetId = inputData.getInt("widgetId", -1)
if (glanceId == -1) {
Result.failure()
val serverConfig = getServerConfig() ?: return Result.success()
val newBitmap = when (config.widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig)
}
fetchImage(config, glanceId)
updateWidget(config, glanceId)
saveImage(newBitmap, widgetId)
updateWidget(config, widgetId)
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()
@ -98,29 +127,26 @@ class ImageDownloadWorker(
}
}
private suspend fun updateWidget(config: WidgetConfig, glanceId: Int) {
private suspend fun updateWidget(config: WidgetConfig, widgetID: Int) {
val manager = GlanceAppWidgetManager(context)
val glanceIds = manager.getGlanceIds(config.widgetType.widgetClass)
val glanceId = manager.getGlanceIdBy(widgetID)
for (id in glanceIds) {
if (id.hashCode() == glanceId) {
config.widgetType.widgetClass.getDeclaredConstructor().newInstance().updateAll(context)
break
}
}
RandomWidget().update(context, glanceId)
Log.w("WIDGET_BG", "SENT THE UPDATE COMMAND: $widgetID")
}
private suspend fun fetchImage(config: WidgetConfig, glanceId: Int) {
val api = ImmichAPI(config.credentials)
private suspend fun fetchRandom(serverConfig: ServerConfig): Bitmap {
val api = ImmichAPI(serverConfig)
val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1))
val image = api.fetchImage(random[0])
saveImage(image, glanceId)
return image
}
private suspend fun saveImage(bitmap: Bitmap, glanceId: Int) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "widget_image_$glanceId.jpg")
private suspend fun saveImage(bitmap: Bitmap, widgetId: Int) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "widget_image_$widgetId.jpg")
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}

View File

@ -22,8 +22,7 @@ class ImmichAPI(cfg: ServerConfig) {
private val serverConfig = cfg
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val baseUrl = URL(serverConfig.serverEndpoint)
val urlString = StringBuilder("${baseUrl.protocol}://${baseUrl.host}:${baseUrl.port}$endpoint?sessionKey=${URLEncoder.encode(serverConfig.sessionKey, "UTF-8")}")
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")

View File

@ -1,14 +1,8 @@
package app.alextran.immich.widget
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.appwidget.GlanceAppWidget
sealed class WidgetError : Exception() {
data object NoLogin : WidgetError()
data object FetchFailed : WidgetError()
data object Unknown : WidgetError()
data object AlbumNotFound : WidgetError()
}
enum class AssetType {
IMAGE, VIDEO, AUDIO, OTHER
}
@ -50,7 +44,12 @@ enum class WidgetType {
data class WidgetConfig(
val widgetType: WidgetType,
val params: Map<String, String>,
val credentials: ServerConfig
)
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
// this value is in HomeWidgetPlugin.PREFERENCES but is marked internal
const val WIDGET_PREFERENCES = "HomeWidgetPreferences"
val loggedInKey = stringPreferencesKey("isLoggedIn")
val lastUpdatedKey = stringPreferencesKey("lastUpdated")

View File

@ -10,6 +10,7 @@ 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
@ -23,15 +24,17 @@ fun PhotoWidget(image: Bitmap?, error: String?, subtitle: String?) {
if (image != null) {
Image(
provider = ImageProvider(image),
contentDescription = "Widget Image"
contentDescription = "Widget Image",
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
} else {
Image(
provider = ImageProvider(R.drawable.splash),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
Text(subtitle ?: "NOPERS")
Text(error ?: "NOPERS")
}
}
}

View File

@ -1,8 +1,36 @@
package app.alextran.immich.widget
import HomeWidgetGlanceWidgetReceiver
import android.appwidget.AppWidgetManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
class RandomReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() {
override val glanceAppWidget = RandomWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
val cfg = WidgetConfig(WidgetType.RANDOM, HashMap())
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueue(context, widgetID, cfg)
Log.w("WIDGET_UPDATE", "WORKER ENQUEUE CALLED: $widgetID")
}
}
}

View File

@ -3,53 +3,38 @@ package app.alextran.immich.widget
import HomeWidgetGlanceState
import HomeWidgetGlanceStateDefinition
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.appwidget.*
import androidx.glance.*
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.GlanceStateDefinition
import java.io.File
class RandomWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<HomeWidgetGlanceState>
get() = HomeWidgetGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) {
Log.w("WIDGET_UPDATE", "PROVIDED GLANCE")
// fetch a random photo from server
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
val file = File(context.cacheDir, "widget_image_$appWidgetId.jpg")
var bitmap: Bitmap? = null
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
provideContent {
val prefs = currentState<HomeWidgetGlanceState>().preferences
val serverURL = prefs.getString("widget_auth_token", "")
val sessionKey = prefs.getString("widget_auth_token", "")
PhotoWidget(image = null, error = null, subtitle = id.hashCode().toString())
PhotoWidget(image = bitmap, error = null, subtitle = "hello")
}
}
override suspend fun onDelete(context: Context, glanceId: GlanceId) {
super.onDelete(context, glanceId)
ImageDownloadWorker.cancel(context, glanceId)
}
}
class RefreshAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
// Clear the state to show loading screen
updateAppWidgetState(context, glanceId) { prefs ->
prefs.clear()
}
ImageGlanceWidget().update(context, glanceId)
parameters.
// Enqueue a job for each size the widget can be shown in the current state
// (i.e landscape/portrait)
GlanceAppWidgetManager(context).getAppWidgetSizes(glanceId).forEach { size ->
ImageWorker.enqueue(context, size, glanceId, force = true)
}
}
}

View File

@ -1,8 +0,0 @@
package app.alextran.immich.widget
data class WidgetEntry(
val imageURI: String? = null,
val error: WidgetError? = null,
val subtitle: String? = null
)