Merge pull request #337 from cyb3rko/picasso-to-coil

Replace Picasso with Coil
This commit is contained in:
Jannis Mattheis
2024-04-21 13:01:13 +02:00
committed by GitHub
12 changed files with 132 additions and 168 deletions

View File

@@ -66,6 +66,7 @@ if (project.hasProperty('sign')) {
}
dependencies {
def markwon_version = "4.6.2"
implementation project(':client')
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
@@ -76,12 +77,12 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.github.cyb3rko:QuickPermissions-Kotlin:1.1.3'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'io.noties.markwon:image-picasso:4.6.2'
implementation 'io.noties.markwon:image:4.6.2'
implementation 'io.noties.markwon:ext-tables:4.6.2'
implementation 'io.noties.markwon:ext-strikethrough:4.6.2'
implementation 'io.coil-kt:coil:2.6.0'
implementation "io.noties.markwon:core:$markwon_version"
implementation "io.noties.markwon:image-coil:$markwon_version"
implementation "io.noties.markwon:image:$markwon_version"
implementation "io.noties.markwon:ext-tables:$markwon_version"
implementation "io.noties.markwon:ext-strikethrough:$markwon_version"
implementation 'org.tinylog:tinylog-api-kotlin:2.6.2'
implementation 'org.tinylog:tinylog-impl:2.6.2'

View File

@@ -0,0 +1,67 @@
package com.github.gotify
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.disk.DiskCache
import coil.executeBlocking
import coil.request.ImageRequest
import com.github.gotify.api.CertUtils
import com.github.gotify.client.model.Application
import java.io.IOException
import okhttp3.OkHttpClient
import org.tinylog.kotlin.Logger
internal class CoilHandler(private val context: Context, private val settings: Settings) {
private val imageLoader = makeImageLoader()
private fun makeImageLoader(): ImageLoader {
val builder = OkHttpClient.Builder()
CertUtils.applySslSettings(builder, settings.sslSettings())
return ImageLoader.Builder(context)
.okHttpClient(builder.build())
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("coil-cache"))
.build()
}
.build()
}
@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

@@ -10,7 +10,7 @@ import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import androidx.core.content.ContextCompat
import com.squareup.picasso.Picasso
import coil.ImageLoader
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonSpansFactory
@@ -22,7 +22,7 @@ import io.noties.markwon.core.MarkwonTheme
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TableAwareMovementMethod
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.image.picasso.PicassoImagesPlugin
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.movement.MovementMethodPlugin
import org.commonmark.ext.gfm.tables.TableCell
import org.commonmark.ext.gfm.tables.TablesExtension
@@ -36,11 +36,11 @@ import org.commonmark.node.StrongEmphasis
import org.commonmark.parser.Parser
internal object MarkwonFactory {
fun createForMessage(context: Context, picasso: Picasso): Markwon {
fun createForMessage(context: Context, imageLoader: ImageLoader): Markwon {
return Markwon.builder(context)
.usePlugin(CorePlugin.create())
.usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create()))
.usePlugin(PicassoImagesPlugin.create(picasso))
.usePlugin(CoilImagesPlugin.create(context, imageLoader))
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(context))
.usePlugin(object : AbstractMarkwonPlugin() {
@@ -52,13 +52,13 @@ internal object MarkwonFactory {
.build()
}
fun createForNotification(context: Context, picasso: Picasso): Markwon {
fun createForNotification(context: Context, imageLoader: ImageLoader): Markwon {
val headingSizes = floatArrayOf(2f, 1.5f, 1.17f, 1f, .83f, .67f)
val bulletGapWidth = (8 * context.resources.displayMetrics.density + 0.5f).toInt()
return Markwon.builder(context)
.usePlugin(CorePlugin.create())
.usePlugin(PicassoImagesPlugin.create(picasso))
.usePlugin(CoilImagesPlugin.create(context, imageLoader))
.usePlugin(StrikethroughPlugin.create())
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {

View File

@@ -3,19 +3,15 @@ package com.github.gotify
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.text.format.DateUtils
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import coil.target.Target
import com.github.gotify.client.JSON
import com.google.android.material.snackbar.Snackbar
import com.google.gson.Gson
import com.squareup.picasso.Picasso.LoadedFrom
import com.squareup.picasso.Target
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStream
@@ -72,17 +68,15 @@ internal object Utils {
}
}
fun toDrawable(resources: Resources?, drawableReceiver: DrawableReceiver): Target {
fun toDrawable(drawableReceiver: DrawableReceiver): Target {
return object : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: LoadedFrom) {
drawableReceiver.loaded(BitmapDrawable(resources, bitmap))
override fun onSuccess(result: Drawable) {
drawableReceiver.loaded(result)
}
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
Logger.error(e, "Bitmap failed")
override fun onError(error: Drawable?) {
Logger.error("Bitmap failed")
}
override fun onPrepareLoad(placeHolderDrawable: Drawable) {}
}
}

View File

@@ -32,7 +32,7 @@ import com.github.gotify.log.UncaughtExceptionHandler
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import java.security.cert.X509Certificate
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.tinylog.kotlin.Logger
internal class LoginActivity : AppCompatActivity() {
@@ -101,13 +101,13 @@ internal class LoginActivity : AppCompatActivity() {
private fun doCheckUrl() {
val url = binding.gotifyUrlEditext.text.toString().trim().trimEnd('/')
val parsedUrl = HttpUrl.parse(url)
val parsedUrl = url.toHttpUrlOrNull()
if (parsedUrl == null) {
Utils.showSnackBar(this, "Invalid URL (include http:// or https://)")
return
}
if ("http" == parsedUrl.scheme()) {
if ("http" == parsedUrl.scheme) {
showHttpWarning()
}

View File

@@ -18,6 +18,8 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil.ImageLoader
import coil.load
import com.github.gotify.MarkwonFactory
import com.github.gotify.R
import com.github.gotify.Settings
@@ -26,7 +28,6 @@ import com.github.gotify.client.model.Message
import com.github.gotify.databinding.MessageItemBinding
import com.github.gotify.databinding.MessageItemCompactBinding
import com.github.gotify.messages.provider.MessageWithImage
import com.squareup.picasso.Picasso
import io.noties.markwon.Markwon
import java.text.DateFormat
import java.util.Date
@@ -35,11 +36,11 @@ import org.threeten.bp.OffsetDateTime
internal class ListMessageAdapter(
private val context: Context,
private val settings: Settings,
private val picasso: Picasso,
private val imageLoader: ImageLoader,
private val delete: Delete
) : ListAdapter<MessageWithImage, ListMessageAdapter.ViewHolder>(DiffCallback) {
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val markwon: Markwon = MarkwonFactory.createForMessage(context, picasso)
private val markwon: Markwon = MarkwonFactory.createForMessage(context, imageLoader)
private val timeFormatRelative =
context.resources.getString(R.string.time_format_value_relative)
@@ -81,10 +82,11 @@ internal class ListMessageAdapter(
}
holder.title.text = message.message.title
if (message.image != null) {
picasso.load(Utils.resolveAbsoluteUrl("${settings.url}/", message.image))
.error(R.drawable.ic_alarm)
.placeholder(R.drawable.ic_placeholder)
.into(holder.image)
val url = Utils.resolveAbsoluteUrl("${settings.url}/", message.image)
holder.image.load(url, imageLoader) {
error(R.drawable.ic_alarm)
placeholder(R.drawable.ic_placeholder)
}
}
val prefs = PreferenceManager.getDefaultSharedPreferences(context)

View File

@@ -29,6 +29,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.request.ImageRequest
import com.github.gotify.BuildConfig
import com.github.gotify.MissedMessageUtil
import com.github.gotify.R
@@ -57,7 +58,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback
import com.google.android.material.snackbar.Snackbar
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tinylog.kotlin.Logger
@@ -102,7 +102,7 @@ internal class MessagesActivity :
listMessageAdapter = ListMessageAdapter(
this,
viewModel.settings,
viewModel.picassoHandler.get()
viewModel.coilHandler.get()
) { message ->
scheduleDeletion(message)
}
@@ -169,19 +169,20 @@ internal class MessagesActivity :
}
private fun refreshAll() {
try {
viewModel.picassoHandler.evict()
} catch (e: IOException) {
Logger.error(e, "Problem evicting Picasso cache")
}
viewModel.coilHandler.evict()
startActivity(Intent(this, InitializationActivity::class.java))
finish()
}
private fun onRefresh() {
viewModel.coilHandler.evict()
viewModel.messages.clear()
launchCoroutine {
loadMore(viewModel.appId)
loadMore(viewModel.appId).forEachIndexed { index, message ->
if (message.image != null) {
listMessageAdapter.notifyItemChanged(index)
}
}
}
}
@@ -201,15 +202,18 @@ internal class MessagesActivity :
val item = menu.add(R.id.apps, index, APPLICATION_ORDER, app.name)
item.isCheckable = true
if (app.id == viewModel.appId) selectedItem = item
val t = Utils.toDrawable(resources) { icon -> item.icon = icon }
val t = Utils.toDrawable { icon -> item.icon = icon }
viewModel.targetReferences.add(t)
viewModel.picassoHandler
.get()
.load(Utils.resolveAbsoluteUrl(viewModel.settings.url + "/", app.image))
val request = ImageRequest.Builder(this)
.data(Utils.resolveAbsoluteUrl(viewModel.settings.url + "/", app.image))
.error(R.drawable.ic_alarm)
.placeholder(R.drawable.ic_placeholder)
.resize(100, 100)
.into(t)
.size(100, 100)
.target(t)
.build()
viewModel.coilHandler
.get()
.enqueue(request)
}
selectAppInMenu(selectedItem)
}
@@ -552,11 +556,12 @@ internal class MessagesActivity :
)
}
private suspend fun loadMore(appId: Long) {
private suspend fun loadMore(appId: Long): List<MessageWithImage> {
val messagesWithImages = viewModel.messages.loadMore(appId)
withContext(Dispatchers.Main) {
updateMessagesAndStopLoading(messagesWithImages)
}
return messagesWithImages
}
private suspend fun updateMessagesForApplication(withLoadingSpinner: Boolean, appId: Long) {

View File

@@ -2,18 +2,18 @@ package com.github.gotify.messages
import android.app.Activity
import androidx.lifecycle.ViewModel
import coil.target.Target
import com.github.gotify.CoilHandler
import com.github.gotify.Settings
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.MessageApi
import com.github.gotify.messages.provider.ApplicationHolder
import com.github.gotify.messages.provider.MessageFacade
import com.github.gotify.messages.provider.MessageState
import com.github.gotify.picasso.PicassoHandler
import com.squareup.picasso.Target
internal class MessagesModel(parentView: Activity) : ViewModel() {
val settings = Settings(parentView)
val picassoHandler = PicassoHandler(parentView, settings)
val coilHandler = CoilHandler(parentView, settings)
val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token)
val appsHolder = ApplicationHolder(parentView, client)
val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder)

View File

@@ -1,39 +0,0 @@
package com.github.gotify.picasso
import android.graphics.BitmapFactory
import android.util.Base64
import com.squareup.picasso.Picasso
import com.squareup.picasso.Request
import com.squareup.picasso.RequestHandler
import org.tinylog.kotlin.Logger
/**
* Adapted from https://github.com/square/picasso/issues/1395#issuecomment-220929377 By
* https://github.com/SmartDengg
*/
internal class PicassoDataRequestHandler : RequestHandler() {
companion object {
private const val DATA_SCHEME = "data"
}
override fun canHandleRequest(data: Request): Boolean {
val scheme = data.uri.scheme
return DATA_SCHEME.equals(scheme, ignoreCase = true)
}
override fun load(request: Request, networkPolicy: Int): Result {
val uri = request.uri.toString()
val imageDataBytes = uri.substring(uri.indexOf(",") + 1)
val bytes = Base64.decode(imageDataBytes.toByteArray(), Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (bitmap == null) {
val show = if (uri.length > 50) uri.take(50) + "..." else uri
val malformed = RuntimeException("Malformed data uri: $show")
Logger.error(malformed, "Could not load image")
throw malformed
}
return Result(bitmap, Picasso.LoadedFrom.NETWORK)
}
}

View File

@@ -1,66 +0,0 @@
package com.github.gotify.picasso
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.github.gotify.R
import com.github.gotify.Settings
import com.github.gotify.Utils
import com.github.gotify.api.CertUtils
import com.github.gotify.client.model.Application
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import java.io.File
import java.io.IOException
import okhttp3.Cache
import okhttp3.OkHttpClient
import org.tinylog.kotlin.Logger
internal class PicassoHandler(private val context: Context, private val settings: Settings) {
companion object {
private const val PICASSO_CACHE_SIZE = 50 * 1024 * 1024 // 50 MB
private const val PICASSO_CACHE_SUBFOLDER = "picasso-cache"
}
private val picassoCache = Cache(
File(context.cacheDir, PICASSO_CACHE_SUBFOLDER),
PICASSO_CACHE_SIZE.toLong()
)
private val picasso = makePicasso()
private fun makePicasso(): Picasso {
val builder = OkHttpClient.Builder()
builder.cache(picassoCache)
CertUtils.applySslSettings(builder, settings.sslSettings())
val downloader = OkHttp3Downloader(builder.build())
return Picasso.Builder(context)
.addRequestHandler(PicassoDataRequestHandler())
.downloader(downloader)
.build()
}
@Throws(IOException::class)
fun getImageFromUrl(url: String?): Bitmap = picasso.load(url).get()
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() = picasso
@Throws(IOException::class)
fun evict() {
picassoCache.evictAll()
}
}

View File

@@ -11,7 +11,7 @@ import com.github.gotify.client.model.Message
import java.util.Calendar
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -89,7 +89,7 @@ internal class WebSocketConnection(
}
private fun request(): Request {
val url = HttpUrl.parse(baseUrl)!!
val url = baseUrl.toHttpUrlOrNull()!!
.newBuilder()
.addPathSegment("stream")
.addQueryParameter("token", token)
@@ -187,12 +187,12 @@ internal class WebSocketConnection(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val code = if (response != null) "StatusCode: ${response.code()}" else ""
val message = if (response != null) response.message() else ""
val code = if (response != null) "StatusCode: ${response.code}" else ""
val message = response?.message ?: ""
Logger.error(t) { "WebSocket($id): failure $code Message: $message" }
syncExec(id) {
closed()
if (response != null && response.code() >= 400 && response.code() <= 499) {
if (response != null && response.code >= 400 && response.code <= 499) {
onBadRequest.execute(message)
return@syncExec
}

View File

@@ -17,6 +17,7 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.github.gotify.BuildConfig
import com.github.gotify.CoilHandler
import com.github.gotify.MarkwonFactory
import com.github.gotify.MissedMessageUtil
import com.github.gotify.NotificationSupport
@@ -34,7 +35,6 @@ import com.github.gotify.log.UncaughtExceptionHandler
import com.github.gotify.messages.Extras
import com.github.gotify.messages.IntentUrlDialogActivity
import com.github.gotify.messages.MessagesActivity
import com.github.gotify.picasso.PicassoHandler
import io.noties.markwon.Markwon
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@@ -62,7 +62,7 @@ internal class WebSocketService : Service() {
private val lastReceivedMessage = AtomicLong(NOT_LOADED)
private lateinit var missingMessageUtil: MissedMessageUtil
private lateinit var picassoHandler: PicassoHandler
private lateinit var coilHandler: CoilHandler
private lateinit var markwon: Markwon
override fun onCreate() {
@@ -75,8 +75,8 @@ internal class WebSocketService : Service() {
)
missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java))
Logger.info("Create ${javaClass.simpleName}")
picassoHandler = PicassoHandler(this, settings)
markwon = MarkwonFactory.createForNotification(this, picassoHandler.get())
coilHandler = CoilHandler(this, settings)
markwon = MarkwonFactory.createForNotification(this, coilHandler.get())
}
override fun onDestroy() {
@@ -381,7 +381,7 @@ internal class WebSocketService : Service() {
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setLargeIcon(picassoHandler.getIcon(appIdToApp[appId]))
.setLargeIcon(coilHandler.getIcon(appIdToApp[appId]))
.setTicker("${getString(R.string.app_name)} - $title")
.setGroup(NotificationSupport.Group.MESSAGES)
.setContentTitle(title)
@@ -410,7 +410,7 @@ internal class WebSocketService : Service() {
try {
b.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(picassoHandler.getImageFromUrl(notificationImageUrl))
.bigPicture(coilHandler.getImageFromUrl(notificationImageUrl))
)
} catch (e: Exception) {
Logger.error(e, "Error loading bigImageUrl")