more wip changes

This commit is contained in:
bwees 2025-06-26 18:49:59 -05:00
parent cba6fb0eb3
commit a482a5a535
No known key found for this signature in database
7 changed files with 229 additions and 146 deletions

View File

@ -99,6 +99,7 @@ dependencies {
def serialization_version = '1.8.1' def serialization_version = '1.8.1'
def compose_version = '1.1.1' def compose_version = '1.1.1'
def coil_version = '3.2.0' def coil_version = '3.2.0'
def gson_version = '2.10.1'
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"
@ -113,12 +114,10 @@ dependencies {
//Glance Widget //Glance Widget
implementation "androidx.glance:glance-appwidget:$compose_version" implementation "androidx.glance:glance-appwidget:$compose_version"
implementation("com.google.code.gson:gson:$gson_version")
implementation("io.coil-kt.coil3:coil-compose:$coil_version") implementation("io.coil-kt.coil3:coil-compose:$coil_version")
implementation("io.coil-kt.coil3:coil-network-okhttp:$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

@ -21,29 +21,19 @@ import android.content.Intent
import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.util.Log import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.core.content.FileProvider.getUriForFile import androidx.core.content.FileProvider.getUriForFile
import androidx.glance.GlanceId import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateAll
import androidx.work.CoroutineWorker import androidx.work.*
import androidx.work.Data import com.google.gson.Gson
import androidx.work.ExistingWorkPolicy import kotlinx.coroutines.Dispatchers
import androidx.work.OneTimeWorkRequestBuilder import kotlinx.coroutines.withContext
import androidx.work.OutOfQuotaPolicy import java.io.File
import androidx.work.WorkManager import java.io.FileOutputStream
import androidx.work.WorkerParameters import kotlin.random.Random
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( class ImageDownloadWorker(
private val context: Context, private val context: Context,
@ -54,39 +44,23 @@ class ImageDownloadWorker(
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
fun enqueue(context: Context, size: DpSize, glanceId: GlanceId, force: Boolean = false) { fun enqueue(context: Context, glanceId: GlanceId, config: WidgetConfig) {
val manager = WorkManager.getInstance(context) val manager = WorkManager.getInstance(context)
val requestBuilder = OneTimeWorkRequestBuilder<ImageDownloadWorker>().apply { val requestBuilder = OneTimeWorkRequestBuilder<ImageDownloadWorker>().apply {
addTag(glanceId.toString()) addTag(glanceId.toString())
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
setInputData( setInputData(
Data.Builder() Data.Builder()
.putFloat("width", size.width.value.toPx) .putString("config", Gson().toJson(config))
.putFloat("height", size.height.value.toPx) .putInt("glanceId", glanceId.hashCode())
.putBoolean("force", force)
.build() .build()
) )
} }
val workPolicy = if (force) {
ExistingWorkPolicy.REPLACE
} else {
ExistingWorkPolicy.KEEP
}
manager.enqueueUniqueWork( manager.enqueueUniqueWork(
uniqueWorkName + size.width + size.height, uniqueWorkName + glanceId.hashCode(),
workPolicy,
requestBuilder.build()
)
// Temporary workaround to avoid WM provider to disable itself and trigger an
// app widget update
manager.enqueueUniqueWork(
"$uniqueWorkName-workaround",
ExistingWorkPolicy.KEEP, ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<ImageWorker>().apply { requestBuilder.build()
setInitialDelay(Duration.ofDays(365))
}.build()
) )
} }
@ -100,11 +74,17 @@ class ImageDownloadWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
return try { return try {
val width = inputData.getFloat("width", 0f) val configString = inputData.getString("config")
val height = inputData.getFloat("height", 0f) val config = Gson().fromJson(configString, WidgetConfig::class.java)
val force = inputData.getBoolean("force", false) val glanceId = inputData.getInt("glanceId", -1)
val uri = getRandomImage(width, height, force)
updateImageWidget(width, height, uri) if (glanceId == -1) {
Result.failure()
}
fetchImage(config, glanceId)
updateWidget(config, glanceId)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(uniqueWorkName, "Error while loading image", e) Log.e(uniqueWorkName, "Error while loading image", e)
@ -118,72 +98,31 @@ class ImageDownloadWorker(
} }
} }
private suspend fun updateImageWidget(width: Float, height: Float, uri: String) { private suspend fun updateWidget(config: WidgetConfig, glanceId: Int) {
val manager = GlanceAppWidgetManager(context) val manager = GlanceAppWidgetManager(context)
val glanceIds = manager.getGlanceIds(ImageGlanceWidget::class.java) val glanceIds = manager.getGlanceIds(config.widgetType.widgetClass)
glanceIds.forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs -> for (id in glanceIds) {
prefs[ImageGlanceWidget.getImageKey(width, height)] = uri if (id.hashCode() == glanceId) {
prefs[ImageGlanceWidget.sourceKey] = "Picsum Photos" config.widgetType.widgetClass.getDeclaredConstructor().newInstance().updateAll(context)
prefs[ImageGlanceWidget.sourceUrlKey] = "https://picsum.photos/" break
} }
} }
ImageGlanceWidget().updateAll(context)
} }
/** private suspend fun fetchImage(config: WidgetConfig, glanceId: Int) {
* Use Coil and Picsum Photos to randomly load images into the cache based on the provided val api = ImmichAPI(config.credentials)
* 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 val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1))
with(context.imageLoader) { val image = api.fetchImage(random[0])
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. saveImage(image, glanceId)
val path = context.imageLoader.diskCache?.get(url)?.use { snapshot -> }
val imageFile = snapshot.data.toFile()
// Use the FileProvider to create a content URI private suspend fun saveImage(bitmap: Bitmap, glanceId: Int) = withContext(Dispatchers.IO) {
val contentUri = getUriForFile( val file = File(context.cacheDir, "widget_image_$glanceId.jpg")
context, FileOutputStream(file).use { out ->
"com.example.android.appwidget.fileprovider", bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
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,85 @@
package app.alextran.immich.widget
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.text.SimpleDateFormat
import java.util.*
class ImmichAPI(cfg: ServerConfig) {
private val gson = Gson()
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")}")
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return URL(urlString.toString())
}
suspend fun fetchSearchResults(filters: SearchFilters): List<SearchResult> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
}
connection.outputStream.use {
OutputStreamWriter(it).use { writer ->
writer.write(gson.toJson(filters))
writer.flush()
}
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<SearchResult>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchMemory(date: Date): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(date)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchImage(asset: SearchResult): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
}
}

View File

@ -0,0 +1,56 @@
package app.alextran.immich.widget
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
}
data class SearchResult(
val id: String,
val type: AssetType
)
data class SearchFilters(
var type: AssetType = AssetType.IMAGE,
val size: Int,
var albumIds: List<String> = listOf()
)
data class MemoryResult(
val id: String,
var assets: List<SearchResult>,
val type: String,
val data: MemoryData
) {
data class MemoryData(val year: Int)
}
data class Album(
val id: String,
val albumName: String
)
enum class WidgetType {
RANDOM;
val widgetClass: Class<out GlanceAppWidget>
get() = when (this) {
RANDOM -> RandomWidget::class.java
}
}
data class WidgetConfig(
val widgetType: WidgetType,
val params: Map<String, String>,
val credentials: ServerConfig
)
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)

View File

@ -1,8 +1,6 @@
package app.alextran.immich.widget package app.alextran.immich.widget
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
@ -12,45 +10,28 @@ import androidx.glance.background
import androidx.glance.layout.Box import androidx.glance.layout.Box
import androidx.glance.layout.ContentScale import androidx.glance.layout.ContentScale
import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxSize
import androidx.glance.text.Text
import app.alextran.immich.R 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 @Composable
fun PhotoWidget(imageURI: Uri?, error: String?, subtitle: String?) { fun PhotoWidget(image: Bitmap?, error: String?, subtitle: String?) {
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.background(Color.White) // your color here .background(Color.White) // your color here
) { ) {
Text(subtitle ?: "WTF is this") if (image != null) {
// Image( Image(
// provider = ImageProvider(R.drawable.splash), provider = ImageProvider(image),
// contentDescription = null, contentDescription = "Widget Image"
// contentScale = ContentScale.Crop, )
// modifier = GlanceModifier.fillMaxSize() } else {
// ) Image(
provider = ImageProvider(R.drawable.splash),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
}
} }
} }

View File

@ -5,6 +5,9 @@ import HomeWidgetGlanceStateDefinition
import android.content.Context import android.content.Context
import androidx.glance.appwidget.* import androidx.glance.appwidget.*
import androidx.glance.* 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 androidx.glance.state.GlanceStateDefinition
@ -13,17 +16,40 @@ class RandomWidget : GlanceAppWidget() {
get() = HomeWidgetGlanceStateDefinition() get() = HomeWidgetGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val bitmap = downloadBitmap("https://picsum.photos/600")
// fetch a random photo from server // fetch a random photo from server
provideContent { provideContent {
val prefs = currentState<HomeWidgetGlanceState>().preferences val prefs = currentState<HomeWidgetGlanceState>().preferences
val serverURL = prefs.getString("widget_auth_token", "") val serverURL = prefs.getString("widget_auth_token", "")
val sessionKey = prefs.getString("widget_auth_token", "") val sessionKey = prefs.getString("widget_auth_token", "")
PhotoWidget(imageURI = null, error = null, subtitle = id.hashCode().toString())
PhotoWidget(image = null, error = null, subtitle = id.hashCode().toString())
}
}
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 +1,5 @@
package app.alextran.immich.widget package app.alextran.immich.widget
enum class WidgetError {
NO_LOGIN, FETCH_FAILED, UNKNOWN, ALBUM_NOT_FOUND
}
data class WidgetEntry( data class WidgetEntry(
val imageURI: String? = null, val imageURI: String? = null,