From 881a4b1da3a92f2794b7349bbd77f22bfce6e946 Mon Sep 17 00:00:00 2001 From: Walter Huf Date: Sat, 27 Jan 2024 20:54:01 -0800 Subject: [PATCH] Show feed icons --- .../io/bimmergestalt/reader/GraphicsUtils.kt | 60 +++++++++++++++++++ .../java/io/bimmergestalt/reader/Utils.kt | 14 ++++- .../io/bimmergestalt/reader/carapp/CarApp.kt | 5 +- .../reader/carapp/CarAppService.kt | 3 +- .../io/bimmergestalt/reader/carapp/Model.kt | 4 +- .../reader/carapp/views/FeedView.kt | 49 +++++++++++---- 6 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 app/src/gestalt/java/io/bimmergestalt/reader/GraphicsUtils.kt diff --git a/app/src/gestalt/java/io/bimmergestalt/reader/GraphicsUtils.kt b/app/src/gestalt/java/io/bimmergestalt/reader/GraphicsUtils.kt new file mode 100644 index 000000000..7284b25ea --- /dev/null +++ b/app/src/gestalt/java/io/bimmergestalt/reader/GraphicsUtils.kt @@ -0,0 +1,60 @@ +package io.bimmergestalt.reader + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.Base64 +import androidx.core.graphics.drawable.toDrawable +import coil.imageLoader +import coil.request.ImageRequest +import java.io.ByteArrayOutputStream + +class GraphicsUtils(val context: Context) { + val imageLoader = context.imageLoader + + suspend fun loadImageUri(uri: String?, width: Int, height: Int): Drawable? { + uri ?: return null + return if (Regex("^image/[a-rt-z-]*;base64,.*").matches(uri)) { + loadBase64Drawable(uri) + } else if (uri.startsWith("http")) { + val request = ImageRequest.Builder(context) + .data(uri) + .allowHardware(false) + .size(width, height) + .build() + imageLoader.execute(request).drawable + } else { + null + } + } + + fun loadBase64Drawable(base64Uri: String): Drawable? { + val bytes = base64ToBytes(base64Uri) + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size).toDrawable(context.resources) + } + + private fun base64ToBytes(base64String: String): ByteArray { + val base64Data = base64String.substringAfter("base64,") + return Base64.decode(base64Data, Base64.DEFAULT) + } + + fun resizeDrawable(drawable: Drawable, width: Int, height: Int): Bitmap { + if (drawable is BitmapDrawable && drawable.bitmap.width == width && drawable.bitmap.height == height) { + return drawable.bitmap + } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, width, height) + drawable.draw(canvas) + return bitmap + } + + fun compressBitmapJpg(bitmap: Bitmap, quality: Int): ByteArray { + val jpg = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, jpg) + return jpg.toByteArray() + } +} \ No newline at end of file diff --git a/app/src/gestalt/java/io/bimmergestalt/reader/Utils.kt b/app/src/gestalt/java/io/bimmergestalt/reader/Utils.kt index 4f30bb834..1bf1f1c41 100644 --- a/app/src/gestalt/java/io/bimmergestalt/reader/Utils.kt +++ b/app/src/gestalt/java/io/bimmergestalt/reader/Utils.kt @@ -1,6 +1,9 @@ package io.bimmergestalt.reader import androidx.core.text.HtmlCompat +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope object Utils { fun parseHtml(article: String): String { @@ -20,4 +23,13 @@ object Utils { .map { it.trim() } .filter { it.isNotBlank() } } -} \ No newline at end of file + + // https://stackoverflow.com/a/74207113/169035 + val Iterable.par get() = ParallelizedIterable(this) + @JvmInline + value class ParallelizedIterable(val iter: Iterable) { + suspend fun map(f: suspend (A) -> B): List = coroutineScope { + iter.map { async { f(it) } }.awaitAll() + } + } +} diff --git a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarApp.kt b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarApp.kt index 331434dcb..09a16dc41 100644 --- a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarApp.kt +++ b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarApp.kt @@ -18,6 +18,7 @@ import io.bimmergestalt.idriveconnectkit.rhmi.RHMIApplicationIdempotent import io.bimmergestalt.idriveconnectkit.rhmi.RHMIApplicationSynchronized import io.bimmergestalt.idriveconnectkit.rhmi.RHMIComponent import io.bimmergestalt.idriveconnectkit.rhmi.RHMIState +import io.bimmergestalt.reader.GraphicsUtils import io.bimmergestalt.reader.carapp.views.FeedView import io.bimmergestalt.reader.carapp.views.HomeView import io.bimmergestalt.reader.carapp.views.ReadView @@ -27,7 +28,7 @@ import me.ash.reader.domain.service.RssService const val TAG = "ReaderGestalt" class CarApp(val iDriveConnectionStatus: IDriveConnectionStatus, securityAccess: SecurityAccess, val carAppResources: CarAppSharedAssetResources, - val rssService: RssService, workManager: WorkManager + val rssService: RssService, workManager: WorkManager, graphicsUtils: GraphicsUtils ) { val carConnection: BMWRemotingServer @@ -52,7 +53,7 @@ class CarApp(val iDriveConnectionStatus: IDriveConnectionStatus, securityAccess: readoutController = ReadoutController.build(carApp, "News") val destStateId = carApp.components.values.filterIsInstance().first().getAction()?.asHMIAction()?.target!! homeView = HomeView(carApp.states[destStateId] as RHMIState, rssService, model) - feedView = FeedView(carApp.states[homeView.getFeedButtonDest()]!!, rssService, model) + feedView = FeedView(carApp.states[homeView.getFeedButtonDest()]!!, rssService, model, graphicsUtils) readView = ReadView(carApp.states[homeView.getEntryListDest()] as RHMIState.ToolbarState, model) readoutView = ReadoutView(carApp.states[readView.getReadoutDest()] as RHMIState.ToolbarState, readoutController, model) diff --git a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarAppService.kt b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarAppService.kt index 110a4667d..e8925a8c5 100644 --- a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarAppService.kt +++ b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/CarAppService.kt @@ -10,6 +10,7 @@ import io.bimmergestalt.idriveconnectkit.android.CarAppAssetResources import io.bimmergestalt.idriveconnectkit.android.IDriveConnectionReceiver import io.bimmergestalt.idriveconnectkit.android.IDriveConnectionStatus import io.bimmergestalt.idriveconnectkit.android.security.SecurityAccess +import io.bimmergestalt.reader.GraphicsUtils import io.bimmergestalt.reader.L import me.ash.reader.domain.service.RssService import javax.inject.Inject @@ -78,7 +79,7 @@ class CarAppService: Service() { iDriveConnectionStatus, securityAccess, CarAppSharedAssetResources(applicationContext, "news"), - rssService, workManager + rssService, workManager, GraphicsUtils(applicationContext) ) } thread?.start() diff --git a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/Model.kt b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/Model.kt index 33e52af98..85bde0513 100644 --- a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/Model.kt +++ b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/Model.kt @@ -22,14 +22,14 @@ data class FeedConfig(val groupId: String?, val feedId: String?, } val isPlaceholder = (groupId == null && feedId == null && !isStarred && !isUnread) } -class FeedSelection(val name: String, val feedConfig: FeedConfig) +class FeedSelection(val name: String, val icon: String?, val feedConfig: FeedConfig) class Model(workManager: WorkManager) { val isSyncing = workManager.getWorkInfosByTagLiveData(SyncWorker.WORK_NAME) .asFlow().map { it.any { workInfo -> workInfo.state == WorkInfo.State.RUNNING } } - var feed = MutableStateFlow(FeedSelection(L.UNREAD, FeedConfig.UNREAD)) + var feed = MutableStateFlow(FeedSelection(L.UNREAD, null, FeedConfig.UNREAD)) var articles = MutableStateFlow(emptyList()) var articleIndex = MutableStateFlow(-1) val article = articles.combine(articleIndex) { articles, index -> diff --git a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/views/FeedView.kt b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/views/FeedView.kt index e7a76973c..4eb339bd9 100644 --- a/app/src/gestalt/java/io/bimmergestalt/reader/carapp/views/FeedView.kt +++ b/app/src/gestalt/java/io/bimmergestalt/reader/carapp/views/FeedView.kt @@ -1,26 +1,32 @@ package io.bimmergestalt.reader.carapp.views +import android.util.Log +import de.bmw.idrive.BMWRemoting import io.bimmergestalt.idriveconnectkit.rhmi.RHMIActionListCallback import io.bimmergestalt.idriveconnectkit.rhmi.RHMIComponent import io.bimmergestalt.idriveconnectkit.rhmi.RHMIModel import io.bimmergestalt.idriveconnectkit.rhmi.RHMIProperty import io.bimmergestalt.idriveconnectkit.rhmi.RHMIState +import io.bimmergestalt.reader.GraphicsUtils import io.bimmergestalt.reader.L +import io.bimmergestalt.reader.Utils.par import io.bimmergestalt.reader.carapp.FeedConfig import io.bimmergestalt.reader.carapp.FeedSelection import io.bimmergestalt.reader.carapp.Model import io.bimmergestalt.reader.carapp.RHMIActionAbort +import io.bimmergestalt.reader.carapp.TAG import kotlinx.coroutines.flow.collectLatest import me.ash.reader.domain.service.RssService -class FeedView(state: RHMIState, val rssService: RssService, val model: Model): OnFocusedView(state) { +class FeedView(state: RHMIState, val rssService: RssService, val model: Model, val graphicsUtils: GraphicsUtils): OnFocusedView(state) { + val iconSize = 32 val feedList = state.componentsList.filterIsInstance().first() var feedOptions = emptyList() fun initWidgets() { - feedList.setProperty(RHMIProperty.PropertyId.LIST_COLUMNWIDTH, "32,*") - feedList.getModel()?.value = RHMIModel.RaListModel.RHMIListConcrete(2).apply { - addRow(arrayOf("", L.UNREAD)) + feedList.setProperty(RHMIProperty.PropertyId.LIST_COLUMNWIDTH, "32,${iconSize},*") + feedList.getModel()?.value = RHMIModel.RaListModel.RHMIListConcrete(3).apply { + addRow(arrayOf("", "", L.UNREAD)) } feedList.getAction()?.asRAAction()?.rhmiActionCallback = RHMIActionListCallback { i -> val option = feedOptions.getOrNull(i) ?: throw RHMIActionAbort() @@ -44,28 +50,45 @@ class FeedView(state: RHMIState, val rssService: RssService, val model: Model): .sortedBy { it.name } val feedOptions = ArrayList(groups.size + feeds.size + 3) - feedOptions.add(FeedSelection(L.UNREAD, FeedConfig.UNREAD)) - feedOptions.add(FeedSelection(L.STARRED, FeedConfig.STARRED)) + feedOptions.add(FeedSelection(L.UNREAD, null, FeedConfig.UNREAD)) + feedOptions.add(FeedSelection(L.STARRED, null, FeedConfig.STARRED)) feedOptions.addAll(groups.map { - FeedSelection(it.name, FeedConfig.GROUP(it.id)) + FeedSelection(it.name, null, FeedConfig.GROUP(it.id)) }) - feedOptions.add(FeedSelection(L.FEEDS, FeedConfig.PLACEHOLDER)) + feedOptions.add(FeedSelection(L.FEEDS, null, FeedConfig.PLACEHOLDER)) feedOptions.addAll(feeds.map { - // TODO parse the url from it.icon like FeedIcon, which might be a base64 data - FeedSelection(it.name, FeedConfig.FEED(it.id)) + FeedSelection(it.name, it.icon, FeedConfig.FEED(it.id)) }) - feedList.getModel()?.value = object: RHMIModel.RaListModel.RHMIListAdapter(2, feedOptions) { + feedList.getModel()?.value = object: RHMIModel.RaListModel.RHMIListAdapter(3, feedOptions) { override fun convertRow(index: Int, item: FeedSelection): Array { return if (item.feedConfig.isPlaceholder) { - arrayOf("-", item.name) + arrayOf("-", "", item.name) } else { - arrayOf("", item.name) + arrayOf("", "", item.name) } } } this.feedOptions = feedOptions feedList.setProperty(RHMIProperty.PropertyId.LABEL_WAITINGANIMATION, false) + + // now show the icons + feedList.getModel()?.value = RHMIModel.RaListModel.RHMIListConcrete(3).apply { + val rows: List> = feedOptions.par.map { item -> + val heading = if (item.feedConfig.isPlaceholder) "-" else "" + val icon = graphicsUtils.loadImageUri(item.icon, iconSize, iconSize)?.let { + graphicsUtils.resizeDrawable(it, iconSize, iconSize) + }?.let { + graphicsUtils.compressBitmapJpg(it, 85) + }?.let { + BMWRemoting.RHMIResourceData(BMWRemoting.RHMIResourceType.IMAGEDATA, it) + } ?: "" + arrayOf(heading, icon, item.name) + } + rows.forEach { + addRow(it) + } + } } } } \ No newline at end of file