From db04cdddb7da23662d2a8fe0ff29b655b668b78f Mon Sep 17 00:00:00 2001 From: Shinokuni Date: Sun, 17 Nov 2024 19:50:36 +0100 Subject: [PATCH] Display feed image in FeedBottomSheet when available --- .../api/localfeed/atom/ATOMFeedAdapter.kt | 3 +- .../api/localfeed/json/JSONFeedAdapter.kt | 11 +- .../api/localfeed/rss1/RSS1FeedAdapter.kt | 12 +- .../api/localfeed/rss2/RSS2FeedAdapter.kt | 16 ++- .../api/localfeed/atom/ATOMAdapterTest.kt | 1 + .../api/localfeed/json/JSONFeedAdapterTest.kt | 1 + .../api/localfeed/rss1/RSS1AdapterTest.kt | 1 + .../api/localfeed/rss2/RSS2AdapterTest.kt | 1 + .../resources/localfeed/atom/atom_items.xml | 1 + .../resources/localfeed/json/json_feed.json | 1 + .../resources/localfeed/rss2/rss_feed.xml | 5 + .../app/feeds/dialogs/FeedBottomSheet.kt | 103 ++++++++++++------ .../app/repositories/GetFoldersWithFeeds.kt | 1 + db/schemas/com.readrops.db.Database/5.json | 12 +- db/src/main/java/com/readrops/db/Database.kt | 3 + .../java/com/readrops/db/entities/Feed.kt | 1 + .../com/readrops/db/pojo/FeedWithFolder.kt | 1 + .../db/queries/FoldersAndFeedsQueryBuilder.kt | 1 + gradle/libs.versions.toml | 3 +- 19 files changed, 133 insertions(+), 45 deletions(-) diff --git a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt index 3f2d63ae..ff425d7b 100644 --- a/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/atom/ATOMFeedAdapter.kt @@ -28,6 +28,7 @@ class ATOMFeedAdapter : XmlAdapter>> { "title" -> name = nonNullText() "link" -> parseLink(this@allChildrenAutoIgnore, feed) "subtitle" -> description = nullableText() + "logo" -> imageUrl = nullableText() "entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore) else -> skipContents() } @@ -52,6 +53,6 @@ class ATOMFeedAdapter : XmlAdapter>> { } companion object { - val names = Names.of("title", "link", "subtitle", "entry") + val names = Names.of("title", "link", "subtitle", "logo", "entry") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt index e8f50572..4e89efb9 100644 --- a/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/json/JSONFeedAdapter.kt @@ -5,7 +5,9 @@ import com.readrops.api.utils.extensions.nextNonEmptyString import com.readrops.api.utils.extensions.nextNullableString import com.readrops.db.entities.Feed import com.readrops.db.entities.Item -import com.squareup.moshi.* +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter class JSONFeedAdapter : JsonAdapter>>() { @@ -27,8 +29,9 @@ class JSONFeedAdapter : JsonAdapter>>() { 0 -> name = reader.nextNonEmptyString() 1 -> siteUrl = reader.nextNullableString() 2 -> url = reader.nextNullableString() - 3 -> description = reader.nextNullableString() - 4 -> items += itemAdapter.fromJson(reader) + 3 -> imageUrl = reader.nextNullableString() + 4 -> description = reader.nextNullableString() + 5 -> items += itemAdapter.fromJson(reader) else -> reader.skipValue() } } @@ -42,6 +45,6 @@ class JSONFeedAdapter : JsonAdapter>>() { companion object { val names: JsonReader.Options = JsonReader.Options.of("title", "home_page_url", - "feed_url", "description", "items") + "feed_url", "icon", "description", "items") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt index ef0beff7..c47e2448 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss1/RSS1FeedAdapter.kt @@ -40,8 +40,10 @@ class RSS1FeedAdapter : XmlAdapter>> { } private fun parseChannel(konsumer: Konsumer, feed: Feed) = with(konsumer) { - feed.url = attributes.getValueOrNull("about", - namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#") + feed.url = attributes.getValueOrNull( + localName = "about", + namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + ) allChildrenAutoIgnore(names) { with(feed) { @@ -49,12 +51,16 @@ class RSS1FeedAdapter : XmlAdapter>> { "title" -> name = nonNullText() "link" -> siteUrl = nonNullText() "description" -> description = nullableText() + "image" -> imageUrl = attributes.getValueOrNull( + localName = "resource", + namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + ) } } } } companion object { - val names = Names.of("title", "link", "description") + val names = Names.of("title", "link", "description", "image") } } \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt index f6845bf9..1ffe6a18 100644 --- a/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt +++ b/api/src/main/java/com/readrops/api/localfeed/rss2/RSS2FeedAdapter.kt @@ -35,6 +35,7 @@ class RSS2FeedAdapter : XmlAdapter>> { url = attributes.getValueOrNull("href") } "item" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore) + "image" -> imageUrl = parseImage(this@allChildrenAutoIgnore) else -> skipContents() } } @@ -49,7 +50,20 @@ class RSS2FeedAdapter : XmlAdapter>> { } } + private fun parseImage(konsumer: Konsumer): String? = with(konsumer) { + var url: String? = null + + allChildrenAutoIgnore(Names.of("url")) { + when (tagName) { + "url" -> url = nullableText() + else -> skipContents() + } + } + + url + } + companion object { - val names = Names.of("title", "description", "link", "item") + val names = Names.of("title", "description", "link", "item", "image") } } \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt index 18ee69c9..e53c17ed 100644 --- a/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/atom/ATOMAdapterTest.kt @@ -27,6 +27,7 @@ class ATOMAdapterTest { assertEquals(url, "https://github.com/readrops/Readrops/commits/develop.atom") assertEquals(siteUrl, "https://github.com/readrops/Readrops/commits/develop") assertEquals(description, "Here is a subtitle") + assertEquals(imageUrl, "https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png") } with(items[0]) { diff --git a/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt index 9280fa83..81ea547b 100644 --- a/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/json/JSONFeedAdapterTest.kt @@ -35,6 +35,7 @@ class JSONFeedAdapterTest { assertEquals(url, "http://flyingmeat.com/blog/feed.json") assertEquals(siteUrl, "http://flyingmeat.com/blog/") assertEquals(description, "News from your friends at Flying Meat.") + assertEquals(imageUrl, "https://secure.flyingmeat.com/favicon.ico") } with(items[0]) { diff --git a/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt index 386fbff6..1c041299 100644 --- a/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/rss1/RSS1AdapterTest.kt @@ -28,6 +28,7 @@ class RSS1AdapterTest { assertEquals(url, "https://slashdot.org/") assertEquals(siteUrl, "https://slashdot.org/") assertEquals(description, "News for nerds, stuff that matters") + assertEquals(imageUrl, "https://a.fsdn.com/sd/topics/topicslashdot.gif") } with(items[0]) { diff --git a/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt b/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt index 7e71d15e..83a6b4d2 100644 --- a/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt +++ b/api/src/test/java/com/readrops/api/localfeed/rss2/RSS2AdapterTest.kt @@ -27,6 +27,7 @@ class RSS2AdapterTest { assertEquals(url, "https://news.ycombinator.com/feed/") assertEquals(siteUrl, "https://news.ycombinator.com/") assertEquals(description, "Links for the intellectually curious, ranked by readers.") + assertEquals(imageUrl, "https://news.ycombinator.com/y18.svg") } with(items[0]) { diff --git a/api/src/test/resources/localfeed/atom/atom_items.xml b/api/src/test/resources/localfeed/atom/atom_items.xml index 680f6f97..4ca0b32e 100644 --- a/api/src/test/resources/localfeed/atom/atom_items.xml +++ b/api/src/test/resources/localfeed/atom/atom_items.xml @@ -6,6 +6,7 @@ Recent Commits to Readrops:develop 2020-09-06T21:09:59Z Here is a subtitle + https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac diff --git a/api/src/test/resources/localfeed/json/json_feed.json b/api/src/test/resources/localfeed/json/json_feed.json index 4e5b032f..12babcad 100644 --- a/api/src/test/resources/localfeed/json/json_feed.json +++ b/api/src/test/resources/localfeed/json/json_feed.json @@ -4,6 +4,7 @@ "home_page_url": "http://flyingmeat.com/blog/", "feed_url": "http://flyingmeat.com/blog/feed.json", "description": "News from your friends at Flying Meat.", + "icon": "https://secure.flyingmeat.com/favicon.ico", "author": { "name": "Gus Mueller" }, diff --git a/api/src/test/resources/localfeed/rss2/rss_feed.xml b/api/src/test/resources/localfeed/rss2/rss_feed.xml index c4702aae..d3d578ed 100644 --- a/api/src/test/resources/localfeed/rss2/rss_feed.xml +++ b/api/src/test/resources/localfeed/rss2/rss_feed.xml @@ -6,6 +6,11 @@ https://news.ycombinator.com/ Links for the intellectually curious, ranked by readers. + + Hacker News + https://news.ycombinator.com/y18.svg + https://news.ycombinator.com/ + Africa declared free of wild polio https://www.bbc.com/news/world-africa-53887947 diff --git a/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt index 812f00b6..e8df0724 100644 --- a/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt +++ b/app/src/main/java/com/readrops/app/feeds/dialogs/FeedBottomSheet.kt @@ -20,11 +20,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.readrops.app.R import com.readrops.app.util.theme.LargeSpacer @@ -46,53 +51,87 @@ fun FeedModalBottomSheet( canDeleteFeed: Boolean ) { ModalBottomSheet( + dragHandle = null, onDismissRequest = { onDismissRequest() } ) { Column { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding( - horizontal = MaterialTheme.spacing.largeSpacing - ) + Box( + modifier = Modifier.fillMaxWidth() ) { - AsyncImage( - model = feed.iconUrl, - contentDescription = feed.name!!, - placeholder = painterResource(id = R.drawable.ic_rss_feed_grey), - error = painterResource(id = R.drawable.ic_rss_feed_grey), - modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing) - ) + if (feed.imageUrl != null) { + AsyncImage( + model = feed.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .matchParentSize() + .drawWithContent { + drawContent() + drawRect( + color = Color.Black.copy(alpha = 0.65f) + ) + } + .blur(2.5.dp) + ) + } - MediumSpacer() - - Column { - Text( - text = feed.name!!, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + top = MaterialTheme.spacing.largeSpacing, + start = MaterialTheme.spacing.largeSpacing, + end = MaterialTheme.spacing.largeSpacing, + bottom = MaterialTheme.spacing.mediumSpacing + ) + ) { + AsyncImage( + model = feed.iconUrl, + contentDescription = feed.name!!, + placeholder = painterResource(id = R.drawable.ic_rss_feed_grey), + error = painterResource(id = R.drawable.ic_rss_feed_grey), + modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing) ) - if (feed.description != null) { - VeryShortSpacer() + MediumSpacer() + Column { Text( - text = feed.description!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, + text = feed.name!!, + style = MaterialTheme.typography.titleLarge, + color = if (feed.imageUrl != null) { + Color.White + } else { + MaterialTheme.colorScheme.onBackground + }, + maxLines = 1, overflow = TextOverflow.Ellipsis ) + + if (feed.description != null) { + VeryShortSpacer() + + Text( + text = feed.description!!, + style = MaterialTheme.typography.bodyMedium, + color = if (feed.imageUrl != null) { + Color.White + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } } - MediumSpacer() - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing) - ) + if (feed.imageUrl == null) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing) + ) + } MediumSpacer() diff --git a/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt b/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt index 4127e2ec..01c1c4c5 100644 --- a/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt +++ b/app/src/main/java/com/readrops/app/repositories/GetFoldersWithFeeds.kt @@ -44,6 +44,7 @@ class GetFoldersWithFeeds( id = it.feedId, name = it.feedName, iconUrl = it.feedIcon, + imageUrl = it.feedImage, url = it.feedUrl, siteUrl = it.feedSiteUrl, description = it.feedDescription, diff --git a/db/schemas/com.readrops.db.Database/5.json b/db/schemas/com.readrops.db.Database/5.json index fb815bae..fd47e112 100644 --- a/db/schemas/com.readrops.db.Database/5.json +++ b/db/schemas/com.readrops.db.Database/5.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 5, - "identityHash": "0bac941f8b1b6003c35a6d0cdc1f2e13", + "identityHash": "63de09bfed367e5705a0889d928f056d", "entities": [ { "tableName": "Feed", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `description` TEXT, `url` TEXT, `siteUrl` TEXT, `last_updated` TEXT, `color` INTEGER NOT NULL, `icon_url` TEXT, `etag` TEXT, `last_modified` TEXT, `folder_id` INTEGER, `remote_id` TEXT, `account_id` INTEGER NOT NULL, `notification_enabled` INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(`folder_id`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `description` TEXT, `url` TEXT, `image_url` TEXT, `siteUrl` TEXT, `last_updated` TEXT, `color` INTEGER NOT NULL, `icon_url` TEXT, `etag` TEXT, `last_modified` TEXT, `folder_id` INTEGER, `remote_id` TEXT, `account_id` INTEGER NOT NULL, `notification_enabled` INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(`folder_id`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -32,6 +32,12 @@ "affinity": "TEXT", "notNull": false }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "siteUrl", "columnName": "siteUrl", @@ -520,7 +526,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0bac941f8b1b6003c35a6d0cdc1f2e13')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '63de09bfed367e5705a0889d928f056d')" ] } } \ No newline at end of file diff --git a/db/src/main/java/com/readrops/db/Database.kt b/db/src/main/java/com/readrops/db/Database.kt index 1d69f123..e98f2934 100644 --- a/db/src/main/java/com/readrops/db/Database.kt +++ b/db/src/main/java/com/readrops/db/Database.kt @@ -121,5 +121,8 @@ object MigrationFrom4To5 : Migration(4, 5) { db.execSQL("DROP TABLE IF EXISTS `Account`") db.execSQL("ALTER TABLE `_new_Account` RENAME TO `Account`") + + // add image_url field + db.execSQL("""ALTER TABLE `Feed` ADD `image_url` TEXT DEFAULT NULL""") } } diff --git a/db/src/main/java/com/readrops/db/entities/Feed.kt b/db/src/main/java/com/readrops/db/entities/Feed.kt index 2962e53d..fd2e0173 100644 --- a/db/src/main/java/com/readrops/db/entities/Feed.kt +++ b/db/src/main/java/com/readrops/db/entities/Feed.kt @@ -28,6 +28,7 @@ data class Feed( var name: String? = null, var description: String? = null, var url: String? = null, + @ColumnInfo("image_url") var imageUrl: String? = null, var siteUrl: String? = null, @ColumnInfo("last_updated") var lastUpdated: String? = null, @ColorInt var color: Int = 0, diff --git a/db/src/main/java/com/readrops/db/pojo/FeedWithFolder.kt b/db/src/main/java/com/readrops/db/pojo/FeedWithFolder.kt index 320b05fd..4f7b9d2f 100644 --- a/db/src/main/java/com/readrops/db/pojo/FeedWithFolder.kt +++ b/db/src/main/java/com/readrops/db/pojo/FeedWithFolder.kt @@ -16,6 +16,7 @@ data class FolderWithFeed( val feedId: Int = 0, val feedName: String? = null, val feedIcon: String? = null, + val feedImage: String? = null, val feedUrl: String? = null, val feedDescription: String? = null, val feedSiteUrl: String? = null, diff --git a/db/src/main/java/com/readrops/db/queries/FoldersAndFeedsQueryBuilder.kt b/db/src/main/java/com/readrops/db/queries/FoldersAndFeedsQueryBuilder.kt index d5a65f82..3e2bf7e2 100644 --- a/db/src/main/java/com/readrops/db/queries/FoldersAndFeedsQueryBuilder.kt +++ b/db/src/main/java/com/readrops/db/queries/FoldersAndFeedsQueryBuilder.kt @@ -11,6 +11,7 @@ object FoldersAndFeedsQueryBuilder { "Feed.name As feedName", "Feed.icon_url As feedIcon", "Feed.url As feedUrl", + "Feed.image_url as feedImage", "Feed.siteUrl As feedSiteUrl", "Feed.description as feedDescription", "Feed.remote_id as feedRemoteId", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8be36f3..1e7bdeec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ lifecyle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-comp coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-http = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } @@ -112,7 +113,7 @@ compose = ["compose-foundation", "compose-runtime", "compose-animation", voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-koin", "voyager-transitions"] lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycle-viewmodel-savedstate", "lifecyle-runtime-compose"] -coil = ["coil-compose", "coil-http"] +coil = ["coil-compose", "coil-http", "coil-svg"] coroutines = ["coroutines-core", "coroutines-android"] room = ["room-runtime", "room-ktx", "room-paging"] koin = ["koin-core", "koin-android", "koin-androidx-compose"]