fix: image loading when using markdown img and bigImageUrl

When receiving a message with the same image in the markdown body and in
the extras client::notification.bigImageUrl, then there is a clash on
the file system.

One request succeeds and the other fails with the following error. This
commit ensures that there is only one coil image loader instance, so
that there shouldn't be file system race conditions.

    WebSocket(1): received message {"id":845,"appid":21,...}
    Failed - http://192.168.178.2:8000/1.jpg?v=1718369188 - java.lang.IllegalStateException: closed
    java.lang.IllegalStateException: closed
        at okio.RealBufferedSource.rangeEquals(RealBufferedSource.kt:466)
        at okio.RealBufferedSource.rangeEquals(RealBufferedSource.kt:130)
        at coil.decode.SvgDecodeUtils.isSvg(DecodeUtils.kt:19)
        at coil.decode.SvgDecoder$Factory.isApplicable(SvgDecoder.kt:104)
        at coil.decode.SvgDecoder$Factory.create(SvgDecoder.kt:99)
        at coil.ComponentRegistry.newDecoder(ComponentRegistry.kt:100)
        at coil.intercept.EngineInterceptor.decode(EngineInterceptor.kt:197)
        at coil.intercept.EngineInterceptor.access$decode(EngineInterceptor.kt:42)
        at coil.intercept.EngineInterceptor$execute$executeResult$1.invokeSuspend(EngineInterceptor.kt:131)
        at coil.intercept.EngineInterceptor$execute$executeResult$1.invoke(EngineInterceptor.kt)
        at coil.intercept.EngineInterceptor$execute$executeResult$1.invoke(EngineInterceptor.kt)
        at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:78)
        at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:167)
        at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
        at coil.intercept.EngineInterceptor.execute(EngineInterceptor.kt:130)
        at coil.intercept.EngineInterceptor.access$execute(EngineInterceptor.kt:42)
        at coil.intercept.EngineInterceptor$execute$1.invokeSuspend(EngineInterceptor.kt)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
        at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:115)
        at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:103)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
    Successful (NETWORK) - http://192.168.178.2:8000/1.jpg?v=1718369188
This commit is contained in:
Jannis Mattheis
2024-06-14 14:44:21 +02:00
parent b9b767f5d7
commit ac27d9ec6d
5 changed files with 65 additions and 54 deletions

View File

@@ -16,13 +16,62 @@ import java.io.IOException
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.tinylog.kotlin.Logger import org.tinylog.kotlin.Logger
internal class CoilHandler(private val context: Context, private val settings: Settings) { object CoilInstance {
private val imageLoader = makeImageLoader() private var holder: Pair<SSLSettings, ImageLoader>? = null
private fun makeImageLoader(): ImageLoader { @Throws(IOException::class)
fun getImageFromUrl(context: Context, url: String?): Bitmap {
val request = ImageRequest.Builder(context)
.data(url)
.build()
return (get(context).executeBlocking(request).drawable as BitmapDrawable).bitmap
}
fun getIcon(context: Context, app: Application?): Bitmap {
if (app == null) {
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
val baseUrl = Settings(context).url
try {
return getImageFromUrl(
context,
Utils.resolveAbsoluteUrl("$baseUrl/", app.image)
)
} catch (e: IOException) {
Logger.error(e, "Could not load image for notification")
}
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
@OptIn(ExperimentalCoilApi::class)
fun evict(context: Context) {
try {
get(context).apply {
diskCache?.clear()
memoryCache?.clear()
}
} catch (e: IOException) {
Logger.error(e, "Problem evicting Coil cache")
}
}
@Synchronized
fun get(context: Context): ImageLoader {
val newSettings = Settings(context).sslSettings()
val copy = holder
if (copy != null && copy.first == newSettings) {
return copy.second
}
return makeImageLoader(context, newSettings).also { holder = it }.second
}
private fun makeImageLoader(
context: Context,
sslSettings: SSLSettings
): Pair<SSLSettings, ImageLoader> {
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
CertUtils.applySslSettings(builder, settings.sslSettings()) CertUtils.applySslSettings(builder, sslSettings)
return ImageLoader.Builder(context) val loader = ImageLoader.Builder(context)
.okHttpClient(builder.build()) .okHttpClient(builder.build())
.diskCache { .diskCache {
DiskCache.Builder() DiskCache.Builder()
@@ -33,39 +82,6 @@ internal class CoilHandler(private val context: Context, private val settings: S
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
} }
.build() .build()
} return sslSettings to loader
@Throws(IOException::class)
fun getImageFromUrl(url: String?): Bitmap {
val request = ImageRequest.Builder(context)
.data(url)
.build()
return (imageLoader.executeBlocking(request).drawable as BitmapDrawable).bitmap
}
fun getIcon(app: Application?): Bitmap {
if (app == null) {
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
try {
return getImageFromUrl(
Utils.resolveAbsoluteUrl("${settings.url}/", app.image)
)
} catch (e: IOException) {
Logger.error(e, "Could not load image for notification")
}
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
fun get() = imageLoader
@OptIn(ExperimentalCoilApi::class)
fun evict() {
try {
imageLoader.diskCache?.clear()
imageLoader.memoryCache?.clear()
} catch (e: IOException) {
Logger.error(e, "Problem evicting Coil cache")
}
} }
} }

View File

@@ -1,6 +1,6 @@
package com.github.gotify package com.github.gotify
internal class SSLSettings( internal data class SSLSettings(
val validateSSL: Boolean, val validateSSL: Boolean,
val caCertPath: String?, val caCertPath: String?,
val clientCertPath: String?, val clientCertPath: String?,

View File

@@ -31,6 +31,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.request.ImageRequest import coil.request.ImageRequest
import com.github.gotify.BuildConfig import com.github.gotify.BuildConfig
import com.github.gotify.CoilInstance
import com.github.gotify.MissedMessageUtil import com.github.gotify.MissedMessageUtil
import com.github.gotify.R import com.github.gotify.R
import com.github.gotify.Utils import com.github.gotify.Utils
@@ -102,7 +103,7 @@ internal class MessagesActivity :
listMessageAdapter = ListMessageAdapter( listMessageAdapter = ListMessageAdapter(
this, this,
viewModel.settings, viewModel.settings,
viewModel.coilHandler.get() CoilInstance.get(this)
) { message -> ) { message ->
scheduleDeletion(message) scheduleDeletion(message)
} }
@@ -169,13 +170,13 @@ internal class MessagesActivity :
} }
private fun refreshAll() { private fun refreshAll() {
viewModel.coilHandler.evict() CoilInstance.evict(this)
startActivity(Intent(this, InitializationActivity::class.java)) startActivity(Intent(this, InitializationActivity::class.java))
finish() finish()
} }
private fun onRefresh() { private fun onRefresh() {
viewModel.coilHandler.evict() CoilInstance.evict(this)
viewModel.messages.clear() viewModel.messages.clear()
launchCoroutine { launchCoroutine {
loadMore(viewModel.appId).forEachIndexed { index, message -> loadMore(viewModel.appId).forEachIndexed { index, message ->
@@ -211,9 +212,7 @@ internal class MessagesActivity :
.size(100, 100) .size(100, 100)
.target(t) .target(t)
.build() .build()
viewModel.coilHandler CoilInstance.get(this).enqueue(request)
.get()
.enqueue(request)
} }
selectAppInMenu(selectedItem) selectAppInMenu(selectedItem)
} }

View File

@@ -3,7 +3,6 @@ package com.github.gotify.messages
import android.app.Activity import android.app.Activity
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import coil.target.Target import coil.target.Target
import com.github.gotify.CoilHandler
import com.github.gotify.Settings import com.github.gotify.Settings
import com.github.gotify.api.ClientFactory import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.MessageApi import com.github.gotify.client.api.MessageApi
@@ -13,7 +12,6 @@ import com.github.gotify.messages.provider.MessageState
internal class MessagesModel(parentView: Activity) : ViewModel() { internal class MessagesModel(parentView: Activity) : ViewModel() {
val settings = Settings(parentView) val settings = Settings(parentView)
val coilHandler = CoilHandler(parentView, settings)
val client = ClientFactory.clientToken(settings) val client = ClientFactory.clientToken(settings)
val appsHolder = ApplicationHolder(parentView, client) val appsHolder = ApplicationHolder(parentView, client)
val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder) val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder)

View File

@@ -17,7 +17,7 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.github.gotify.BuildConfig import com.github.gotify.BuildConfig
import com.github.gotify.CoilHandler import com.github.gotify.CoilInstance
import com.github.gotify.MarkwonFactory import com.github.gotify.MarkwonFactory
import com.github.gotify.MissedMessageUtil import com.github.gotify.MissedMessageUtil
import com.github.gotify.NotificationSupport import com.github.gotify.NotificationSupport
@@ -62,7 +62,6 @@ internal class WebSocketService : Service() {
private val lastReceivedMessage = AtomicLong(NOT_LOADED) private val lastReceivedMessage = AtomicLong(NOT_LOADED)
private lateinit var missingMessageUtil: MissedMessageUtil private lateinit var missingMessageUtil: MissedMessageUtil
private lateinit var coilHandler: CoilHandler
private lateinit var markwon: Markwon private lateinit var markwon: Markwon
override fun onCreate() { override fun onCreate() {
@@ -71,8 +70,7 @@ internal class WebSocketService : Service() {
val client = ClientFactory.clientToken(settings) val client = ClientFactory.clientToken(settings)
missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java)) missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java))
Logger.info("Create ${javaClass.simpleName}") Logger.info("Create ${javaClass.simpleName}")
coilHandler = CoilHandler(this, settings) markwon = MarkwonFactory.createForNotification(this, CoilInstance.get(this))
markwon = MarkwonFactory.createForNotification(this, coilHandler.get())
} }
override fun onDestroy() { override fun onDestroy() {
@@ -377,7 +375,7 @@ internal class WebSocketService : Service() {
.setDefaults(Notification.DEFAULT_ALL) .setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis()) .setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify) .setSmallIcon(R.drawable.ic_gotify)
.setLargeIcon(coilHandler.getIcon(appIdToApp[appId])) .setLargeIcon(CoilInstance.getIcon(this, appIdToApp[appId]))
.setTicker("${getString(R.string.app_name)} - $title") .setTicker("${getString(R.string.app_name)} - $title")
.setGroup(NotificationSupport.Group.MESSAGES) .setGroup(NotificationSupport.Group.MESSAGES)
.setContentTitle(title) .setContentTitle(title)
@@ -406,7 +404,7 @@ internal class WebSocketService : Service() {
try { try {
b.setStyle( b.setStyle(
NotificationCompat.BigPictureStyle() NotificationCompat.BigPictureStyle()
.bigPicture(coilHandler.getImageFromUrl(notificationImageUrl)) .bigPicture(CoilInstance.getImageFromUrl(this, notificationImageUrl))
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.error(e, "Error loading bigImageUrl") Logger.error(e, "Error loading bigImageUrl")