From fdcd3aa7eb415d3ad17177dad9ece5ccdf1ce28e Mon Sep 17 00:00:00 2001
From: Andreas <andreas.everos@gmail.com>
Date: Sun, 15 May 2022 15:59:53 +0200
Subject: [PATCH] Convert Extension tab to use Compose (#7107)

* Convert Extension tab to use Compose

Co-authored-by: jobobby04 <17078382+jobobby04@users.noreply.github.com>

* Review changes

Co-authored-by: jobobby04 <17078382+jobobby04@users.noreply.github.com>
(cherry picked from commit 3e2d7d76b9b0fb1156d4dfaa01f4176d801089ce)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
---
 app/build.gradle.kts                          |   1 +
 .../eu/kanade/core/util/RxJavaExtensions.kt   |  25 +
 .../java/eu/kanade/domain/DomainModule.kt     |   5 +
 .../interactor/GetExtensionUpdates.kt         |  25 +
 .../extension/interactor/GetExtensions.kt     |  48 ++
 .../browse/components/BaseBrowseItem.kt       |  35 ++
 .../browse/components/BrowseIcons.kt          |  91 ++++
 .../presentation/extension/ExtensionScreen.kt | 453 ++++++++++++++++++
 .../presentation/source/SourceScreen.kt       |  27 --
 .../source/components/BaseSourceItem.kt       |  28 +-
 .../ui/browse/extension/ExtensionAdapter.kt   |  27 --
 .../browse/extension/ExtensionController.kt   | 212 ++------
 .../browse/extension/ExtensionGroupHolder.kt  |  27 --
 .../ui/browse/extension/ExtensionGroupItem.kt |  62 ---
 .../ui/browse/extension/ExtensionHolder.kt    | 116 -----
 .../ui/browse/extension/ExtensionItem.kt      |  65 ---
 .../ui/browse/extension/ExtensionPresenter.kt | 271 ++++++-----
 .../browse/extension/ExtensionTrustDialog.kt  |  43 --
 .../ui/browse/extension/ExtensionViewUtils.kt |  32 ++
 .../main/res/layout/extension_controller.xml  |  32 --
 app/src/main/res/layout/extension_item.xml    |  98 ----
 .../res/mipmap-hdpi/ic_untrusted_source.png   | Bin 0 -> 2124 bytes
 .../res/mipmap-mdpi/ic_untrusted_source.png   | Bin 0 -> 1102 bytes
 .../res/mipmap-xhdpi/ic_untrusted_source.png  | Bin 0 -> 2599 bytes
 .../res/mipmap-xxhdpi/ic_untrusted_source.png | Bin 0 -> 5065 bytes
 .../mipmap-xxxhdpi/ic_untrusted_source.png    | Bin 0 -> 7096 bytes
 gradle/compose.versions.toml                  |   3 +-
 27 files changed, 938 insertions(+), 788 deletions(-)
 create mode 100644 app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt
 create mode 100644 app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt
 create mode 100644 app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt
 create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt
 create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt
 create mode 100644 app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt
 delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
 delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt
 delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt
 delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
 delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
 delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt
 delete mode 100644 app/src/main/res/layout/extension_controller.xml
 delete mode 100644 app/src/main/res/layout/extension_item.xml
 create mode 100644 app/src/main/res/mipmap-hdpi/ic_untrusted_source.png
 create mode 100644 app/src/main/res/mipmap-mdpi/ic_untrusted_source.png
 create mode 100644 app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png
 create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png
 create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png

diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7bcff576c..550c4b4c0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -131,6 +131,7 @@ dependencies {
     implementation(compose.animation)
     implementation(compose.ui.tooling)
     implementation(compose.accompanist.webview)
+    implementation(compose.accompanist.swiperefresh)
 
     implementation(androidx.paging.runtime)
     implementation(androidx.paging.compose)
diff --git a/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt b/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt
new file mode 100644
index 000000000..4d1ef452d
--- /dev/null
+++ b/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt
@@ -0,0 +1,25 @@
+package eu.kanade.core.util
+
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import rx.Observable
+import rx.Observer
+
+fun <T : Any> Observable<T>.asFlow(): Flow<T> = callbackFlow {
+    val observer = object : Observer<T> {
+        override fun onNext(t: T) {
+            trySend(t)
+        }
+
+        override fun onError(e: Throwable) {
+            close(e)
+        }
+
+        override fun onCompleted() {
+            close()
+        }
+    }
+    val subscription = subscribe(observer)
+    awaitClose { subscription.unsubscribe() }
+}
diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt
index 0657ce59f..8bc448163 100644
--- a/app/src/main/java/eu/kanade/domain/DomainModule.kt
+++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt
@@ -3,6 +3,8 @@ package eu.kanade.domain
 import eu.kanade.data.history.HistoryRepositoryImpl
 import eu.kanade.data.manga.MangaRepositoryImpl
 import eu.kanade.data.source.SourceRepositoryImpl
+import eu.kanade.domain.extension.interactor.GetExtensionUpdates
+import eu.kanade.domain.extension.interactor.GetExtensions
 import eu.kanade.domain.history.interactor.DeleteHistoryTable
 import eu.kanade.domain.history.interactor.GetHistory
 import eu.kanade.domain.history.interactor.GetNextChapterForManga
@@ -45,6 +47,9 @@ class DomainModule : InjektModule {
         addFactory { RemoveHistoryById(get()) }
         addFactory { RemoveHistoryByMangaId(get()) }
 
+        addFactory { GetExtensions(get(), get()) }
+        addFactory { GetExtensionUpdates(get(), get()) }
+
         addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
         addFactory { GetLanguagesWithSources(get(), get()) }
         addFactory { GetEnabledSources(get(), get()) }
diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt
new file mode 100644
index 000000000..96373f9b4
--- /dev/null
+++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt
@@ -0,0 +1,25 @@
+package eu.kanade.domain.extension.interactor
+
+import eu.kanade.core.util.asFlow
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.Extension
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class GetExtensionUpdates(
+    private val preferences: PreferencesHelper,
+    private val extensionManager: ExtensionManager,
+) {
+
+    fun subscribe(): Flow<List<Extension.Installed>> {
+        val showNsfwSources = preferences.showNsfwSource().get()
+
+        return extensionManager.getInstalledExtensionsObservable().asFlow()
+            .map { installed ->
+                installed
+                    .filter { it.hasUpdate && (showNsfwSources || it.isNsfw.not()) }
+                    .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+            }
+    }
+}
diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt
new file mode 100644
index 000000000..fbb5c1ac2
--- /dev/null
+++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt
@@ -0,0 +1,48 @@
+package eu.kanade.domain.extension.interactor
+
+import eu.kanade.core.util.asFlow
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.Extension
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+
+typealias ExtensionSegregation = Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
+
+class GetExtensions(
+    private val preferences: PreferencesHelper,
+    private val extensionManager: ExtensionManager,
+) {
+
+    fun subscribe(): Flow<ExtensionSegregation> {
+        val activeLanguages = preferences.enabledLanguages().get()
+        val showNsfwSources = preferences.showNsfwSource().get()
+
+        return combine(
+            extensionManager.getInstalledExtensionsObservable().asFlow(),
+            extensionManager.getUntrustedExtensionsObservable().asFlow(),
+            extensionManager.getAvailableExtensionsObservable().asFlow(),
+        ) { _installed, _untrusted, _available ->
+
+            val installed = _installed
+                .filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) }
+                .sortedWith(
+                    compareBy<Extension.Installed> { it.isObsolete.not() /* SY --> */ && it.isRedundant.not() /* SY <-- */ }
+                        .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
+                )
+
+            val untrusted = _untrusted
+                .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+
+            val available = _available
+                .filter { extension ->
+                    _installed.none { it.pkgName == extension.pkgName } &&
+                        _untrusted.none { it.pkgName == extension.pkgName } &&
+                        extension.lang in activeLanguages &&
+                        (showNsfwSources || extension.isNsfw.not())
+                }
+
+            Triple(installed, untrusted, available)
+        }
+    }
+}
diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt
new file mode 100644
index 000000000..2b3dd022f
--- /dev/null
+++ b/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt
@@ -0,0 +1,35 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.util.horizontalPadding
+
+@Composable
+fun BaseBrowseItem(
+    modifier: Modifier = Modifier,
+    onClickItem: () -> Unit = {},
+    onLongClickItem: () -> Unit = {},
+    icon: @Composable RowScope.() -> Unit = {},
+    action: @Composable RowScope.() -> Unit = {},
+    content: @Composable RowScope.() -> Unit = {},
+) {
+    Row(
+        modifier = modifier
+            .combinedClickable(
+                onClick = onClickItem,
+                onLongClick = onLongClickItem,
+            )
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        icon()
+        content()
+        action()
+    }
+}
diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt
new file mode 100644
index 000000000..1b0323257
--- /dev/null
+++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt
@@ -0,0 +1,91 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.res.imageResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import eu.kanade.domain.source.model.Source
+import eu.kanade.presentation.util.bitmapPainterResource
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.ui.browse.extension.Result
+import eu.kanade.tachiyomi.ui.browse.extension.getIcon
+
+private val defaultModifier = Modifier
+    .height(40.dp)
+    .aspectRatio(1f)
+
+@Composable
+fun SourceIcon(
+    source: Source,
+    modifier: Modifier = Modifier,
+) {
+    val icon = source.icon
+
+    if (icon != null) {
+        Image(
+            bitmap = icon,
+            contentDescription = "",
+            modifier = modifier.then(defaultModifier),
+        )
+    } else {
+        Image(
+            painter = painterResource(id = R.mipmap.ic_local_source),
+            contentDescription = "",
+            modifier = modifier.then(defaultModifier),
+        )
+    }
+}
+
+@Composable
+fun ExtensionIcon(
+    extension: Extension,
+    modifier: Modifier = Modifier,
+) {
+    when (extension) {
+        is Extension.Available -> {
+            AsyncImage(
+                model = extension.iconUrl,
+                contentDescription = "",
+                placeholder = ColorPainter(Color(0x1F888888)),
+                error = bitmapPainterResource(id = R.drawable.cover_error),
+                modifier = modifier
+                    .clip(RoundedCornerShape(4.dp))
+                    .then(defaultModifier),
+            )
+        }
+        is Extension.Installed -> {
+            val icon by extension.getIcon()
+            when (icon) {
+                Result.Error -> Image(
+                    bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_local_source),
+                    contentDescription = "",
+                    modifier = modifier.then(defaultModifier),
+                )
+                Result.Loading -> Box(modifier = modifier.then(defaultModifier))
+                is Result.Success -> Image(
+                    bitmap = (icon as Result.Success<ImageBitmap>).value,
+                    contentDescription = "",
+                    modifier = modifier.then(defaultModifier),
+                )
+            }
+        }
+        is Extension.Untrusted -> Image(
+            bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_untrusted_source),
+            contentDescription = "",
+            modifier = modifier.then(defaultModifier),
+        )
+    }
+}
diff --git a/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt b/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt
new file mode 100644
index 000000000..3eda4feec
--- /dev/null
+++ b/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt
@@ -0,0 +1,453 @@
+package eu.kanade.presentation.extension
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.google.accompanist.swiperefresh.SwipeRefresh
+import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
+import eu.kanade.presentation.browse.components.BaseBrowseItem
+import eu.kanade.presentation.browse.components.ExtensionIcon
+import eu.kanade.presentation.theme.header
+import eu.kanade.presentation.util.horizontalPadding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.extension.api.REPO_URL_PREFIX
+import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.extension.model.InstallStep
+import eu.kanade.tachiyomi.source.ConfigurableSource
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionPresenter
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionState
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
+import eu.kanade.tachiyomi.util.system.LocaleHelper
+import exh.source.anyIs
+
+@Composable
+fun ExtensionScreen(
+    nestedScrollInterop: NestedScrollConnection,
+    presenter: ExtensionPresenter,
+    onLongClickItem: (Extension) -> Unit,
+    onClickItemCancel: (Extension) -> Unit,
+    onInstallExtension: (Extension.Available) -> Unit,
+    onUninstallExtension: (Extension) -> Unit,
+    onUpdateExtension: (Extension.Installed) -> Unit,
+    onTrustExtension: (Extension.Untrusted) -> Unit,
+    onOpenExtension: (Extension.Installed) -> Unit,
+    onClickUpdateAll: () -> Unit,
+    onRefresh: () -> Unit,
+    onLaunched: () -> Unit,
+) {
+    val state by presenter.state.collectAsState()
+    val isRefreshing = presenter.isRefreshing
+
+    SwipeRefresh(
+        modifier = Modifier.nestedScroll(nestedScrollInterop),
+        state = rememberSwipeRefreshState(isRefreshing),
+        onRefresh = onRefresh,
+    ) {
+        when (state) {
+            is ExtensionState.Initialized -> {
+                ExtensionContent(
+                    nestedScrollInterop = nestedScrollInterop,
+                    items = (state as ExtensionState.Initialized).list,
+                    onLongClickItem = onLongClickItem,
+                    onClickItemCancel = onClickItemCancel,
+                    onInstallExtension = onInstallExtension,
+                    onUninstallExtension = onUninstallExtension,
+                    onUpdateExtension = onUpdateExtension,
+                    onTrustExtension = onTrustExtension,
+                    onOpenExtension = onOpenExtension,
+                    onClickUpdateAll = onClickUpdateAll,
+                    onLaunched = onLaunched,
+                )
+            }
+            ExtensionState.Uninitialized -> {}
+        }
+    }
+}
+
+@Composable
+fun ExtensionContent(
+    nestedScrollInterop: NestedScrollConnection,
+    items: List<ExtensionUiModel>,
+    onLongClickItem: (Extension) -> Unit,
+    onClickItemCancel: (Extension) -> Unit,
+    onInstallExtension: (Extension.Available) -> Unit,
+    onUninstallExtension: (Extension) -> Unit,
+    onUpdateExtension: (Extension.Installed) -> Unit,
+    onTrustExtension: (Extension.Untrusted) -> Unit,
+    onOpenExtension: (Extension.Installed) -> Unit,
+    onClickUpdateAll: () -> Unit,
+    onLaunched: () -> Unit,
+) {
+    val (trustState, setTrustState) = remember { mutableStateOf<Extension.Untrusted?>(null) }
+    LazyColumn(
+        contentPadding = WindowInsets.navigationBars.asPaddingValues(),
+    ) {
+        items(
+            items = items,
+            key = {
+                when (it) {
+                    is ExtensionUiModel.Header.Resource -> it.textRes
+                    is ExtensionUiModel.Header.Text -> it.text
+                    is ExtensionUiModel.Item -> it.key()
+                }
+            },
+            contentType = {
+                when (it) {
+                    is ExtensionUiModel.Item -> "item"
+                    else -> "header"
+                }
+            },
+        ) { item ->
+            when (item) {
+                is ExtensionUiModel.Header.Resource -> {
+                    val action: @Composable RowScope.() -> Unit =
+                        if (item.textRes == R.string.ext_updates_pending) {
+                            {
+                                Button(onClick = { onClickUpdateAll() }) {
+                                    Text(
+                                        text = stringResource(id = R.string.ext_update_all),
+                                        style = LocalTextStyle.current.copy(
+                                            color = MaterialTheme.colorScheme.onPrimary,
+                                        ),
+                                    )
+                                }
+                            }
+                        } else {
+                            {}
+                        }
+                    ExtensionHeader(
+                        textRes = item.textRes,
+                        modifier = Modifier.animateItemPlacement(),
+                        action = action,
+                    )
+                }
+                is ExtensionUiModel.Header.Text -> {
+                    ExtensionHeader(
+                        text = item.text,
+                        modifier = Modifier.animateItemPlacement(),
+                    )
+                }
+                is ExtensionUiModel.Item -> {
+                    ExtensionItem(
+                        modifier = Modifier.animateItemPlacement(),
+                        item = item,
+                        onClickItem = {
+                            when (it) {
+                                is Extension.Available -> onInstallExtension(it)
+                                is Extension.Installed -> {
+                                    if (it.hasUpdate) {
+                                        onUpdateExtension(it)
+                                    } else {
+                                        onOpenExtension(it)
+                                    }
+                                }
+                                is Extension.Untrusted -> setTrustState(it)
+                            }
+                        },
+                        onLongClickItem = onLongClickItem,
+                        onClickItemCancel = onClickItemCancel,
+                        onClickItemAction = {
+                            when (it) {
+                                is Extension.Available -> onInstallExtension(it)
+                                is Extension.Installed -> {
+                                    if (it.hasUpdate) {
+                                        onUpdateExtension(it)
+                                    } else {
+                                        onOpenExtension(it)
+                                    }
+                                }
+                                is Extension.Untrusted -> setTrustState(it)
+                            }
+                        },
+                    )
+                    LaunchedEffect(Unit) {
+                        onLaunched()
+                    }
+                }
+            }
+        }
+    }
+    if (trustState != null) {
+        ExtensionTrustDialog(
+            onClickConfirm = {
+                onTrustExtension(trustState)
+                setTrustState(null)
+            },
+            onClickDismiss = {
+                onUninstallExtension(trustState)
+                setTrustState(null)
+            },
+            onDismissRequest = {
+                setTrustState(null)
+            },
+        )
+    }
+}
+
+@Composable
+fun ExtensionItem(
+    modifier: Modifier = Modifier,
+    item: ExtensionUiModel.Item,
+    onClickItem: (Extension) -> Unit,
+    onLongClickItem: (Extension) -> Unit,
+    onClickItemCancel: (Extension) -> Unit,
+    onClickItemAction: (Extension) -> Unit,
+) {
+    val (extension, installStep) = item
+    BaseBrowseItem(
+        modifier = modifier
+            .combinedClickable(
+                onClick = { onClickItem(extension) },
+                onLongClick = { onLongClickItem(extension) },
+            ),
+        onClickItem = { onClickItem(extension) },
+        onLongClickItem = { onLongClickItem(extension) },
+        icon = {
+            ExtensionIcon(extension = extension)
+        },
+        action = {
+            ExtensionItemActions(
+                extension = extension,
+                installStep = installStep,
+                onClickItemCancel = onClickItemCancel,
+                onClickItemAction = onClickItemAction,
+            )
+        },
+    ) {
+        ExtensionItemContent(
+            extension = extension,
+            modifier = Modifier.weight(1f),
+        )
+    }
+}
+
+@Composable
+fun ExtensionItemContent(
+    extension: Extension,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+    val warning = remember(extension) {
+        when {
+            extension is Extension.Untrusted -> R.string.ext_untrusted
+            extension is Extension.Installed && extension.isUnofficial -> R.string.ext_unofficial
+            extension is Extension.Installed && extension.isObsolete -> R.string.ext_obsolete
+            extension is Extension.Installed && extension.isRedundant -> R.string.ext_redundant
+            extension.isNsfw -> R.string.ext_nsfw_short
+            else -> null
+        }
+    }
+
+    Column(
+        modifier = modifier.padding(start = horizontalPadding),
+    ) {
+        Text(
+            text = extension.name,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            style = MaterialTheme.typography.bodyMedium,
+        )
+        Row(
+            horizontalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            if (extension.lang.isNullOrEmpty().not()) {
+                Text(
+                    text = LocaleHelper.getSourceDisplayName(extension.lang, context),
+                    style = MaterialTheme.typography.bodySmall,
+                )
+            }
+
+            if (extension.versionName.isNotEmpty()) {
+                Text(
+                    text = extension.versionName,
+                    style = MaterialTheme.typography.bodySmall,
+                )
+            }
+
+            if (warning != null) {
+                Text(
+                    text = stringResource(id = warning).uppercase() /* SY --> */ plusRepo extension /* SY <-- */,
+                    style = MaterialTheme.typography.bodySmall.copy(
+                        color = MaterialTheme.colorScheme.error,
+                    ),
+                )
+            } /* SY --> */ else if (extension is Extension.Available && extension.repoUrl != REPO_URL_PREFIX) {
+                Text(
+                    text = stringResource(R.string.repo_source).uppercase(),
+                    style = MaterialTheme.typography.bodySmall.copy(
+                        color = MaterialTheme.colorScheme.error,
+                    ),
+                )
+            }
+            // SY <--
+        }
+    }
+}
+
+@Composable
+private infix fun String.plusRepo(extension: Extension): String {
+    val context = LocalContext.current
+    return remember(this, extension, context) {
+        if (extension is Extension.Available && extension.repoUrl != REPO_URL_PREFIX) {
+            if (isNullOrEmpty()) {
+                ""
+            } else {
+                "$this • "
+            } + context.getString(R.string.repo_source)
+        } else this
+    }
+}
+
+@Composable
+fun ExtensionItemActions(
+    extension: Extension,
+    installStep: InstallStep,
+    modifier: Modifier = Modifier,
+    onClickItemCancel: (Extension) -> Unit = {},
+    onClickItemAction: (Extension) -> Unit = {},
+) {
+    val isIdle = remember(installStep) {
+        installStep == InstallStep.Idle || installStep == InstallStep.Error
+    }
+    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
+        // SY -->
+        if (
+            extension is Extension.Installed &&
+            isIdle &&
+            extension.sources.any { it.anyIs<ConfigurableSource>() }
+        ) {
+            Icon(Icons.Outlined.Settings, "", tint = MaterialTheme.colorScheme.primary)
+        }
+        // SY <--
+        TextButton(
+            onClick = { onClickItemAction(extension) },
+            enabled = isIdle,
+        ) {
+            Text(
+                text = when (installStep) {
+                    InstallStep.Pending -> stringResource(R.string.ext_pending)
+                    InstallStep.Downloading -> stringResource(R.string.ext_downloading)
+                    InstallStep.Installing -> stringResource(R.string.ext_installing)
+                    InstallStep.Installed -> stringResource(R.string.ext_installed)
+                    InstallStep.Error -> stringResource(R.string.action_retry)
+                    InstallStep.Idle -> {
+                        when (extension) {
+                            is Extension.Installed -> {
+                                if (extension.hasUpdate) {
+                                    stringResource(R.string.ext_update)
+                                } else {
+                                    stringResource(R.string.action_settings)
+                                }
+                            }
+                            is Extension.Untrusted -> stringResource(R.string.ext_trust)
+                            is Extension.Available -> stringResource(R.string.ext_install)
+                        }
+                    }
+                },
+                style = LocalTextStyle.current.copy(
+                    color = if (isIdle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceTint,
+                ),
+            )
+        }
+        if (isIdle.not()) {
+            IconButton(onClick = { onClickItemCancel(extension) }) {
+                Icon(Icons.Default.Close, "")
+            }
+        }
+    }
+}
+
+@Composable
+fun ExtensionHeader(
+    @StringRes textRes: Int,
+    modifier: Modifier = Modifier,
+    action: @Composable RowScope.() -> Unit = {},
+) {
+    ExtensionHeader(
+        text = stringResource(id = textRes),
+        modifier = modifier,
+        action = action,
+    )
+}
+
+@Composable
+fun ExtensionHeader(
+    text: String,
+    modifier: Modifier = Modifier,
+    action: @Composable RowScope.() -> Unit = {},
+) {
+    Row(
+        modifier = modifier.padding(horizontal = horizontalPadding),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Text(
+            text = text,
+            modifier = Modifier
+                .padding(vertical = 8.dp)
+                .weight(1f),
+            style = MaterialTheme.typography.header,
+        )
+        action()
+    }
+}
+
+@Composable
+fun ExtensionTrustDialog(
+    onClickConfirm: () -> Unit,
+    onClickDismiss: () -> Unit,
+    onDismissRequest: () -> Unit,
+) {
+    AlertDialog(
+        title = {
+            Text(text = stringResource(id = R.string.untrusted_extension))
+        },
+        text = {
+            Text(text = stringResource(id = R.string.untrusted_extension_message))
+        },
+        confirmButton = {
+            TextButton(onClick = onClickConfirm) {
+                Text(text = stringResource(id = R.string.ext_trust))
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onClickDismiss) {
+                Text(text = stringResource(id = R.string.ext_uninstall))
+            }
+        },
+        onDismissRequest = onDismissRequest,
+    )
+}
diff --git a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt b/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt
index feb8e75d3..3198babcc 100644
--- a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt
+++ b/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt
@@ -1,13 +1,10 @@
 package eu.kanade.presentation.source
 
-import androidx.compose.foundation.Image
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyColumn
@@ -33,7 +30,6 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import eu.kanade.domain.source.model.Pin
@@ -243,29 +239,6 @@ fun SourceItem(
     )
 }
 
-@Composable
-fun SourceIcon(
-    source: Source,
-) {
-    val icon = source.icon
-    val modifier = Modifier
-        .height(40.dp)
-        .aspectRatio(1f)
-    if (icon != null) {
-        Image(
-            bitmap = icon,
-            contentDescription = "",
-            modifier = modifier,
-        )
-    } else {
-        Image(
-            painter = painterResource(id = R.mipmap.ic_local_source),
-            contentDescription = "",
-            modifier = modifier,
-        )
-    }
-}
-
 @Composable
 fun SourcePinButton(
     isPinned: Boolean,
diff --git a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt b/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt
index 4f35d7e4d..d5d1b6fc1 100644
--- a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt
+++ b/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt
@@ -1,19 +1,16 @@
 package eu.kanade.presentation.source.components
 
-import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
 import eu.kanade.domain.source.model.Source
-import eu.kanade.presentation.source.SourceIcon
+import eu.kanade.presentation.browse.components.BaseBrowseItem
+import eu.kanade.presentation.browse.components.SourceIcon
 import eu.kanade.presentation.util.horizontalPadding
 import eu.kanade.tachiyomi.util.system.LocaleHelper
 
@@ -28,19 +25,14 @@ fun BaseSourceItem(
     action: @Composable RowScope.(Source) -> Unit = {},
     content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent,
 ) {
-    Row(
-        modifier = modifier
-            .combinedClickable(
-                onClick = onClickItem,
-                onLongClick = onLongClickItem,
-            )
-            .padding(horizontal = horizontalPadding, vertical = 8.dp),
-        verticalAlignment = Alignment.CenterVertically,
-    ) {
-        icon.invoke(this, source)
-        content.invoke(this, source, showLanguageInContent)
-        action.invoke(this, source)
-    }
+    BaseBrowseItem(
+        modifier = modifier,
+        onClickItem = onClickItem,
+        onLongClickItem = onLongClickItem,
+        icon = { icon.invoke(this, source) },
+        action = { action.invoke(this, source) },
+        content = { content.invoke(this, source, showLanguageInContent) },
+    )
 }
 
 private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source ->
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
deleted file mode 100644
index 89f621da2..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
-
-/**
- * Adapter that holds the catalogue cards.
- *
- * @param controller instance of [ExtensionController].
- */
-class ExtensionAdapter(controller: ExtensionController) :
-    FlexibleAdapter<IFlexible<*>>(null, controller, true) {
-
-    init {
-        setDisplayHeadersAtStartUp(true)
-    }
-
-    /**
-     * Listener for browse item clicks.
-     */
-    val buttonClickListener: OnButtonClickListener = controller
-
-    interface OnButtonClickListener {
-        fun onButtonClick(position: Int)
-        fun onCancelButtonClick(position: Int)
-    }
-}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
index 7b1aa7b3d..8112692b7 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
@@ -1,48 +1,30 @@
 package eu.kanade.tachiyomi.ui.browse.extension
 
-import android.view.LayoutInflater
 import android.view.Menu
 import android.view.MenuInflater
 import android.view.MenuItem
-import android.view.View
 import androidx.appcompat.widget.SearchView
-import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import com.bluelinelabs.conductor.ControllerChangeHandler
 import com.bluelinelabs.conductor.ControllerChangeType
-import dev.chrisbanes.insetter.applyInsetter
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.presentation.extension.ExtensionScreen
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding
 import eu.kanade.tachiyomi.extension.model.Extension
-import eu.kanade.tachiyomi.source.online.HttpSource
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
+import eu.kanade.tachiyomi.ui.base.controller.ComposeController
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.BrowseController
 import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController
-import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import reactivecircus.flowbinding.appcompat.queryTextChanges
-import reactivecircus.flowbinding.swiperefreshlayout.refreshes
 
 /**
  * Controller to manage the catalogues available in the app.
  */
 open class ExtensionController :
-    NucleusController<ExtensionControllerBinding, ExtensionPresenter>(),
-    ExtensionAdapter.OnButtonClickListener,
-    FlexibleAdapter.OnItemClickListener,
-    FlexibleAdapter.OnItemLongClickListener,
-    ExtensionTrustDialog.Listener {
-
-    /**
-     * Adapter containing the list of manga from the catalogue.
-     */
-    private var adapter: FlexibleAdapter<IFlexible<*>>? = null
-
-    private var extensions: List<ExtensionItem> = emptyList()
+    ComposeController<ExtensionPresenter>() {
 
     private var query = ""
 
@@ -50,42 +32,54 @@ open class ExtensionController :
         setHasOptionsMenu(true)
     }
 
-    override fun getTitle(): String? {
-        return applicationContext?.getString(R.string.label_extensions)
-    }
+    override fun getTitle(): String? =
+        applicationContext?.getString(R.string.label_extensions)
 
-    override fun createPresenter(): ExtensionPresenter {
-        return ExtensionPresenter()
-    }
+    override fun createPresenter(): ExtensionPresenter =
+        ExtensionPresenter()
 
-    override fun createBinding(inflater: LayoutInflater) =
-        ExtensionControllerBinding.inflate(inflater)
-
-    override fun onViewCreated(view: View) {
-        super.onViewCreated(view)
-
-        binding.recycler.applyInsetter {
-            type(navigationBars = true) {
-                padding()
-            }
-        }
-
-        binding.swipeRefresh.isRefreshing = true
-        binding.swipeRefresh.refreshes()
-            .onEach { presenter.findAvailableExtensions() }
-            .launchIn(viewScope)
-
-        // Initialize adapter, scroll listener and recycler views
-        adapter = ExtensionAdapter(this)
-        // Create recycler and set adapter.
-        binding.recycler.layoutManager = LinearLayoutManager(view.context)
-        binding.recycler.adapter = adapter
-        adapter?.fastScroller = binding.fastScroller
-    }
-
-    override fun onDestroyView(view: View) {
-        adapter = null
-        super.onDestroyView(view)
+    @Composable
+    override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
+        ExtensionScreen(
+            nestedScrollInterop = nestedScrollInterop,
+            presenter = presenter,
+            onLongClickItem = { extension ->
+                when (extension) {
+                    is Extension.Available -> presenter.installExtension(extension)
+                    else -> presenter.uninstallExtension(extension.pkgName)
+                }
+            },
+            onClickItemCancel = { extension ->
+                presenter.cancelInstallUpdateExtension(extension)
+            },
+            onClickUpdateAll = {
+                presenter.updateAllExtensions()
+            },
+            onLaunched = {
+                val ctrl = parentController as BrowseController
+                ctrl.setExtensionUpdateBadge()
+                ctrl.extensionListUpdateRelay.call(true)
+            },
+            onInstallExtension = {
+                presenter.installExtension(it)
+            },
+            onOpenExtension = {
+                val controller = ExtensionDetailsController(it.pkgName)
+                parentController!!.router.pushController(controller)
+            },
+            onTrustExtension = {
+                presenter.trustSignature(it.signatureHash)
+            },
+            onUninstallExtension = {
+                presenter.uninstallExtension(it.pkgName)
+            },
+            onUpdateExtension = {
+                presenter.updateExtension(it)
+            },
+            onRefresh = {
+                presenter.findAvailableExtensions()
+            },
+        )
     }
 
     override fun onOptionsItemSelected(item: MenuItem): Boolean {
@@ -105,26 +99,6 @@ open class ExtensionController :
         }
     }
 
-    override fun onButtonClick(position: Int) {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
-        when (extension) {
-            is Extension.Available -> presenter.installExtension(extension)
-            is Extension.Untrusted -> openTrustDialog(extension)
-            is Extension.Installed -> {
-                if (!extension.hasUpdate) {
-                    openDetails(extension)
-                } else {
-                    presenter.updateExtension(extension)
-                }
-            }
-        }
-    }
-
-    override fun onCancelButtonClick(position: Int) {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
-        presenter.cancelInstallUpdateExtension(extension)
-    }
-
     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
         inflater.inflate(R.menu.browse_extensions, menu)
 
@@ -142,93 +116,11 @@ open class ExtensionController :
         }
 
         searchView.queryTextChanges()
-            .drop(1) // Drop first event after subscribed
             .filter { router.backstack.lastOrNull()?.controller == this }
             .onEach {
                 query = it.toString()
-                updateExtensionsList()
+                presenter.search(query)
             }
             .launchIn(viewScope)
     }
-
-    override fun onItemClick(view: View, position: Int): Boolean {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false
-        when (extension) {
-            is Extension.Available -> presenter.installExtension(extension)
-            is Extension.Untrusted -> openTrustDialog(extension)
-            is Extension.Installed -> openDetails(extension)
-        }
-        return false
-    }
-
-    override fun onItemLongClick(position: Int) {
-        val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
-        if (extension is Extension.Installed || extension is Extension.Untrusted) {
-            uninstallExtension(extension.pkgName)
-        }
-    }
-
-    private fun openDetails(extension: Extension.Installed) {
-        val controller = ExtensionDetailsController(extension.pkgName)
-        parentController!!.router.pushController(controller)
-    }
-
-    private fun openTrustDialog(extension: Extension.Untrusted) {
-        ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName)
-            .showDialog(router)
-    }
-
-    fun setExtensions(extensions: List<ExtensionItem>) {
-        binding.swipeRefresh.isRefreshing = false
-        this.extensions = extensions
-        updateExtensionsList()
-
-        // Update badge on parent controller tab
-        val ctrl = parentController as BrowseController
-        ctrl.setExtensionUpdateBadge()
-        ctrl.extensionListUpdateRelay.call(true)
-    }
-
-    private fun updateExtensionsList() {
-        if (query.isNotBlank()) {
-            val queries = query.split(",")
-            adapter?.updateDataSet(
-                extensions.filter {
-                    queries.any { query ->
-                        when (it.extension) {
-                            is Extension.Available -> {
-                                it.extension.sources.any {
-                                    it.name.contains(query, ignoreCase = true) ||
-                                        it.baseUrl.contains(query, ignoreCase = true) ||
-                                        it.id == query.toLongOrNull()
-                                } || it.extension.name.contains(query, ignoreCase = true)
-                            }
-                            is Extension.Installed -> {
-                                it.extension.sources.any {
-                                    it.name.contains(query, ignoreCase = true) ||
-                                        it.id == query.toLongOrNull() ||
-                                        if (it is HttpSource) { it.baseUrl.contains(query, ignoreCase = true) } else false
-                                } || it.extension.name.contains(query, ignoreCase = true)
-                            }
-                            is Extension.Untrusted -> it.extension.name.contains(query, ignoreCase = true)
-                        }
-                    }
-                },
-            )
-        } else {
-            adapter?.updateDataSet(extensions)
-        }
-    }
-
-    fun downloadUpdate(item: ExtensionItem) {
-        adapter?.updateItem(item, item.installStep)
-    }
-
-    override fun trustSignature(signatureHash: String) {
-        presenter.trustSignature(signatureHash)
-    }
-
-    override fun uninstallExtension(pkgName: String) {
-        presenter.uninstallExtension(pkgName)
-    }
 }
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt
deleted file mode 100644
index 099ad8c88..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.annotation.SuppressLint
-import android.view.View
-import androidx.core.view.isVisible
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.viewholders.FlexibleViewHolder
-import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding
-
-class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) :
-    FlexibleViewHolder(view, adapter) {
-
-    private val binding = SectionHeaderItemBinding.bind(view)
-
-    @SuppressLint("SetTextI18n")
-    fun bind(item: ExtensionGroupItem) {
-        var text = item.name
-        if (item.showSize) {
-            text += " (${item.size})"
-        }
-        binding.title.text = text
-
-        binding.actionButton.isVisible = item.actionLabel != null && item.actionOnClick != null
-        binding.actionButton.text = item.actionLabel
-        binding.actionButton.setOnClickListener(if (item.actionLabel != null) item.actionOnClick else null)
-    }
-}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt
deleted file mode 100644
index 53adf7588..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.view.View
-import androidx.recyclerview.widget.RecyclerView
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.AbstractHeaderItem
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.R
-
-/**
- * Item that contains the group header.
- *
- * @param name The header name.
- * @param size The number of items in the group.
- */
-data class ExtensionGroupItem(
-    val name: String,
-    val size: Int,
-    val showSize: Boolean = false,
-) : AbstractHeaderItem<ExtensionGroupHolder>() {
-
-    var actionLabel: String? = null
-    var actionOnClick: (View.OnClickListener)? = null
-
-    /**
-     * Returns the layout resource of this item.
-     */
-    override fun getLayoutRes(): Int {
-        return R.layout.section_header_item
-    }
-
-    /**
-     * Creates a new view holder for this item.
-     */
-    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ExtensionGroupHolder {
-        return ExtensionGroupHolder(view, adapter)
-    }
-
-    /**
-     * Binds this item to the given view holder.
-     */
-    override fun bindViewHolder(
-        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
-        holder: ExtensionGroupHolder,
-        position: Int,
-        payloads: List<Any?>?,
-    ) {
-        holder.bind(this)
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other is ExtensionGroupItem) {
-            return name == other.name
-        }
-        return false
-    }
-
-    override fun hashCode(): Int {
-        return name.hashCode()
-    }
-}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
deleted file mode 100644
index 33209ecfd..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.annotation.SuppressLint
-import android.view.View
-import androidx.core.view.isVisible
-import coil.dispose
-import coil.load
-import eu.davidea.viewholders.FlexibleViewHolder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.databinding.ExtensionItemBinding
-import eu.kanade.tachiyomi.extension.api.REPO_URL_PREFIX
-import eu.kanade.tachiyomi.extension.model.Extension
-import eu.kanade.tachiyomi.extension.model.InstallStep
-import eu.kanade.tachiyomi.source.ConfigurableSource
-import eu.kanade.tachiyomi.util.system.LocaleHelper
-
-class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
-    FlexibleViewHolder(view, adapter) {
-
-    private val binding = ExtensionItemBinding.bind(view)
-
-    init {
-        binding.extButton.setOnClickListener {
-            adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
-        }
-        binding.cancelButton.setOnClickListener {
-            adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
-        }
-    }
-
-    fun bind(item: ExtensionItem) {
-        val extension = item.extension
-
-        binding.name.text = extension.name
-        binding.version.text = extension.versionName
-        binding.lang.text = LocaleHelper.getSourceDisplayName(extension.lang, itemView.context)
-        binding.warning.text = when {
-            extension is Extension.Untrusted -> itemView.context.getString(R.string.ext_untrusted)
-            extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial)
-            extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete)
-            // SY -->
-            extension is Extension.Installed && extension.isRedundant -> itemView.context.getString(R.string.ext_redundant)
-            extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short).plusRepo(extension)
-            else -> "".plusRepo(extension)
-            // SY <--
-        }.uppercase()
-
-        binding.icon.dispose()
-        if (extension is Extension.Available) {
-            binding.icon.load(extension.iconUrl)
-        } else if (extension is Extension.Installed) {
-            binding.icon.load(extension.icon)
-        }
-        bindButtons(item)
-    }
-
-    // SY -->
-    private fun String.plusRepo(extension: Extension): String {
-        return if (extension is Extension.Available) {
-            when (extension.repoUrl) {
-                REPO_URL_PREFIX -> this
-                else -> {
-                    if (isEmpty()) {
-                        this
-                    } else {
-                        this + " • "
-                    } + itemView.context.getString(R.string.repo_source)
-                }
-            }
-        } else this
-    }
-
-    // SY <--
-    @Suppress("ResourceType")
-    fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
-        val extension = item.extension
-
-        val installStep = item.installStep
-        setText(
-            when (installStep) {
-                InstallStep.Pending -> R.string.ext_pending
-                InstallStep.Downloading -> R.string.ext_downloading
-                InstallStep.Installing -> R.string.ext_installing
-                InstallStep.Installed -> R.string.ext_installed
-                InstallStep.Error -> R.string.action_retry
-                InstallStep.Idle -> {
-                    when (extension) {
-                        is Extension.Installed -> {
-                            if (extension.hasUpdate) {
-                                R.string.ext_update
-                            } else {
-                                R.string.action_settings
-                            }
-                        }
-                        is Extension.Untrusted -> R.string.ext_trust
-                        is Extension.Available -> R.string.ext_install
-                    }
-                }
-            },
-        )
-        // SY -->
-        if (extension is Extension.Installed &&
-            installStep == InstallStep.Idle &&
-            extension.sources.any { it is ConfigurableSource }
-        ) {
-            @SuppressLint("SetTextI18n")
-            text = "$text+"
-        }
-        // SY <--
-
-        val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
-        binding.cancelButton.isVisible = !isIdle
-        isEnabled = isIdle
-        isClickable = isIdle
-    }
-}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
deleted file mode 100644
index 5e895f6b5..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.view.View
-import androidx.recyclerview.widget.RecyclerView
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.AbstractSectionableItem
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.extension.model.Extension
-import eu.kanade.tachiyomi.extension.model.InstallStep
-import eu.kanade.tachiyomi.source.CatalogueSource
-
-/**
- * Item that contains source information.
- *
- * @param source Instance of [CatalogueSource] containing source information.
- * @param header The header for this item.
- */
-data class ExtensionItem(
-    val extension: Extension,
-    val header: ExtensionGroupItem? = null,
-    val installStep: InstallStep = InstallStep.Idle,
-) :
-    AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
-
-    /**
-     * Returns the layout resource of this item.
-     */
-    override fun getLayoutRes(): Int {
-        return R.layout.extension_item
-    }
-
-    /**
-     * Creates a new view holder for this item.
-     */
-    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ExtensionHolder {
-        return ExtensionHolder(view, adapter as ExtensionAdapter)
-    }
-
-    /**
-     * Binds this item to the given view holder.
-     */
-    override fun bindViewHolder(
-        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
-        holder: ExtensionHolder,
-        position: Int,
-        payloads: List<Any?>?,
-    ) {
-        if (payloads == null || payloads.isEmpty()) {
-            holder.bind(this)
-        } else {
-            holder.bindButtons(this)
-        }
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        return extension.pkgName == (other as ExtensionItem).extension.pkgName
-    }
-
-    override fun hashCode(): Int {
-        return extension.pkgName.hashCode()
-    }
-}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
index 9e3f1aacd..76b38aa06 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
@@ -2,144 +2,151 @@ package eu.kanade.tachiyomi.ui.browse.extension
 
 import android.app.Application
 import android.os.Bundle
-import android.view.View
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import eu.kanade.domain.extension.interactor.GetExtensionUpdates
+import eu.kanade.domain.extension.interactor.GetExtensions
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.preference.PreferenceValues
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.extension.ExtensionManager
 import eu.kanade.tachiyomi.extension.model.Extension
 import eu.kanade.tachiyomi.extension.model.InstallStep
+import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.system.LocaleHelper
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
 import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
-import java.util.concurrent.TimeUnit
-
-private typealias ExtensionTuple =
-    Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
 
 /**
  * Presenter of [ExtensionController].
  */
 open class ExtensionPresenter(
     private val extensionManager: ExtensionManager = Injekt.get(),
-    private val preferences: PreferencesHelper = Injekt.get(),
+    private val getExtensionUpdates: GetExtensionUpdates = Injekt.get(),
+    private val getExtensions: GetExtensions = Injekt.get(),
 ) : BasePresenter<ExtensionController>() {
 
-    private var extensions = emptyList<ExtensionItem>()
+    private val _query: MutableStateFlow<String> = MutableStateFlow("")
 
-    private var currentDownloads = hashMapOf<String, InstallStep>()
+    private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
+
+    private val _state: MutableStateFlow<ExtensionState> = MutableStateFlow(ExtensionState.Uninitialized)
+    val state: StateFlow<ExtensionState> = _state.asStateFlow()
+
+    var isRefreshing: Boolean by mutableStateOf(true)
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 
         extensionManager.findAvailableExtensions()
-        bindToExtensionsObservable()
-    }
 
-    private fun bindToExtensionsObservable(): Subscription {
-        val installedObservable = extensionManager.getInstalledExtensionsObservable()
-        val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
-        val availableObservable = extensionManager.getAvailableExtensionsObservable()
-            .startWith(emptyList<Extension.Available>())
-
-        return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) }
-            .debounce(500, TimeUnit.MILLISECONDS)
-            .map(::toItems)
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) })
-    }
-
-    @Synchronized
-    private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
         val context = Injekt.get<Application>()
-        val activeLangs = preferences.enabledLanguages().get()
-        val showNsfwSources = preferences.showNsfwSource().get()
-
-        val (installed, untrusted, available) = tuple
-
-        val items = mutableListOf<ExtensionItem>()
-
-        val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) }
-            .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
-
-        val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) }
-            .sortedWith(
-                compareBy<Extension.Installed> { !it.isObsolete /* SY --> */ && !it.isRedundant /* SY <-- */ }
-                    .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
-            )
-
-        val untrustedSorted = untrusted.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
-
-        val availableSorted = available
-            // Filter out already installed extensions and disabled languages
-            .filter { avail ->
-                installed.none { it.pkgName == avail.pkgName } &&
-                    untrusted.none { it.pkgName == avail.pkgName } &&
-                    avail.lang in activeLangs &&
-                    (showNsfwSources || !avail.isNsfw)
-            }
-            .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
-
-        if (updatesSorted.isNotEmpty()) {
-            val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
-            if (preferences.extensionInstaller().get() != PreferenceValues.ExtensionInstaller.LEGACY) {
-                header.actionLabel = context.getString(R.string.ext_update_all)
-                header.actionOnClick = View.OnClickListener { _ ->
-                    extensions
-                        .filter { it.extension is Extension.Installed && it.extension.hasUpdate }
-                        .forEach { updateExtension(it.extension as Extension.Installed) }
-                }
-            }
-            items += updatesSorted.map { extension ->
-                ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
+        val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
+            {
+                ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
             }
         }
-        if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
-            val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
-
-            items += installedSorted.map { extension ->
-                ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
-            }
-
-            items += untrustedSorted.map { extension ->
-                ExtensionItem(extension, header)
-            }
-        }
-        if (availableSorted.isNotEmpty()) {
-            val availableGroupedByLang = availableSorted
-                .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
-                .toSortedMap()
-
-            availableGroupedByLang
-                .forEach {
-                    val header = ExtensionGroupItem(it.key, it.value.size)
-                    items += it.value.map { extension ->
-                        ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
+        val queryFilter: (String) -> ((Extension) -> Boolean) = { query ->
+            filter@{ extension ->
+                if (query.isEmpty()) return@filter true
+                query.split(",").any { _input ->
+                    val input = _input.trim()
+                    if (input.isEmpty()) return@any false
+                    when (extension) {
+                        is Extension.Available -> {
+                            extension.sources.any {
+                                it.name.contains(input, ignoreCase = true) ||
+                                    it.baseUrl.contains(input, ignoreCase = true) ||
+                                    it.id == input.toLongOrNull()
+                            } || extension.name.contains(input, ignoreCase = true)
+                        }
+                        is Extension.Installed -> {
+                            extension.sources.any {
+                                it.name.contains(input, ignoreCase = true) ||
+                                    it.id == input.toLongOrNull() ||
+                                    if (it is HttpSource) { it.baseUrl.contains(input, ignoreCase = true) } else false
+                            } || extension.name.contains(input, ignoreCase = true)
+                        }
+                        is Extension.Untrusted -> extension.name.contains(input, ignoreCase = true)
                     }
                 }
+            }
         }
 
-        this.extensions = items
-        return items
+        launchIO {
+            combine(
+                _query,
+                getExtensions.subscribe(),
+                getExtensionUpdates.subscribe(),
+                _currentDownloads,
+            ) { query, (installed, untrusted, available), updates, downloads ->
+                isRefreshing = false
+
+                val languagesWithExtensions = available
+                    .filter(queryFilter(query))
+                    .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
+                    .toSortedMap()
+                    .flatMap { (key, value) ->
+                        listOf(
+                            ExtensionUiModel.Header.Text(key),
+                            *value.map(extensionMapper(downloads)).toTypedArray(),
+                        )
+                    }
+
+                val items = mutableListOf<ExtensionUiModel>()
+
+                val updates = updates.filter(queryFilter(query)).map(extensionMapper(downloads))
+                if (updates.isNotEmpty()) {
+                    items.add(ExtensionUiModel.Header.Resource(R.string.ext_updates_pending))
+                    items.addAll(updates)
+                }
+
+                val installed = installed.filter(queryFilter(query)).map(extensionMapper(downloads))
+                val untrusted = untrusted.filter(queryFilter(query)).map(extensionMapper(downloads))
+                if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
+                    items.add(ExtensionUiModel.Header.Resource(R.string.ext_installed))
+                    items.addAll(installed)
+                    items.addAll(untrusted)
+                }
+
+                if (languagesWithExtensions.isNotEmpty()) {
+                    items.addAll(languagesWithExtensions)
+                }
+
+                items
+            }.collectLatest {
+                _state.value = ExtensionState.Initialized(it)
+            }
+        }
     }
 
-    @Synchronized
-    private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
-        val extensions = extensions.toMutableList()
-        val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
+    fun search(query: String) {
+        launchIO {
+            _query.emit(query)
+        }
+    }
 
-        return if (position != -1) {
-            val item = extensions[position].copy(installStep = state)
-            extensions[position] = item
-
-            this.extensions = extensions
-            item
-        } else {
-            null
+    fun updateAllExtensions() {
+        launchIO {
+            val state = _state.value
+            if (state !is ExtensionState.Initialized) return@launchIO
+            state.list.mapNotNull {
+                if (it !is ExtensionUiModel.Item) return@mapNotNull null
+                if (it.extension !is Extension.Installed) return@mapNotNull null
+                if (it.extension.hasUpdate.not()) return@mapNotNull null
+                it.extension
+            }.forEach {
+                updateExtension(it)
+            }
         }
     }
 
@@ -155,15 +162,29 @@ open class ExtensionPresenter(
         extensionManager.cancelInstallUpdateExtension(extension)
     }
 
+    private fun removeDownloadState(extension: Extension) {
+        _currentDownloads.update { map ->
+            val map = map.toMutableMap()
+            map.remove(extension.pkgName)
+            map
+        }
+    }
+
+    private fun addDownloadState(extension: Extension, installStep: InstallStep) {
+        _currentDownloads.update { map ->
+            val map = map.toMutableMap()
+            map[extension.pkgName] = installStep
+            map
+        }
+    }
+
     private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
-        this.doOnNext { currentDownloads[extension.pkgName] = it }
-            .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
-            .map { state -> updateInstallStep(extension, state) }
-            .subscribeWithView({ view, item ->
-                if (item != null) {
-                    view.downloadUpdate(item)
-                }
-            },)
+        this
+            .doOnUnsubscribe { removeDownloadState(extension) }
+            .subscribe(
+                { installStep -> addDownloadState(extension, installStep) },
+                { removeDownloadState(extension) },
+            )
     }
 
     fun uninstallExtension(pkgName: String) {
@@ -171,6 +192,7 @@ open class ExtensionPresenter(
     }
 
     fun findAvailableExtensions() {
+        isRefreshing = true
         extensionManager.findAvailableExtensions()
     }
 
@@ -178,3 +200,28 @@ open class ExtensionPresenter(
         extensionManager.trustSignature(signatureHash)
     }
 }
+
+sealed interface ExtensionUiModel {
+    sealed interface Header : ExtensionUiModel {
+        data class Resource(@StringRes val textRes: Int) : Header
+        data class Text(val text: String) : Header
+    }
+    data class Item(
+        val extension: Extension,
+        val installStep: InstallStep,
+    ) : ExtensionUiModel {
+
+        fun key(): String {
+            return when (extension) {
+                is Extension.Installed ->
+                    if (extension.hasUpdate) "update_${extension.pkgName}" else extension.pkgName
+                else -> extension.pkgName
+            }
+        }
+    }
+}
+
+sealed class ExtensionState {
+    object Uninitialized : ExtensionState()
+    data class Initialized(val list: List<ExtensionUiModel>) : ExtensionState()
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt
deleted file mode 100644
index 23d23a32b..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension
-
-import android.app.Dialog
-import android.os.Bundle
-import androidx.core.os.bundleOf
-import com.bluelinelabs.conductor.Controller
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-
-class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
-        where T : Controller, T : ExtensionTrustDialog.Listener {
-
-    constructor(target: T, signatureHash: String, pkgName: String) : this(
-        bundleOf(
-            SIGNATURE_KEY to signatureHash,
-            PKGNAME_KEY to pkgName,
-        ),
-    ) {
-        targetController = target
-    }
-
-    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-        return MaterialAlertDialogBuilder(activity!!)
-            .setTitle(R.string.untrusted_extension)
-            .setMessage(R.string.untrusted_extension_message)
-            .setPositiveButton(R.string.ext_trust) { _, _ ->
-                (targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!)
-            }
-            .setNegativeButton(R.string.ext_uninstall) { _, _ ->
-                (targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!)
-            }
-            .create()
-    }
-
-    interface Listener {
-        fun trustSignature(signatureHash: String)
-        fun uninstallExtension(pkgName: String)
-    }
-}
-
-private const val SIGNATURE_KEY = "signature_key"
-private const val PKGNAME_KEY = "pkgname_key"
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt
index a4bae2484..e9f4b263f 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt
@@ -3,7 +3,15 @@ package eu.kanade.tachiyomi.ui.browse.extension
 import android.content.Context
 import android.content.pm.PackageManager
 import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
 import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.util.lang.withIOContext
 
 fun Extension.getApplicationIcon(context: Context): Drawable? {
     return try {
@@ -12,3 +20,27 @@ fun Extension.getApplicationIcon(context: Context): Drawable? {
         null
     }
 }
+
+@Composable
+fun Extension.getIcon(): State<Result<ImageBitmap>> {
+    val context = LocalContext.current
+    return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
+        withIOContext {
+            value = try {
+                Result.Success(
+                    context.packageManager.getApplicationIcon(pkgName)
+                        .toBitmap()
+                        .asImageBitmap(),
+                )
+            } catch (e: Exception) {
+                Result.Error
+            }
+        }
+    }
+}
+
+sealed class Result<out T> {
+    object Loading : Result<Nothing>()
+    object Error : Result<Nothing>()
+    data class Success<out T>(val value: T) : Result<T>()
+}
diff --git a/app/src/main/res/layout/extension_controller.xml b/app/src/main/res/layout/extension_controller.xml
deleted file mode 100644
index 0db6a3024..000000000
--- a/app/src/main/res/layout/extension_controller.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
-        android:id="@+id/swipe_refresh"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-
-        <androidx.recyclerview.widget.RecyclerView
-            android:id="@+id/recycler"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:clipToPadding="false"
-            android:paddingTop="8dp"
-            android:paddingBottom="@dimen/action_toolbar_list_padding"
-            tools:listitem="@layout/section_header_item" />
-
-    </eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
-
-    <eu.kanade.tachiyomi.widget.MaterialFastScroll
-        android:id="@+id/fast_scroller"
-        android:layout_width="wrap_content"
-        android:layout_height="match_parent"
-        android:layout_gravity="end"
-        app:fastScrollerBubbleEnabled="false"
-        tools:visibility="visible" />
-
-</FrameLayout>
diff --git a/app/src/main/res/layout/extension_item.xml b/app/src/main/res/layout/extension_item.xml
deleted file mode 100644
index b76c9437d..000000000
--- a/app/src/main/res/layout/extension_item.xml
+++ /dev/null
@@ -1,98 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="64dp"
-    android:background="@drawable/list_item_selector_background"
-    android:paddingEnd="16dp">
-
-    <ImageView
-        android:id="@+id/icon"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:paddingStart="16dp"
-        android:paddingEnd="8dp"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintDimensionRatio="1:1"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:ignore="ContentDescription"
-        tools:src="@mipmap/ic_launcher_round" />
-
-    <TextView
-        android:id="@+id/name"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginEnd="4dp"
-        android:ellipsize="end"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodyMedium"
-        app:layout_constraintBottom_toTopOf="@id/lang"
-        app:layout_constraintEnd_toStartOf="@id/ext_button"
-        app:layout_constraintStart_toEndOf="@id/icon"
-        app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintVertical_chainStyle="packed"
-        tools:text="Batoto" />
-
-    <TextView
-        android:id="@+id/lang"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodySmall"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintStart_toEndOf="@id/icon"
-        app:layout_constraintTop_toBottomOf="@+id/name"
-        tools:text="English"
-        tools:visibility="visible" />
-
-    <TextView
-        android:id="@+id/version"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="4dp"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodySmall"
-        app:layout_constraintStart_toEndOf="@id/lang"
-        app:layout_constraintTop_toBottomOf="@+id/name"
-        tools:text="Version" />
-
-    <TextView
-        android:id="@+id/warning"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="4dp"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodySmall"
-        android:textColor="?attr/colorError"
-        app:layout_constraintStart_toEndOf="@id/version"
-        app:layout_constraintTop_toBottomOf="@+id/name"
-        tools:text="Warning" />
-
-    <Button
-        android:id="@+id/ext_button"
-        style="?attr/borderlessButtonStyle"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/cancel_button"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:text="Details" />
-
-    <ImageButton
-        android:id="@+id/cancel_button"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="?selectableItemBackgroundBorderless"
-        android:contentDescription="@android:string/cancel"
-        android:padding="12dp"
-        android:src="@drawable/ic_close_24dp"
-        android:visibility="gone"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:tint="?android:attr/textColorPrimary"
-        tools:visibility="visible" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/mipmap-hdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-hdpi/ic_untrusted_source.png
new file mode 100644
index 0000000000000000000000000000000000000000..a61d75b69f6a3d3e135b505a741ee03c67125473
GIT binary patch
literal 2124
zcmV-S2($NzP)<h;3K|Lk000e1NJLTq002k;002k`1^@s6RqeA!00001b5ch_0Itp)
z=>Px-21!IgRCr$PoLNj%XBdXxS+ItxNh5*~CJ>j#8?0-+AT;2uH{upa6BpDNYutv!
zSX{6!s7)BRxFkkg8{E0_QZXf5xNkS6F2M`|K}-{|+QQ7xygmGxe-39GhR&QJ^Cy$&
zaJK(B@B4k<`Oci<Kx!BgaHRMc6G#CvQAi3<3Q%l8B&DE1QV>B7C78Vc_%R)3ll~9P
z-Me?cDJUqI?Ql3IIi1d7rdze~0-;c-ySKOZ#q;OSZ_k=F>rVh7qr)`=<Z5VW$Sx`>
z`pxBXeFtD90H=-TZ{^Skq$?N<K5cAl{IR5@qzyn&Z$%?O&hzKbXOxwdU30tLMOOBl
zY|8`!fyTPJx&=#?Ea}u!k{%#_@Amusi*j;ujys*sQOP!WE5{oOg+8{nw*KVtc&-5G
z(OO3j5N-5TXJ=>S=+UDqhk&j0c!fTE_)wjZkx>ocA1xIb0s6YVy}c?kGqb|V*+|x9
zIyyRPva+&%0q|F}fJU^nwN;HBJJy@5leco*@7}%hWoKs-(5Dn24Pq%kCNfF^N-98~
zJ^|OR0d1_!SnOM<_T^==vw=m6fDt3CgcnQ8*hfKxQ(X<5H~|Fv3Rg8C`Ttt~b~=Hz
zYk_UsfRQZfV;}Ya)X@R#-w&KQ6NaOuATvN*hmcmR04ghitgM&`B_0r!vttKv;R4Xz
z9ce2oK(v+_HVjy{4A{FjX1gU85aCcc$BsoRN39+`P)5r+9KibZirr#}Nr?kQTOBw6
z)Ybx_kiK%P!qSULD_1IzGBXEcx5NOVa;mC;bLW7bo`J!!3N9@TSiW3I00U4``~k_f
zQY**mqz97Sgx#XdZt(>qXDcl@!-oT`Wty9<$0PyO<pT2ZfYw&v%a{Ij6l2n=Rf^rB
z$ZqiiWR$JMBsXsc=FSDytpgrB2*(U7ikUG(3HJ5%z`lLjwq<q`cC)CY_yE!>0_BiN
z<q*o&tx6rkN_z2P;K>sp5Qq$}>R?ReOq~i`xuU3#%G|XJID8oR{JFo9xP9)6G0;Lx
ziVvW(XMs(d`rAe*XWTg8;zgjKAPnQxE1;qRXlT%{tU1||62RvJCQb~m`|Ozl`1NaT
zB@rss#)8ro*J6}BKk6aWz==`?b#;mg1UOERlc3$SY&LTyaQZYbVFJJy!$I{eSO7eJ
ztbo<4p|)>V3RGJH(u*n6rz;a4KR$B8RKuf3VI>jhv}wTQ%aK5-I>wm$_xoE*y-!;K
zVhzHIS?lUt{*KZwE(U696jg{pC3(HT?c2&WX3qu=9#lYz@$#i&V=kk0v8z@R)ngk4
z5s;C^WSI-QUAh#hjyG?V>nje%G{!C2R%~-Td}s|0w_$5QW=i4&n6`NtB7^AcR1`F3
zOk|v*t?u0mW42Nj*cOn{!HX$!8QP6jVuq5fB&&_q+qe<9bxXfZq8FrX2Z%P)%VP5P
z>X<ZTic<V#WJIq0{yk7y8X3Gr_LD*DZJR-wwU8~c7~!Y^S+SXjM>lSSZ6zi|K&-k&
zIyZu&rCKV;mVm5);baL%D_f~8wqb*kx#Wu5%*On??Eta6r#19|u?S=fRu0ls4Tm~N
zFI^h0ebh?QQ<m)k>49Nz)+z#-<_Us>eIywiuizIhj7;-daY){$tpHIyA_i%J5oeij
zw63jKA+rc%w&I}m4jqatCRr$|Gn$drYzN4S#YF9+zP3^qf#M4O>Qx{oM}bBd1S#xh
zRtqse+a^d8jvA0Uoy#o?dBWx8kwu^!Qy8aMRm(tWxB2rU)4x_NWGg_dh}rVg>LPN&
zj3>O3pFBAn#<i}k)B#r%leC-M?s)oCxx;GI?l^v2=`h-&4r0S>_ipXmJ3K%(%osmd
zyorXH+_KOLxN_WLQ0Fac+Kne+=T7BfRy54y^$C?Hi_2K(D#oW_#slo?Qu@KPg!%@d
zn821Tz`S|Dnl;v2u3}xruahT%>(`Zppk*tzTxnU}Zn6M}n~onK?ybFDIrvkjlrt?4
zPAEOSf79Nq=#!O{pRY9SW#x$Vsm#@@l>*V0O?v@@cH6TDIC3Nm$td-!A*y4B89@G>
za9Ab~jz#5&-NaWxG9>=EfR!@sW(GkWoVEV1{!W0St=P6@!!AlV@wb=^g7?U%B-w6O
zuhYzyi_uo{VJ1dFvXYqHc#B3IldP!3D7(dAEBgQv*-dRXGldv|;ooAkmAwFo>?Z6c
z8t!IsNiR6F&S3$_N;!&brH=iH4boai!#as~M2{Xd{*{?gz>cyA)PrI_1<5gqcQaVa
zXbrt`KrG8jn)lEKkge>mAS<Uc0m~#dizQ&f(e5$@$b?u5Pzq4ArixrXCI!8H`}Sv#
z$Fteta3uS4sya!tH{|#Gf6dFw<8ODOejF<O&Ye4Dg@uJRZnt}Ie)k{S$sY8z1A#zS
zQ&Uq#adB}S0RCL9-xJV=k7SmXmgf6>zO(uH`QH!v(b-^w&CSjCyk76J`uh6jXdlTW
zxHJHx=FFM%t<UE>?D2RerKP2%4}s@bLp?n`pZ$LS3$NF^;pWYoe*pN{_fT)Y$Bzsj
z3uiZ(kO~V6bIZ%if5^?v9q)3vlKRC0!C<hvrKRO{ZEfxEO-)TLeQ^Gc_OWnb02iNr
z20-QTLpb~{nFpW6Vi%7whaW!eJfIf~2nftz?!FJ<B=dVb#5+3(gug%LC#F|Z2uK2o
z*{dv)L0KZsSbD+9P?+%ldQwt=Oz@@vr2xg|z<&XkuH%$I-}!I=0000<MNUMnLSTZJ
Cya4$C

literal 0
HcmV?d00001

diff --git a/app/src/main/res/mipmap-mdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-mdpi/ic_untrusted_source.png
new file mode 100644
index 0000000000000000000000000000000000000000..27fa261666b6833db8549bbb910da902034e8a21
GIT binary patch
literal 1102
zcmV-U1hM;xP)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm00001b5ch_0Itp)
z=>Px(2uVaiRA@u(n$1rWK^VrLh00<p2R{PwqMZ1Un<4xIY9ItW7{27%iwQSFxEO?s
zF<iWnaOo9LLVybp;4dH=PbhGLlkxkYtt=_jJT7lKOlRrV?oy0w(zIo^GtY0H_kCyD
z!NNa&S^v0xoCB2F!IK<dTNzMtmofkj0q{BJG67iQ<KwY-JpRJA?LGiiC7$IUj^j)x
z5{btH0|P&GEhd0SGMSu+Mx*f(3qLk|DwRsaVzEa6(rSYa!1u9(gM$xcWo4Jn62Rf%
z;m`8&^6LPSYB-qyE@v{CuV+cTXt6BoDuD0#0Ip;*nJ;G);2MB$=Kw}`K7R&Yzcw3}
z(JEkbGf-V!u*sHXh2+4ePr&{Az`J*@C=u-S+}H>#E&??*1wn)Z;3YmE$gNwz(o#WA
z1P{Ql>FmtTd_K{7J0X^reM1BbAmZP@_YN69Km|Yq2Owc%67NT}o*M#@Cntgd@De{b
z2)um@tgV?7S-|ez1#aI4#>S32*VN>O(+Lp819-(e#Xo%t?Ck-4eOU<I4T^vD3aG3E
zMn;Z9@LoExvf`N&#RITv^!8>;pM=7lJHXTwaPcDW>sJ;+4V8QMfaz%<8g=jK>T*H#
zaGIL~Iy#KyHCO<3apG%2P@oF2y6Os~_?Iu;wp0q}=>eoob&0P-2jJl9rGn@X;+BZ#
z%=7aM6R%{SpAXpq0ni-~D8{)s;W(}XjCnue*+=LA-QC^}2oxb^W&qoE?`3lHbNbAi
zTsSoVeS@aHSF4QxFlp7e_X?qfg%CrC8?by1s0quN=OrOzazPKpQv)zLpo*86=Y0$(
zsZMl4LdoPp=l~R8szMAKRvW4FYPBJlK(LJJ;iOImbATH}!&Uq`zz|Xa)THGKD|Mdb
zQ%_id5Woiy%sD{^LIdDFsJ$K7*f>5vLqovmXtvrgS@i`<7PQ$}Hz$UNf#=V&{ji<;
zA+F9I`=R0;;BX&5x)A*KrCLvjH*az`b2<o};AXBs@$4oT0QN*D+S+ozLdtx4Epqz4
zN8s|zymT8JfIzgin!k;i!p95Fr}$tU5HWOu5PDAJbD+SP561!Nl{vw1BE(6nvkq0j
z%!dmgiLE-359BYzhZ90-F=aTduNxPjoRZqoQt)p=;RAR}53D8FDioM3{~i550FLAA
zR#a5P{LfAJckb`+CvDrl;pcpyYHMq2qOPv)VfeEAkFdSHJyl;{&m*QEC!G(lN4ue+
z;pXJz<g?n^+V)5!67@e!@OP%u>D127&SHOm|C7zl&0PSFI*Rd>5&%VCK02Yi5WFnk
zJ@R0IhkBfK9xbKx<5sV8V*WjH^x#=>DKDN86M~PNv*PoAH7KPue^%#G3n+H{-<N|1
UUQ-!Z@&Et;07*qoM6N<$g3|{9-T(jq

literal 0
HcmV?d00001

diff --git a/app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-xhdpi/ic_untrusted_source.png
new file mode 100644
index 0000000000000000000000000000000000000000..e83ceec512162949905e8494161b36821ae5bc98
GIT binary patch
literal 2599
zcmV+?3fT3DP)<h;3K|Lk000e1NJLTq003YB003YJ1^@s6;+S_h00001b5ch_0Itp)
z=>Px;;Ymb6RCr$PU0ZAwMHv3J(1OKiHEoO+D54TD;ekgKBLow?px75n5q+_E!P|pq
zRTKkSm52|<c%@z*&{}*bC|-yfh^g|(1Bn<#;DFJXHi||N+tM{3d&UmS%+BndbJ*R}
z+2o{`oxOeE|9}5K|ID88;I>o|@KU|HyEq&HDI0+!z!8u#0x4hKd4MAzWdu^byz_vZ
z65xnCl{h~MmvMn)d-17}eou1kYB`3$^D|QXDgL&4K3f9*2feGS>#Vl6wiOd6PQ1nQ
zycwS7)mmMtN*npUKji!V_s5PM+tl6N{c3xA`(H&eNQiN>C4kmGuCK3e+MGFaw${|t
zTxvY?7;G>yGO~B)&Yg?q&Yimt!0{pxkx1AOz&7Jnu3UNUx^?Tmao|T=7D?#n=(uwI
z`t`pTNr;RMCIYyh)?e4(-`~;L*!a{KH0;=qJTNe@rm3lE9e}|i3EFem5HKD<{gER_
zzOApXzdZKcjKKqr9zFWqq)C&m0B{t*a4|S5kuVW(!ukQ6IWjWx%UBP%wzkB;TvJnX
z0f4`YUZ4%gCIZF*m;m4`-}isDy#dz_@I3E)0L+N~jglc70ww}z@O}Sh2dq5;p66W%
z;7<U@%0a+6zV9Ehzr1VddER6Ie>ehCvIR##O2CWDIszO4DI<{b<@4<UAASh*^^HR4
zTU2_(ZNh(FbrsOsdfI5oF9F@%zyl8e|1y1+HWdjb(B=Jgb-*jH0Jq+HT1dzz0Uvw-
z+<!kXI0%@)C5)yv;r+F>z-zAoOP5xhg!~cE(*xXhU**6TWY7~%0}1&efZ*SIFEBI|
zSf<IcZQ4YggP!ol8$erI#XKQD1Tf&<b58(%sX8y<G!e-CBw@n_V9An-kdO}o2>#u7
z1H-J?Dl9*VrN=pD{CJ?Nt0E+1pMXu90?XHdZ{rPdz}9(3g-FOY0ZA<125g%)DtV$D
zmDv-rOF*T-xAB;$*dRQiOo2I@1emfN+c;oj={9Y`gpdRV<}&mXvPVE1@R^c+^bv6A
zP_oP@4E~fUz)d#+FTboWZ^-=wrl=+Mn6pKI+`~0l`6G`2k3S9^I1qqVD8w&G$k%9Y
z2DWYmF1{Fe<{99*=gfT#cGg~h9ay$3af+H90z_>`RbB{ZCIb9%9Et|8cQ3Gb@hAy#
zbzXpfX>lIe@#DbLPXo_Cf3jz(&RZf0*&u)ccf|^Ic_9owWv8<f5a5gbKmG``v;-az
zhk&V50~x#QvXiZtBy@B{%aQP)2@?`$N7*3Y!3TkN-qE|U4Cv&^z{ej0mt1mcdy;VT
z%>fCzd|s9vg1==8Fn#)|=b~3#cO7u>pjnF8qP;z+9mU+TPk_pr87v8$kPP{u!zAdu
zfm+XVge_l4+V$6;99K+bi>f0)H@G7HPu<?USq~rsv+N0B%cmFV2wb!%+KW{8mTLky
zu=4J^(Xye+iYOtyy=oGE`U$x4##4dhG0b*U686InK}NNIe{@DwwFE>BDeCq`dBVPZ
zzzsJ9Qo`fe_R~*+Y17p4gM`HeU)<O7<vET4^Z?r+EJG(AUIbH>q!7?+uMLj+{PQ5s
z4{r>(1fPV3H<R{PEdg=8f!3|djzmwH!8^QBs^}#q%U4ODe2<I)?3BgavXKR;Jiru$
zRhBCBT_rIpU}*8ocEaGBz!v9{@3CqmC<*W~xI*VuQX{t$(iF~=RN&hXl6wz$`)$4R
z#+4&>*1r4_;7`@Y&eb*71f49C#@iB7l?22EUVufd|KbZkrTJ__;1?_y?d7T@MfX2l
z$Bq(|>lhIBfTZA4f<F69o$Z7rXyL->-mZ=Gh&H(<fRn658kz)n0Rn^YC!eVEd|eP`
zy+>8RvnlAw1m&K9s2HHrVfp`2JzP;x)1{$;1eqio0>W8PZY78wpd-L0D`LjOlCrKN
zMycVpZJSz}SiocbhnbPh^;8B?bsi8UMPbWV)$`egc|NUME=906tm-GQ4rJRKt~vrZ
z*V85jgdaSrp0DfS@)*{Gbj3K~32`zbwq+X1G2)n8HhRJ&AZ`p0r3YO-KMbS{43A~7
zRvk-EU}?g3WKGV3s*(Ve6otodWpJtIEGe4`Ym6JL9jQjccn%iu;tcdN22>pZy3sIN
zuPWf`>iIf3i6Vg&Qn{aC%8GJL0OtbTbyxJDya17M#n}p~L3vd@-;~0V1P;)IJ%NMr
zoPF`qOXfj-4$8mvR-%bo*@yuI>Am-Yx&Pr;j#Rn%V~+t(JTXcFtu;J`8x=%V!JL6v
z!~}xBW)1M{v%y)Ste@ahdTzfxX7~-7XNLgZ&HL{MGod**$OPb{j{;9V8RYIN@NM&X
zaUI4LF<>LWH^~m?1aXSuZMP)?KYIiS68J8F?nMmwQ7K29*&uqlDsKrx69p<s;ItOX
zj>+-~K4(goFe@&b1n{mnOYY7)M_)y<0oLR(aXi9g`JARs%`XxB>=M8`<2+GzZp4cj
zQNgsF;F~O;Gov|!qYN{nvrT|Zf_N!UrL;+^r%Anmmd_r0nZVCJ0YZ+LW2;C=0=~(@
zbuokBv$tO2boB7)vy}zuatX@O9d|^(q-T<$C<!xZewk%Qe$0FjAUuIF;dIMU1!O0m
z1c(%s*^%5&Fa_s0dLKR)Q%z<&WtN@%5}+a>NgS{trA&FgPF2PK^CLm>ldx>T+L5ko
zQ7S1cFTD9?Nj+Q>@cAZycfWCC@b<jT*VUK;v`kouwH;gF^G|>X%wK;U{meFUBcI{w
ztIL|_n+VCL1eu;HNBbQCDU;<0a0H}`K+2bQ9^eQ_8G)29?>wN&2xu4_9Nb@9%L<m;
zOb84O4Gq=R)$y&t!{z$+^t1c>`@d>zY@A`bxZ4j53=DkV)YP<~NI*#oIB*G*6Dw$J
z-@g6fdGqEy<N!7!uw%!L7w6BP|3YyEjmU+3Oe<)R0LsppvuDq~sJFLwOLH@)7`RP`
zK%r15w6wG=-nDBNR~+FQGW;^s#B0cqAg-cx27t3$TU%##c6M%PZf>43o})=_`yYYf
z;b9aCg@dbBty<pG)3c|ze91qhuA;;Xh?SHkQHo~HoO#~r)vK4ynl)=-Lqo$<&-2DK
z6Ta^cA3l8e!0z3<x2;{fcH^EsdwwfQ&k@rKN(9#=L81-6!ivjT)dL_A{4h39o=DJG
z-NeG3lo<wFg3b@u4-?v7(n@*)*b~bc76B*lTv>_W)B44#s$-D^v5G3c`j;ed#Uv8J
z-?^5gap40S50FVXv8ZS9BZD93Jk>;$yp%0TAn0NdPb2uY5+nmAmbDeJVXOi#V<LbS
zWK?85VU^W7@JCUxvUUez2F1@=amu7P0vrJ;Bargtod={$z<)-b$Ga#=DK!89002ov
JPDHLkV1h)d*^dAK

literal 0
HcmV?d00001

diff --git a/app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-xxhdpi/ic_untrusted_source.png
new file mode 100644
index 0000000000000000000000000000000000000000..4eddc956103e19b582a5a6e5aa1f93846abb0520
GIT binary patch
literal 5065
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
Y^O<f9egb#y)ol)-qhWw4Reu!ne^lOqqyPW_

literal 0
HcmV?d00001

diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png b/app/src/main/res/mipmap-xxxhdpi/ic_untrusted_source.png
new file mode 100644
index 0000000000000000000000000000000000000000..a2825d56262a1523572632a43c1a6525022266ec
GIT binary patch
literal 7096
zcmbt(c{r4B^!GiR!C=O|#>iILHI}lC43R`CTSSt*LM6K)LZy*}MwTp5*|&rkyU4yT
z*|)Mp*0H|x{ax34z5l-N_5SfZ_qncfp3i;GdG7l>_xU`L##b&fGx9M40GRdl2qv_V
z^4~>IN4uMhxglu-<e|yM3m~sU;5PvLp85o>>)y9ktWnWi{ckliWlum*bY>h@?Vl4`
zElc%zqgtX*<}t88y;K;f+jmJ!J}rSefsoduhI7G2M#neEH;8i8@`OgL%kxO<!)@e$
zd~E$?dDSa`iIo|aN&h;$c&qz3SXKSkv3izJ(9ZF3)|Vr`lFsh|f4?oL=KJk=g)J+v
zl*l2^NUJ?btneT*0dcr=I|PO|kgYKs2et}bJpyu|Wb@zXEHe;?{eP;e$3gbahFcgv
z2^O#=;HeH*_`|#9eP5mG)Y<w9CIdW?+Q}h{+wI3J$B5B?0@_|}cItDD!KZ>3j=7H#
zsXYJKG|al{kDG!vIzcowM#(BXFM4KTN=Q7GA5%n!H(#2mLEb<FcI|B~O(+H)?Ecmc
zXGv1?UYq=CcIIu6OZi+8#IYl|y1LqIXL)!-v(jtDHzO-+ftFtJgRqG4&x{(!?FhI9
z6nm*xdl#vGxY>K`wOrVIOVMqppjcrc`SH*rfuU~?7d|_t1Z-E#kTj3-Z(z%@wy4)X
zs*0g_^W7ar4OZsyDxVFVm6eqj3cR_GdrSltS5}hFI{Zwy8R2BagfZC=F#rWNHD+t;
zqeXYys;;K%S||C&4rsM`+Aa;`4!#mp_YLHP%V5Tau<}7gfcWL`e^7m>bMdl#s$R=c
zkry49qhL2!+(t@%7uB<<=DdjTQXRm)Nx(`2t0~8J&5X**{iJJISFBwnodjB*!@ij1
zuP!bxA9T`VGDaj@pkR4#x&MHG+l*2kH}~z*LK?QY{2L&7*zRwy>@W#w9QdCcnIlpR
z--+9q@BzX%4Q;a=bC0^0yk`Zk;P_na1+r^{=i1^^e7A=kHE$pw(~tv`yJ1BDRBC8P
zIt@MSRDZJ7eNjk#(6mP-S`n#4%idbTombzUMR+;fMf5<4$`#uc8iYp_*Zl8ZJ1wt8
zBplRzmO53|_LlNBz1C`xDf)y5=ktuc(=Rt>X8`(2O+b-5dER|z^_LM^P|f?L3x}?o
z0LI<6Gg&<`oQc;{jrFj;5DLIke3wB`ObJ+AIKo`LX=7tU^5qzys9<ODnI^nO1l7*~
zkN@chi!9P|29)8HfK?+-Aj`O5XWs--M4oxus%Zw9tkA*)nnSkUasfBU<dFcLfZW>J
zQqLC*Q<P(&KWF^u`m4`S;xFtVG&>j11%?)m&7>*E4F3mXG{VVG5i{>*JdIaz1r+E(
zzOU*3_JJCI2Gj++ryUY=AaYgt`467D%jh}?7(Yy91n^n``gDzK4zAQ(J{S+^Yja`F
z5XioM=AtSW2zEaWh+r2FBR=Adk~xiJ0{Z71SI8kSI^suj-9&;33lE6#>=;o*&{T$n
zFO?!QXuDQ)?tQ5%BTYrN<GshGf6f-@wVrbw<e;fIN?!&95Q2`*cJn`&RfVc$nFxR0
z`Z_@W3@vefDm0ZOUd04-r2oLs7;E3t-CljSb*wPv6K@ew`J^(Xv^<%#PuBv{om=+l
zh`zG?@YTZR2o0)!S`<I5-D)Xj%#m3LnNhMsQVCgH(3Cho7yGdoPP2qfR_{kRbc&A@
zWgfo~3t8oN9no+p6;dZJ#@&q2*c6^P#rLNgl6tERG3EPDKaCXda`8dX=y9J^s8a5;
z_%b2oS5Ci8(%u-|=QFSc*+_t$-5EF79PVVhH?Fo8>8wQ*?A;c=7t|t5c)`tXm^i?J
zl>@Y@ML!^PtoP`MN=Q`N>TKfpy}wbM8S?klRzZEl{hFjj^}o*E7#{Es4nmib{UYW&
z#C+#Czt6`zc`+EW38P6$W6dAJ*$yX>1jppzuKyqrV4M1ZYCZL2V_m;UDQ`Y*Z`E1a
z(8`ZEX;$O1m_Py1$p6+w9rqrxYPHzk(jskG3|GCdySkt3u{%YWI1a3DqUA#ktnJPa
z&N|XT@{MK;6n*(p)S0SqaUw1n^e=-Bmuyo~Yh_^gI27H~;;MMXvO-#N0&Jzn#p*Q1
zpM2BP=0g1juck%>!xhc%AMBtsLty^()ne7g-UH9l5QMFuT`^Of?Y~*6Z)8^+I-=AN
zN;qguE3Gpqc`-A7+Rx2&<!=YiU3TSPgw0?yE>uho*h7fnLgIT35)r<BJ3Ak{52~dM
zrBkf=wC&FcPIx!J6s}lNO6$GS7E<|b`!>+4zeV3qR@A!T_`XwiJv|f0R^e;GJMqw#
z!C1%l&!@~XT7)>T`|EFnOhtChe7uXjv)q^NG3O#x^7^h6m<Sgu-!e_>=)hTZl;%RP
z41U6~Bn>|LAj=e+wEi~Tnckz-0GZ6y#Z#Fy*F+(d!yR^OPyM4|!B2l>`&+I)(m>5r
z$`%T=ut;05xy8L*Rsc?1@Vd1Ib}dTcz(Tys<I_n-k_LM}iqXsLsj<G(4JH;)7?A^5
zjbmh-y%g<D`EHJoVmlm#|8T0>Ht#lT%G`h?LGVx@<A$pF$#!3|JC)5)%0seMZzfT5
zbtaQ9)+By5lMhBj19My~uJfJt=ASS7pA@S-VxOJBMJ&EN8)2IJM-v)}>DS)a$o^=A
zZrU~dDN!ss!@S|WY@Q}`6up>J%=jdn325v9+2YhH;m^r7>0#FGXvooKR%A8RFwHez
z%(5iH39Vsx<GcTb%nxriId#r@yBqgzmgcisd*~Q1T>BzB@vv?O5;E6@3i{LpFi6^3
zTvtGi&w7SkNeiA?{6G~pw>db;+A-#H_&DuvVEm%$#Bfj`2_0L#AG1!C8j7zbgif@)
zt{v7vabW`XM`6;zrfa_nXWA9aL!dUF<v{}jdy)D=GXEh<eiI4p8rX-je%OxhMY{sw
z7Y`N3JoZ9mqQU@vp<QI}j~TMU!OU8x#C-~HFv)L}yFVm#^P55o3#U6TsJq;3C)|l{
zC-w6ClNCM(yzeA2^~Bd)#@RJcGRMoL0VPVAg`-LeSHZ#I9ho)2lVAC2(QjhuZ;=i|
z9-Ko>Hbd_=1EY8MIKbReTV9*K8@KjN-30Qyr_bDpo~y(qSA<R&x3PdM)RG?jnRPm#
zj@z*|uloGjPQaamy+K&P^tozB>gtC1$Y`W#4z})<#|c>&QF17Ig*vtn!af>9?;mSh
zn~5(RGplCXJ*Y+<ZGWxiEaw}uQI`VKF_QVQBvveO;L4j5jGd^v>?)p!Qtij*U-Z!`
zIyFm^HDELShtY$Go`c;Wy{*G(ZbHjvz5HL5FyKGK?~A>pn5W96h6Fqz4`NZW_PNm~
z_S+v6m72D&eCT91;$RUy@eV?y?u2W?wPcGF$Odd8l*3vyFX!*lJ4n`pdu4Es6@Tbo
zej&RP<r*q*GvDBg?!jv)o0#=JUzTYRVB5;^+sUMxmScdId*1dn`l=hr6}Cc(!j&_V
zm)ez|9;kY*R~smd%P|*Sb*HjW%$zy7!PCCn$|oNX7X(NjM58$sKp$1L))F<@n{yQ!
zyv#_%yhS{Jqe%pbcRk`L61yHVp>0o7twautSuh7n`J{_4?A06~bnd{$hVlTzTFaVy
z99~okCQt8jjU}Cdssf<#RlMb?YUqwz{WqVeOwVQp{A~%#ne9|{*T9KeVI+yt)kM^F
zEWp5vQ5>1IV#7S#JRX}v#vx0>KNvlN+?9dAGS6Uze;k8qmwO{l>zu;1Q-j2|*jQOM
zV<N4BK_OV?kboDb*TY%;>%I=!-@-;{?sXq6N81a?FvE|}rgTHlyr*BX=aII3$SRS}
z%m4w!wnBGtF49qp#XJ}(`iv&d+6`=rhn7>H_lK3&VV=F8V-mRYxL>Ki^!zRw*Aegx
zt)_M#hW`Pg8wLkUv@U}QOZJy_Y<Rh;w*4{Tv5UvqT0P}f^wkiaSclzEGrkvkd-8yT
z3CxxUkX3Vr6K?f%^mkoGYiTD%)4_RQfY`$T&-5S*h1~<h`H;2r@hnr-Pj@#N0(Lce
zQp?cB#$2bCp;Ru}ND@D{ko)P7rV}AzcwgN6xLJ?%63C$oPIFAK?__Obt4Z;`_m3TB
zAXWhQ?w#%rWlQ0;;3_AOnV9#!Pd&ps$#WeT2ZfG^aFjXA*pny!;WrH6rY`HE_xXBx
z03Q?J3$kJ3IB6a>n$|s10Qa^)tYTzJn%ksCG>k*T12f1r7ZzY9<!D;y5UfR4IgeIa
zRJUf4HpXtH@zbT*y;AN_p<%I!1JRw{_X@RIYy0C_Nm?g;zaZ?&ZcFgU6F>5FNH9%_
zb$4w@M)FB_sFt_~XK>f7whU;e^>1Xzql{A#Dhuy}Rco(W@JG65MloZM*ogkU^_aqM
zxNH?2OcZmzn*ZJ@G2^9k1>niAyN&Jy1V_CN1RKR=#3mG)3&#_!??j1369HM%YDm1)
zn5`lM=Kx_W1FITDf=I=MybtjqPi;&F=l=y@@l6c((YT;o1D7ATN2SS*^7vXn&t0#N
zv*-(P8Ko{lz&(g{UwJCE@W&ii88ulN;mTDQY(Wfxq%wr~)GDgh>Pyol2UE=VO$>S9
zEpo#Bk?(67?3{6a<YhFe*N#X>D<ifhhU60Fz2;}!c2L8Q$WHBN^odqO*j^2r^Rf&{
zq$XgD0fEPU7JSz~v!>&|o6hKVryn8bnDrsVC#<cr8{yO+GSMqAj#$K{X($!iQSwey
z=0Qs<A|@FhA7+$aszi&f2n^9{^Wj*4WqV5BAjyx#<zF#V$jmr%bjLOAtU?tB$R$Q#
zC@$KTgHdXTiN)>buodA?1x2V1*Dic<-4*`*!Wn>3ddXA)K6!AHC~A=;g$s=BOv%U?
zmN+Qy<i9<{z~Wf!&o}zShJUI2@Ezh*7xaCA*QUWb8*^OPz>|RcGGPGxs{-I)h)r*S
zES5AH4@t#>axQEnML%FVo2YW>&aJ?6tbt^<h>S+}A=YQAqTl>OWaqR6lKwNq(mS9g
zZ`!Azu&f!D!>n#zVyDCBMXtrfX%G5Rt!Fl}ehBgcMkuilT23~YUea|wumr(t)9`aq
z^G^)O@){+Bmgvo3<+i$ofJ<+KbvFjdnps-+gi?tCz#Ob-5U-Ew+riyDZ^xVMUlMJ7
zSE&6{21AS-5x#doQ~{i6Q@`w@wg-Xua|s6s9|h`UCGe?{vMK{N+BZ>)$6a)`UEZ7K
zG>B5HHCP8HE3at;4>$!zHsVmUSEoW3Z4Oe5o$iEYWZ@p_KOEy0zpX#XqVMnb<fjT3
z;-au8L)oKhw(~+!v~>fBJVfi%oNb6GABm*b_nx7_@$rRg`*6k(W-wX85G4lacl8=n
zb_^9XbHn6N#&RHyrTH!WJJY47$>@fja&;$UZRm@W?Z4{Tu;s7LkChFSPq9#0T6!Ar
zY#Mxkk&d`+ui|`5^D;6;2y_X)dVzLW`^{|0%mgOk=cB|Z{CC{L>at8+ag|2r$~PuW
z=-Z(E=THqY7%3v*8kMVb;82w2(5Utrbxbg7{;~6GLBtyFow!D@j1^vD?oFtS70Mo*
zq6gJjb8>$q6u9h8ZPY&rA9!=iU1v?FDd&otb}L65e#ZCqc*~huw!S_5vnB<Cg<LTZ
zqwVt$3s+_3&u9S;=-|cuNYQc`e^W=3b|FEu9khXsJjp-63bMCO+>th_+mHvb0>FfM
zfe|{!Okb@;KX;A%y76^6tJLL~Ay864Xkl^26TOD=CwHEw+hHT2pKhQ+J)Ir~U-|=G
zpT2}(+w^Zu+&=pWV_enzrGJml7qHX3VyQLU<m}SlW!JgGm5N2J^<5(f9m#cnG~72b
zh<52Y71hjlfA3LRrom$^2?beqA5ngD%NHCv=+s)V9{UVT?93YzNz;}q@zG~qX`{s|
z_V1^?xe_z<x)0~2lwB!@R>SzK7iKwP?}+h9%dF|7bQQ&HiYUlrue=U?BN^FwLhK~~
zR{Xzt<c0h*#RV`1R1Tdd4Xgy^iOZX{=sz*=)3&yN{_KNa6}A`4zbf<IOfMo7i0Wyc
zjIxm2e_DnKG|a(rszlN~GZyKc;TNHOt%KLxJ|X^fmwrXsJbR~hj(38+OaTxO9y`Cd
ztB8Vk88Tk7pu|m5=~b?N;C+j@@T^fi#b!?+szw$9nSN_|hd@W~p&v&MI@q05ZE<{m
zRk^)0M~-%;8@WSb4VV=nBr5M@=@p|vJ0k|)7G5f)^bQcq1uBi6>Ow5o>)nr?U?9{e
zwLXAjz*!?n6ta*YPxxZWn&kPM?%%s^J}_+_iKtmfwyj^Bi$7$FuQ*u%?<zW|V^lcf
z%^-Gg^7-G$o(27qKn`#0vbQ9&1#g2GkO+#F)gNP39Ir>CamN;p{ni_ah&2Wt*L;a&
z`YSKI=ia93@S!Lx5U9UULQN~%PWuF|^Nr=pa;oiLC_d~i6l2U0pV_JDCDq;fmLQR0
zm9X~oy92}@Pe~JzMjBAC(FtBQHSUV3RLrh(UX@~yx71k&_`;_B?+xu;ZdvgQ&&{u~
zrA@tw9st<e9HFc0&5<3dB&Qi&eUy(^M%>@zhVXc(Xk=LQ&#@i<a`m=&cEk7P<!gzE
zXsPTa?c?pQgkneb!^6MixP9k<I@ZB2W!A}?J=N)pEayULjVt38wO&dImCAZo>P(R~
zw-qMkWcx{7z0Xv!l??I(qB_GSUgLSz&}_xwHg@z{!RQQ2CrE{W-f=(K?ktVD38^<O
zZQ1go&R_1Oe~9nV?Y{kH$x##a??9}Q*5q@+UsQQXcQ<QZ*_?i%@7p4-8?E;3R8;4k
zr}G!3IlR0LGN?Y@bRZJb*qw9h1d-S#o?)pV8AS>#>HPUZ<Fk#M=P$3D#-rDI+Ca3!
z?Se(LIsxa()omdA!?*RMZnXO8)Jt{{(Zl_{u=(TPMv+%kb7RR9QbhaLnK%G}&bSYU
z=6^Tk--otacJ``CN=xI8i|!Ks2Gx*YVH3)z=tefY?Ieq}?{C`azc<#SGv7>g<)Qy1
zasKb0lYf?_O6O@dVUywe-m|YqGW#^9eq4GgQOZGHZ@lYgJia!*Ghh45Y7d8s7P~3T
zq4zq?4Jscj9<<7MFXqkf7$?v6UH?Pvjr4+hBPGwe4jdpw90%?;v>vhQ*1Jc|Dxc^U
zQq9gPM-7mcgjB1toyj=;jaic}GNR=UFZTMWM55SQKMNJ->g}&ztmmA3?Zw6H6|LK&
z?;Mq4|8pyRvNGVpq%l5xP;x!(`)rTPOqw*}^Z9iXe|^SLQ7M+*1cY9lqBB1|?$W1q
z6Sb$<PjsZ`tfDhr8b+6F(qbGK_@|wifOENoGb*^wK(PRR^+B8>d7%0I)b^N<<CJW%
z-q7E4d9bSz+Za#=kGHx&>lP~ndv{kY9DKa{hGzGE7+t#2lXJ%@$OVSyTXi1syiBam
zh)Gs@-C$`yOqZoR=5bMb|9}hT6sm-_v=5i5lB<rj{PVOqN;;}BThS&KH{t6Vz2cnK
zCWZgb%?^ED|J7Jk^mM90TCKdqy-LeX+7xrLk>}6zDfC;oMlypfI-vRYXdh-!5Kws^
zt}h)rps<$h?j(ad{++edG>q+|Bc^dNLL*^59`jem;|KW4XKF1zNF)zEBBx|IftH)Q
zImadsDZu#8Tw(3pe+==sK-rPOf5NBuX1aJ~8uR4yaA;3fktSr`66CiUr}0d)?YEa|
z`e;I|uaBF%5AFGA<?!79XP{G(;2G;xe%2Euu7i2Z+RvDNB%%j`P@c9lMiBRCu`4~2
zUiR)XHZ6%zk{B^wC#ln)^873(=T@LyNj<Gu(HemeVG!c@_;{8sN$^*4fsLz!K*Ss+
zPWq<r=;-K#HG-mfiGk>t>@)w0Mg_mOU!u{bWZ2SRe$c=2nOCY=F9A<0S88RY-+14r
zYmScpLsk~}ZvAOYN=n+$21D0MP`HE$9&pdh%A>JTw&vj9+jQNiBF?ic_D#XLH^26T
zAW#lt?)A(@Hx7f!@s$zTa=_Lz0>T53*I`prw4lB__hjg2-Bt2i%APw{aQ{~YQuX}#
zNeg#y@DT>!6#CG9Gcq;-0x%TlavQw;-PwhthYKCwH(EqU*U>F}Jjgl$@2N0ur|vYJ
z<3jhTx|(MS_yU0O?|^=K7taB_F^(a&kZ6XH1v+8=68j%9sPn3-`2wWgA2j-3bQW)9
zgns<wiA@j<V%+p`K=hIQ4)U~dIX==PRjPp@ybKL)E7SirUS=qfzFFqBouU86AyG`P
zb$Z}Fvb~1pZR1(T4l4n+z`;ZPDr7VQSSd&Bz*M+~P@#Ewc@Jr!%t!Gnzmk%>e=j($
zLlE+}DT?2DWu$b&v7P+bFLbSv-*$Pwo-sQgClkFy_stX(0z3edPbfJS#Yc~uUh)>d
zI{zp8G^mYW70J%cjdE)?|9;DG7^qg!`0&(wqh+k2$JB)sn_|zoR@#Jl$0tuZ`IW2f
zPEBBr`i6xZ?70B_O~@eIG6JuN7xY?h<gU5X8hiR!!)tlyNoi^62^zsK*Y;K^Q*TMU
zl$O_A8mxC<??=NRi(-d%ZRH97jUY3`iMo!E&im-llr|wYWQ_%k!W?l<n$dtlYnrtb
z0s*~~tzx;gCH9ZX{l2@Cpi)YJav_1W>q5Ek>~3Kb5;L^IfUJ(RO349}O{`Y@4O?38
zd7<=HQ%@iitR6op_O{kNrt>W2VDh>+jHpFfWpO<$m^TtTuwXpl>dp;+FBnES%VWMf
zBSo};CSerMluDK_Hx(e<a{)X#sA^`ZLOm0Le=jyUnNUj!A=NN(ajQjDq$=H4N7iwH
z<Ms<3Sc;9=+F8ycDQWyrre|9wBqZBr=va9rpF2(WLG4iuKe9lT<7nx=SLVQhMH86P
zaaXL6oXu-eo_g@8yWQbn3O|Inz#|}YPsu31LipX|`A4%Z54pU`RHlwQW~c*>GwziB
z`Mj<>bl;o;;uU==PfQ)n92{0PcdJZyS&A@MD%_TnQ&xEBGd=dUYWS7`#c+3HK9IUk
zVp|&vtxo2`zS<Jsoa1iwB^Ag(ZDJAG%4b)6njoym%j_?P><R6yv{F-?m(y<00D%!B
u40Zc*Xmtg*)H+f{pa~KGw<f{INVAK(oty7U6==WWfWFQZLf!@2kpBfFgWsh9

literal 0
HcmV?d00001

diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml
index e97b9dacb..da78c8e97 100644
--- a/gradle/compose.versions.toml
+++ b/gradle/compose.versions.toml
@@ -12,4 +12,5 @@ material3-core = "androidx.compose.material3:material3:1.0.0-alpha11"
 material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.9"
 material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref="compose" }
 
-accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" }
\ No newline at end of file
+accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" }
+accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref="accompanist" }
\ No newline at end of file