mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
latest changes
This commit is contained in:
parent
a482a5a535
commit
d8f3627beb
@ -155,16 +155,16 @@
|
|||||||
android:resource="@xml/widget" />
|
android:resource="@xml/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/widget" />-->
|
||||||
</receiver>
|
<!-- </receiver>-->
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
}
|
@ -16,23 +16,31 @@ package app.alextran.immich.widget
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import HomeWidgetGlanceState
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.graphics.Bitmap
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider.getUriForFile
|
|
||||||
import androidx.glance.*
|
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.glance.state.GlanceStateDefinition
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class ImageDownloadWorker(
|
class ImageDownloadWorker(
|
||||||
@ -44,53 +52,74 @@ class ImageDownloadWorker(
|
|||||||
|
|
||||||
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
|
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 manager = WorkManager.getInstance(context)
|
||||||
val requestBuilder = OneTimeWorkRequestBuilder<ImageDownloadWorker>().apply {
|
|
||||||
addTag(glanceId.toString())
|
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
|
||||||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
20, TimeUnit.MINUTES
|
||||||
setInputData(
|
)
|
||||||
Data.Builder()
|
.setConstraints(
|
||||||
.putString("config", Gson().toJson(config))
|
Constraints.Builder()
|
||||||
.putInt("glanceId", glanceId.hashCode())
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
.setInputData(
|
||||||
|
Data.Builder()
|
||||||
|
.putString("config", Gson().toJson(config))
|
||||||
|
.putInt("widgetId", appWidgetId)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.addTag(appWidgetId.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
manager.enqueueUniqueWork(
|
manager.enqueueUniquePeriodicWork(
|
||||||
uniqueWorkName + glanceId.hashCode(),
|
"$uniqueWorkName-$appWidgetId",
|
||||||
ExistingWorkPolicy.KEEP,
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
requestBuilder.build()
|
workRequest
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel any ongoing worker
|
|
||||||
*/
|
|
||||||
fun cancel(context: Context, glanceId: GlanceId) {
|
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 {
|
override suspend fun doWork(): Result {
|
||||||
return try {
|
return try {
|
||||||
val configString = inputData.getString("config")
|
val configString = inputData.getString("config")
|
||||||
val config = Gson().fromJson(configString, WidgetConfig::class.java)
|
val config = Gson().fromJson(configString, WidgetConfig::class.java)
|
||||||
val glanceId = inputData.getInt("glanceId", -1)
|
val widgetId = inputData.getInt("widgetId", -1)
|
||||||
|
|
||||||
if (glanceId == -1) {
|
val serverConfig = getServerConfig() ?: return Result.success()
|
||||||
Result.failure()
|
|
||||||
|
val newBitmap = when (config.widgetType) {
|
||||||
|
WidgetType.RANDOM -> fetchRandom(serverConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchImage(config, glanceId)
|
saveImage(newBitmap, widgetId)
|
||||||
updateWidget(config, glanceId)
|
updateWidget(config, widgetId)
|
||||||
|
|
||||||
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)
|
||||||
if (runAttemptCount < 10) {
|
if (runAttemptCount < 10) {
|
||||||
// Exponential backoff strategy will avoid the request to repeat
|
|
||||||
// too fast in case of failures.
|
|
||||||
Result.retry()
|
Result.retry()
|
||||||
} else {
|
} else {
|
||||||
Result.failure()
|
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 manager = GlanceAppWidgetManager(context)
|
||||||
val glanceIds = manager.getGlanceIds(config.widgetType.widgetClass)
|
val glanceId = manager.getGlanceIdBy(widgetID)
|
||||||
|
|
||||||
for (id in glanceIds) {
|
RandomWidget().update(context, glanceId)
|
||||||
if (id.hashCode() == glanceId) {
|
|
||||||
config.widgetType.widgetClass.getDeclaredConstructor().newInstance().updateAll(context)
|
Log.w("WIDGET_BG", "SENT THE UPDATE COMMAND: $widgetID")
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchImage(config: WidgetConfig, glanceId: Int) {
|
private suspend fun fetchRandom(serverConfig: ServerConfig): Bitmap {
|
||||||
val api = ImmichAPI(config.credentials)
|
val api = ImmichAPI(serverConfig)
|
||||||
|
|
||||||
val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1))
|
val random = api.fetchSearchResults(SearchFilters(AssetType.IMAGE, size=1))
|
||||||
val image = api.fetchImage(random[0])
|
val image = api.fetchImage(random[0])
|
||||||
|
|
||||||
saveImage(image, glanceId)
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun saveImage(bitmap: Bitmap, glanceId: Int) = withContext(Dispatchers.IO) {
|
private suspend fun saveImage(bitmap: Bitmap, widgetId: Int) = withContext(Dispatchers.IO) {
|
||||||
val file = File(context.cacheDir, "widget_image_$glanceId.jpg")
|
val file = File(context.cacheDir, "widget_image_$widgetId.jpg")
|
||||||
FileOutputStream(file).use { out ->
|
FileOutputStream(file).use { out ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
||||||
}
|
}
|
||||||
|
@ -22,8 +22,7 @@ class ImmichAPI(cfg: ServerConfig) {
|
|||||||
private val serverConfig = cfg
|
private val serverConfig = cfg
|
||||||
|
|
||||||
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
|
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
|
||||||
val baseUrl = URL(serverConfig.serverEndpoint)
|
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
|
||||||
val urlString = StringBuilder("${baseUrl.protocol}://${baseUrl.host}:${baseUrl.port}$endpoint?sessionKey=${URLEncoder.encode(serverConfig.sessionKey, "UTF-8")}")
|
|
||||||
|
|
||||||
for ((key, value) in params) {
|
for ((key, value) in params) {
|
||||||
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
|
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
|
||||||
|
@ -1,14 +1,8 @@
|
|||||||
package app.alextran.immich.widget
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
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 {
|
enum class AssetType {
|
||||||
IMAGE, VIDEO, AUDIO, OTHER
|
IMAGE, VIDEO, AUDIO, OTHER
|
||||||
}
|
}
|
||||||
@ -50,7 +44,12 @@ enum class WidgetType {
|
|||||||
data class WidgetConfig(
|
data class WidgetConfig(
|
||||||
val widgetType: WidgetType,
|
val widgetType: WidgetType,
|
||||||
val params: Map<String, String>,
|
val params: Map<String, String>,
|
||||||
val credentials: ServerConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
|
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")
|
||||||
|
@ -10,6 +10,7 @@ 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
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -23,15 +24,17 @@ fun PhotoWidget(image: Bitmap?, error: String?, subtitle: String?) {
|
|||||||
if (image != null) {
|
if (image != null) {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(image),
|
provider = ImageProvider(image),
|
||||||
contentDescription = "Widget Image"
|
contentDescription = "Widget Image",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = GlanceModifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(R.drawable.splash),
|
provider = ImageProvider(R.drawable.splash),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = GlanceModifier.fillMaxSize()
|
|
||||||
)
|
)
|
||||||
|
Text(subtitle ?: "NOPERS")
|
||||||
|
Text(error ?: "NOPERS")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,36 @@
|
|||||||
package app.alextran.immich.widget
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
import HomeWidgetGlanceWidgetReceiver
|
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>() {
|
class RandomReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() {
|
||||||
override val glanceAppWidget = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,53 +3,38 @@ package app.alextran.immich.widget
|
|||||||
import HomeWidgetGlanceState
|
import HomeWidgetGlanceState
|
||||||
import HomeWidgetGlanceStateDefinition
|
import HomeWidgetGlanceStateDefinition
|
||||||
import android.content.Context
|
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.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
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class RandomWidget : GlanceAppWidget() {
|
class RandomWidget : GlanceAppWidget() {
|
||||||
override val stateDefinition: GlanceStateDefinition<HomeWidgetGlanceState>
|
override val stateDefinition: GlanceStateDefinition<HomeWidgetGlanceState>
|
||||||
get() = HomeWidgetGlanceStateDefinition()
|
get() = HomeWidgetGlanceStateDefinition()
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
Log.w("WIDGET_UPDATE", "PROVIDED GLANCE")
|
||||||
// fetch a random photo from server
|
// 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 {
|
provideContent {
|
||||||
|
PhotoWidget(image = bitmap, error = null, subtitle = "hello")
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override suspend fun onDelete(context: Context, glanceId: GlanceId) {
|
override suspend fun onDelete(context: Context, glanceId: GlanceId) {
|
||||||
super.onDelete(context, glanceId)
|
super.onDelete(context, glanceId)
|
||||||
ImageDownloadWorker.cancel(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 +0,0 @@
|
|||||||
package app.alextran.immich.widget
|
|
||||||
|
|
||||||
|
|
||||||
data class WidgetEntry(
|
|
||||||
val imageURI: String? = null,
|
|
||||||
val error: WidgetError? = null,
|
|
||||||
val subtitle: String? = null
|
|
||||||
)
|
|
Loading…
x
Reference in New Issue
Block a user