mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
more wip changes
This commit is contained in:
parent
cba6fb0eb3
commit
a482a5a535
@ -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
|
||||
|
@ -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/"
|
||||
}
|
||||
}
|
||||
ImageGlanceWidget().updateAll(context)
|
||||
}
|
||||
val glanceIds = manager.getGlanceIds(config.widgetType.widgetClass)
|
||||
|
||||
/**
|
||||
* 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))
|
||||
for (id in glanceIds) {
|
||||
if (id.hashCode() == glanceId) {
|
||||
config.widgetType.widgetClass.getDeclaredConstructor().newInstance().updateAll(context)
|
||||
break
|
||||
}
|
||||
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()
|
||||
private suspend fun fetchImage(config: WidgetConfig, glanceId: Int) {
|
||||
val api = ImmichAPI(config.credentials)
|
||||
|
||||
// Use the FileProvider to create a content URI
|
||||
val contentUri = getUriForFile(
|
||||
context,
|
||||
"com.example.android.appwidget.fileprovider",
|
||||
imageFile
|
||||
)
|
||||
val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1))
|
||||
val image = api.fetchImage(random[0])
|
||||
|
||||
// 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
|
||||
)
|
||||
saveImage(image, glanceId)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user