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 compose_version = '1.1.1'
def coil_version = '3.2.0'
def gson_version = '2.10.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
@ -113,12 +114,10 @@ dependencies {
//Glance Widget
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-network-okhttp:$coil_version") {
// Exclude OkHttp to avoid conflicts with the one used by Flutter
exclude group: 'com.squareup.okhttp3', module: 'okhttp'
}
implementation("io.coil-kt.coil3:coil-network-okhttp:$coil_version")
}
// 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_READ_URI_PERMISSION
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.core.content.FileProvider.getUriForFile
import androidx.glance.GlanceId
import androidx.glance.*
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
import androidx.work.*
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.random.Random
class ImageDownloadWorker(
private val context: Context,
@ -54,39 +44,23 @@ class ImageDownloadWorker(
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 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)
.putString("config", Gson().toJson(config))
.putInt("glanceId", glanceId.hashCode())
.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",
uniqueWorkName + glanceId.hashCode(),
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<ImageWorker>().apply {
setInitialDelay(Duration.ofDays(365))
}.build()
requestBuilder.build()
)
}
@ -100,11 +74,17 @@ class ImageDownloadWorker(
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)
val configString = inputData.getString("config")
val config = Gson().fromJson(configString, WidgetConfig::class.java)
val glanceId = inputData.getInt("glanceId", -1)
if (glanceId == -1) {
Result.failure()
}
fetchImage(config, glanceId)
updateWidget(config, glanceId)
Result.success()
} catch (e: Exception) {
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 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/"
val glanceIds = manager.getGlanceIds(config.widgetType.widgetClass)
for (id in glanceIds) {
if (id.hashCode() == glanceId) {
config.widgetType.widgetClass.getDeclaredConstructor().newInstance().updateAll(context)
break
}
}
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()
private suspend fun fetchImage(config: WidgetConfig, glanceId: Int) {
val api = ImmichAPI(config.credentials)
// 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
}
}
val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1))
val image = api.fetchImage(random[0])
// Get the path of the loaded image from DiskCache.
val path = context.imageLoader.diskCache?.get(url)?.use { snapshot ->
val imageFile = snapshot.data.toFile()
saveImage(image, glanceId)
}
// 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"
private suspend fun saveImage(bitmap: Bitmap, glanceId: Int) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "widget_image_$glanceId.jpg")
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}

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
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
@ -12,45 +10,28 @@ 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?) {
fun PhotoWidget(image: Bitmap?, 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()
// )
if (image != null) {
Image(
provider = ImageProvider(image),
contentDescription = "Widget Image"
)
} 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 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
@ -13,17 +16,40 @@ class RandomWidget : GlanceAppWidget() {
get() = HomeWidgetGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) {
val bitmap = downloadBitmap("https://picsum.photos/600")
// 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())
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
enum class WidgetError {
NO_LOGIN, FETCH_FAILED, UNKNOWN, ALBUM_NOT_FOUND
}
data class WidgetEntry(
val imageURI: String? = null,