feat(plugin_discovery): implement GitHub plugin discovery and add logo support and information dialog in UI

This commit is contained in:
Kingkor Roy Tirtho 2026-07-25 22:06:13 +06:00
parent ec672d5f84
commit 160f1f318f
7 changed files with 421 additions and 45 deletions

View File

@ -27,7 +27,11 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
PluginCapability.NETWORK_REQUESTS,
PluginCapability.PERSISTENT_STORAGE
),
abilities = listOf(PluginAbility.AUDIO)
abilities = listOf(PluginAbility.AUDIO),
contact = "",
repository = "",
bugs = "",
license = "",
)
val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
name = "LRCLib Lyrics",
@ -38,7 +42,11 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
capabilities = listOf(
PluginCapability.NETWORK_REQUESTS,
),
abilities = listOf(PluginAbility.LYRICS)
abilities = listOf(PluginAbility.LYRICS),
contact = "",
repository = "",
bugs = "",
license = "",
)
val BUILT_IN_PLUGINS = listOf(
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,

View File

@ -61,9 +61,9 @@ class PluginDiscoverViewModel(
viewModelScope.launch {
pluginManager.state.collect { pluginState ->
if (pluginState is PluginManagerStates.Data) {
val installedAuthors = pluginState.plugins.map { it.author }.toSet()
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
_state.update {
it.copy(repos = _allRepos.filter { repo -> repo.owner.login !in installedAuthors })
it.copy(repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls })
}
}
}
@ -74,8 +74,8 @@ class PluginDiscoverViewModel(
private fun filterInstalled(repos: List<GitHubRepo>): List<GitHubRepo> {
val pluginState = pluginManager.state.value
if (pluginState !is PluginManagerStates.Data) return repos
val installedAuthors = pluginState.plugins.map { it.author }.toSet()
return repos.filter { it.owner.login !in installedAuthors }
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
return repos.filter { it.htmlUrl !in installedUrls }
}
private fun loadFirstPage() {

View File

@ -325,6 +325,15 @@ class PluginManager(
throw IllegalArgumentException("Invalid plugin.json format: ${e.message}")
}
// Preserve logo.png before deleting temp dir so the permission dialog can show it
val logoPngPath = tempDir / "logo.png".toPath()
if (FileSystem.SYSTEM.exists(logoPngPath)) {
val logoDir = pluginsDirPath / pluginEntry.id.toPath()
if (!FileSystem.SYSTEM.exists(logoDir)) FileSystem.SYSTEM.createDirectories(logoDir)
val destLogo = logoDir / "logo.png".toPath()
FileSystem.SYSTEM.copy(logoPngPath, destLogo)
}
pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes)
} catch (e: Exception) {
throw Exception("Failed to read plugin: ${e.message}", e)

View File

@ -51,7 +51,11 @@ data class PluginEntry(
val description: String,
val author: String,
val capabilities: List<PluginCapability>,
val abilities: List<PluginAbility>
val abilities: List<PluginAbility>,
val contact: String,
val repository: String,
val bugs: String,
val license: String,
) {
@Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD")
val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray())

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.modules.plugin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -30,6 +31,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@ -41,6 +44,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -50,16 +54,24 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.extras.kebabToTitleCase
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
@ -67,21 +79,21 @@ import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.openUrlInBrowser
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.openUrlInBrowser
import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
@ -92,20 +104,9 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import net.swiftzer.semver.SemVer
import okio.FileSystem
import okio.Path.Companion.toPath
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.platform.LocalDensity
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import dev.krtirtho.spotube.core.extras.kebabToTitleCase
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
import okio.SYSTEM
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
@ -164,6 +165,11 @@ fun PluginScreen(
val discoverViewModel: PluginDiscoverViewModel = koinViewModel()
val discoverState by discoverViewModel.state.collectAsStateWithLifecycle()
var showPluginInfo by remember { mutableStateOf<PluginEntry?>(null) }
var showPluginSupport by remember { mutableStateOf<PluginEntry?>(null) }
var supportText by remember { mutableStateOf<String?>(null) }
var isLoadingSupport by remember { mutableStateOf(false) }
val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
val downloadFailed = stringResource(Res.string.plugin_error_download_failed)
@ -203,9 +209,8 @@ fun PluginScreen(
}
pendingPlugin?.let { pending ->
val logoPath = remember(pending.existingEntry?.id) {
val existingId = pending.existingEntry?.id ?: return@remember null
val path = pluginManager.pluginsDirPath / existingId.toPath() / "logo.png".toPath()
val logoPath = remember(pending.entry.id) {
val path = pluginManager.pluginsDirPath / pending.entry.id.toPath() / "logo.png".toPath()
if (FileSystem.SYSTEM.exists(path)) path else null
}
PluginPermissionDialog(
@ -224,6 +229,144 @@ fun PluginScreen(
)
}
showPluginInfo?.let { plugin ->
val logoPath = remember(plugin.id) {
val path = pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath()
if (FileSystem.SYSTEM.exists(path)) path else null
}
ThemedDialog(
onDismissRequest = { showPluginInfo = null },
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp)),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
) {
if (logoPath != null) {
val platformContext = LocalPlatformContext.current
AsyncImage(
model = ImageRequest.Builder(platformContext)
.data(logoPath.toString())
.crossfade(true)
.build(),
contentDescription = plugin.name,
modifier = Modifier.fillMaxSize()
)
} else {
Box(contentAlignment = Alignment.Center) {
Icon(
Iconsax.IconsaxBox,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
Text(
plugin.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
},
actions = {
PrimaryButton(onClick = { showPluginInfo = null }) {
Text("Close")
}
}
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
if (plugin.description.isNotBlank()) {
Text(
plugin.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
DetailRow("Version", plugin.version)
DetailRow("API Version", plugin.apiVersion)
DetailRow("Author", plugin.author)
if (plugin.license.isNotBlank()) {
DetailRow("License", plugin.license)
}
if (plugin.capabilities.isNotEmpty()) {
DetailChipsRow("Capabilities", plugin.capabilities.map { it.name })
}
if (plugin.abilities.isNotEmpty()) {
DetailChipsRow("Abilities", plugin.abilities.map { ability ->
when (ability) {
PluginAbility.METADATA -> "Metadata"
PluginAbility.AUDIO -> "Audio"
PluginAbility.LYRICS -> "Lyrics"
PluginAbility.SCROBBLE -> "Scrobble"
}
})
}
if (plugin.repository.isNotBlank()) {
ClickableDetailRow(label = "Repository", value = plugin.repository) {
openUrlInBrowser(plugin.repository)
}
}
if (plugin.contact.isNotBlank()) {
DetailRow("Contact", plugin.contact)
}
if (plugin.bugs.isNotBlank()) {
ClickableDetailRow(label = "Report Bugs", value = plugin.bugs) {
openUrlInBrowser(plugin.bugs)
}
}
}
}
}
showPluginSupport?.let { plugin ->
ThemedDialog(
onDismissRequest = { showPluginSupport = null; supportText = null },
title = {
Text(
"Support ${plugin.name}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
},
actions = {
PrimaryButton(onClick = { showPluginSupport = null; supportText = null }) {
Text("Close")
}
}
) {
if (isLoadingSupport) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Text(
supportText ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
if (showInstallSheet) {
AdaptiveDialogBottomSheet(
onDismiss = { showInstallSheet = false },
@ -539,6 +682,23 @@ fun PluginScreen(
},
isLoggedIn = isLoggedIn,
logoPath = logoPath,
onInfo = { showPluginInfo = plugin },
onSupport = if (selectedService != null) {
{
isLoadingSupport = true
scope.launch {
showPluginSupport = plugin
val version = SemVer.parse(plugin.version)
selectedService.use {
supportText =
coreAPI.supportMarkdownText(version)
}
isLoadingSupport = false
}
}
} else {
null
},
onLogin = if (requiresAuth && selectedService != null) {
{
pluginManager.launchTask {
@ -823,6 +983,78 @@ fun PluginScreen(
}
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"$label:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.widthIn(min = 90.dp)
)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"$label:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.widthIn(min = 90.dp)
)
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun DetailChipsRow(label: String, chips: List<String>) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"$label:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.widthIn(min = 90.dp)
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
chips.forEach { chip ->
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f)
) {
Text(
chip,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}
@Composable
fun DefaultAbilityPluginSelector(
ability: PluginAbility,

View File

@ -52,6 +52,8 @@ import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxTag
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.User
@ -76,6 +78,8 @@ internal fun PluginCard(
isLoggedIn: Boolean,
onLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null,
onInfo: (() -> Unit)? = null,
onSupport: (() -> Unit)? = null,
logoPath: Path? = null,
) {
Row(
@ -234,28 +238,50 @@ internal fun PluginCard(
Column(
modifier = Modifier.align(Alignment.Bottom),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (plugin in BUILT_IN_PLUGINS) {
// Built-in plugins cannot be removed
Text(
stringResource(Res.string.plugin_state_builtin),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(6.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (onInfo != null) {
GhostIconButton(onClick = onInfo) {
Icon(
Iconsax.IconsaxInformation,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
} else {
GhostIconButton(onClick = onRemove) {
Icon(
Iconsax.IconsaxTrash,
contentDescription = stringResource(Res.string.plugin_action_remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
}
}
if (onSupport != null) {
GhostIconButton(onClick = onSupport) {
Icon(
Iconsax.IconsaxHeart,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (plugin in BUILT_IN_PLUGINS) {
Text(
stringResource(Res.string.plugin_state_builtin),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(6.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
} else {
GhostIconButton(onClick = onRemove) {
Icon(
Iconsax.IconsaxTrash,
contentDescription = stringResource(Res.string.plugin_action_remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@ -0,0 +1,97 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxInformation: ImageVector
get() {
if (_IconsaxInformation != null) {
return _IconsaxInformation!!
}
_IconsaxInformation = ImageVector.Builder(
name = "IconsaxInformation",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(10.75f, 2.45f)
curveTo(11.45f, 1.86f, 12.58f, 1.86f, 13.26f, 2.45f)
lineTo(14.84f, 3.8f)
curveTo(15.14f, 4.05f, 15.71f, 4.26f, 16.11f, 4.26f)
horizontalLineTo(17.81f)
curveTo(18.87f, 4.26f, 19.74f, 5.13f, 19.74f, 6.19f)
verticalLineTo(7.89f)
curveTo(19.74f, 8.29f, 19.95f, 8.85f, 20.2f, 9.15f)
lineTo(21.55f, 10.73f)
curveTo(22.14f, 11.43f, 22.14f, 12.56f, 21.55f, 13.24f)
lineTo(20.2f, 14.82f)
curveTo(19.95f, 15.12f, 19.74f, 15.68f, 19.74f, 16.08f)
verticalLineTo(17.78f)
curveTo(19.74f, 18.84f, 18.87f, 19.71f, 17.81f, 19.71f)
horizontalLineTo(16.11f)
curveTo(15.71f, 19.71f, 15.15f, 19.92f, 14.85f, 20.17f)
lineTo(13.27f, 21.52f)
curveTo(12.57f, 22.11f, 11.44f, 22.11f, 10.76f, 21.52f)
lineTo(9.18f, 20.17f)
curveTo(8.88f, 19.92f, 8.31f, 19.71f, 7.92f, 19.71f)
horizontalLineTo(6.17f)
curveTo(5.11f, 19.71f, 4.24f, 18.84f, 4.24f, 17.78f)
verticalLineTo(16.07f)
curveTo(4.24f, 15.68f, 4.04f, 15.11f, 3.79f, 14.82f)
lineTo(2.44f, 13.23f)
curveTo(1.86f, 12.54f, 1.86f, 11.42f, 2.44f, 10.73f)
lineTo(3.79f, 9.14f)
curveTo(4.04f, 8.84f, 4.24f, 8.28f, 4.24f, 7.89f)
verticalLineTo(6.2f)
curveTo(4.24f, 5.14f, 5.11f, 4.27f, 6.17f, 4.27f)
horizontalLineTo(7.9f)
curveTo(8.3f, 4.27f, 8.86f, 4.06f, 9.16f, 3.81f)
lineTo(10.75f, 2.45f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 16.869f)
curveTo(11.45f, 16.869f, 11f, 16.419f, 11f, 15.869f)
curveTo(11f, 15.319f, 11.44f, 14.869f, 12f, 14.869f)
curveTo(12.55f, 14.869f, 13f, 15.319f, 13f, 15.869f)
curveTo(13f, 16.419f, 12.56f, 16.869f, 12f, 16.869f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 13.721f)
curveTo(11.59f, 13.721f, 11.25f, 13.381f, 11.25f, 12.971f)
verticalLineTo(8.131f)
curveTo(11.25f, 7.721f, 11.59f, 7.381f, 12f, 7.381f)
curveTo(12.41f, 7.381f, 12.75f, 7.721f, 12.75f, 8.131f)
verticalLineTo(12.961f)
curveTo(12.75f, 13.381f, 12.42f, 13.721f, 12f, 13.721f)
close()
}
}
}.build()
return _IconsaxInformation!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxInformation: ImageVector? = null