Преглед изворни кода

Use Voyager on Extension Details screen (#8576)

Andreas пре 2 година
родитељ
комит
f1b85ff39d

+ 6 - 1
app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt

@@ -3,7 +3,6 @@ package eu.kanade.domain.extension.interactor
 import eu.kanade.domain.source.service.SourcePreferences
 import eu.kanade.tachiyomi.extension.model.Extension
 import eu.kanade.tachiyomi.source.Source
-import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 
@@ -30,3 +29,9 @@ class GetExtensionSources(
         }
     }
 }
+
+data class ExtensionSourceItem(
+    val source: Source,
+    val enabled: Boolean,
+    val labelAsName: Boolean,
+)

+ 82 - 70
app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt

@@ -38,19 +38,18 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalUriHandler
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
+import eu.kanade.domain.extension.interactor.ExtensionSourceItem
 import eu.kanade.presentation.browse.components.ExtensionIcon
 import eu.kanade.presentation.components.AppBar
 import eu.kanade.presentation.components.AppBarActions
 import eu.kanade.presentation.components.DIVIDER_ALPHA
 import eu.kanade.presentation.components.Divider
 import eu.kanade.presentation.components.EmptyScreen
-import eu.kanade.presentation.components.LoadingScreen
 import eu.kanade.presentation.components.Scaffold
 import eu.kanade.presentation.components.ScrollbarLazyColumn
 import eu.kanade.presentation.components.WarningBanner
@@ -60,18 +59,22 @@ import eu.kanade.presentation.util.padding
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.extension.model.Extension
 import eu.kanade.tachiyomi.source.ConfigurableSource
-import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsPresenter
-import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
+import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsState
 import eu.kanade.tachiyomi.util.system.LocaleHelper
 
 @Composable
 fun ExtensionDetailsScreen(
     navigateUp: () -> Unit,
-    presenter: ExtensionDetailsPresenter,
+    state: ExtensionDetailsState,
     onClickSourcePreferences: (sourceId: Long) -> Unit,
+    onClickWhatsNew: () -> Unit,
+    onClickReadme: () -> Unit,
+    onClickEnableAll: () -> Unit,
+    onClickDisableAll: () -> Unit,
+    onClickClearCookies: () -> Unit,
+    onClickUninstall: () -> Unit,
+    onClickSource: (sourceId: Long) -> Unit,
 ) {
-    val uriHandler = LocalUriHandler.current
-
     Scaffold(
         topBar = { scrollBehavior ->
             AppBar(
@@ -80,19 +83,19 @@ fun ExtensionDetailsScreen(
                 actions = {
                     AppBarActions(
                         actions = buildList {
-                            if (presenter.extension?.isUnofficial == false) {
+                            if (state.extension?.isUnofficial == false) {
                                 add(
                                     AppBar.Action(
                                         title = stringResource(R.string.whats_new),
                                         icon = Icons.Outlined.History,
-                                        onClick = { uriHandler.openUri(presenter.getChangelogUrl()) },
+                                        onClick = onClickWhatsNew,
                                     ),
                                 )
                                 add(
                                     AppBar.Action(
                                         title = stringResource(R.string.action_faq_and_guides),
                                         icon = Icons.Outlined.HelpOutline,
-                                        onClick = { uriHandler.openUri(presenter.getReadmeUrl()) },
+                                        onClick = onClickReadme,
                                     ),
                                 )
                             }
@@ -100,15 +103,15 @@ fun ExtensionDetailsScreen(
                                 listOf(
                                     AppBar.OverflowAction(
                                         title = stringResource(R.string.action_enable_all),
-                                        onClick = { presenter.toggleSources(true) },
+                                        onClick = onClickEnableAll,
                                     ),
                                     AppBar.OverflowAction(
                                         title = stringResource(R.string.action_disable_all),
-                                        onClick = { presenter.toggleSources(false) },
+                                        onClick = onClickDisableAll,
                                     ),
                                     AppBar.OverflowAction(
                                         title = stringResource(R.string.pref_clear_cookies),
-                                        onClick = { presenter.clearCookies() },
+                                        onClick = onClickClearCookies,
                                     ),
                                 ),
                             )
@@ -119,77 +122,86 @@ fun ExtensionDetailsScreen(
             )
         },
     ) { paddingValues ->
-        ExtensionDetails(paddingValues, presenter, onClickSourcePreferences)
+
+        if (state.extension == null) {
+            EmptyScreen(
+                textResource = R.string.empty_screen,
+                modifier = Modifier.padding(paddingValues),
+            )
+            return@Scaffold
+        }
+
+        ExtensionDetails(
+            contentPadding = paddingValues,
+            extension = state.extension,
+            sources = state.sources,
+            onClickSourcePreferences = onClickSourcePreferences,
+            onClickUninstall = onClickUninstall,
+            onClickSource = onClickSource,
+        )
     }
 }
 
 @Composable
 private fun ExtensionDetails(
     contentPadding: PaddingValues,
-    presenter: ExtensionDetailsPresenter,
+    extension: Extension.Installed,
+    sources: List<ExtensionSourceItem>,
     onClickSourcePreferences: (sourceId: Long) -> Unit,
+    onClickUninstall: () -> Unit,
+    onClickSource: (sourceId: Long) -> Unit,
 ) {
-    when {
-        presenter.isLoading -> LoadingScreen()
-        presenter.extension == null -> EmptyScreen(
-            textResource = R.string.empty_screen,
-            modifier = Modifier.padding(contentPadding),
-        )
-        else -> {
-            val context = LocalContext.current
-            val extension = presenter.extension
-            var showNsfwWarning by remember { mutableStateOf(false) }
+    val context = LocalContext.current
+    var showNsfwWarning by remember { mutableStateOf(false) }
 
-            ScrollbarLazyColumn(
-                contentPadding = contentPadding,
-            ) {
-                when {
-                    extension.isUnofficial ->
-                        item {
-                            WarningBanner(R.string.unofficial_extension_message)
-                        }
-                    extension.isObsolete ->
-                        item {
-                            WarningBanner(R.string.obsolete_extension_message)
-                        }
+    ScrollbarLazyColumn(
+        contentPadding = contentPadding,
+    ) {
+        when {
+            extension.isUnofficial ->
+                item {
+                    WarningBanner(R.string.unofficial_extension_message)
                 }
-
+            extension.isObsolete ->
                 item {
-                    DetailsHeader(
-                        extension = extension,
-                        onClickUninstall = { presenter.uninstallExtension() },
-                        onClickAppInfo = {
-                            Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
-                                data = Uri.fromParts("package", extension.pkgName, null)
-                                context.startActivity(this)
-                            }
-                        },
-                        onClickAgeRating = {
-                            showNsfwWarning = true
-                        },
-                    )
+                    WarningBanner(R.string.obsolete_extension_message)
                 }
+        }
 
-                items(
-                    items = presenter.sources,
-                    key = { it.source.id },
-                ) { source ->
-                    SourceSwitchPreference(
-                        modifier = Modifier.animateItemPlacement(),
-                        source = source,
-                        onClickSourcePreferences = onClickSourcePreferences,
-                        onClickSource = { presenter.toggleSource(it) },
-                    )
-                }
-            }
-            if (showNsfwWarning) {
-                NsfwWarningDialog(
-                    onClickConfirm = {
-                        showNsfwWarning = false
-                    },
-                )
-            }
+        item {
+            DetailsHeader(
+                extension = extension,
+                onClickUninstall = onClickUninstall,
+                onClickAppInfo = {
+                    Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+                        data = Uri.fromParts("package", extension.pkgName, null)
+                        context.startActivity(this)
+                    }
+                },
+                onClickAgeRating = {
+                    showNsfwWarning = true
+                },
+            )
         }
+
+        items(
+            items = sources,
+            key = { it.source.id },
+        ) { source ->
+            SourceSwitchPreference(
+                modifier = Modifier.animateItemPlacement(),
+                source = source,
+                onClickSourcePreferences = onClickSourcePreferences,
+                onClickSource = onClickSource,
+            )
+        }
+    }
+    if (showNsfwWarning) {
+        NsfwWarningDialog(
+            onClickConfirm = {
+                showNsfwWarning = false
+            },
+        )
     }
 }
 

+ 0 - 25
app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsState.kt

@@ -1,25 +0,0 @@
-package eu.kanade.presentation.browse
-
-import androidx.compose.runtime.Stable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import eu.kanade.tachiyomi.extension.model.Extension
-import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
-
-@Stable
-interface ExtensionDetailsState {
-    val isLoading: Boolean
-    val extension: Extension.Installed?
-    val sources: List<ExtensionSourceItem>
-}
-
-fun ExtensionDetailsState(): ExtensionDetailsState {
-    return ExtensionDetailsStateImpl()
-}
-
-class ExtensionDetailsStateImpl : ExtensionDetailsState {
-    override var isLoading: Boolean by mutableStateOf(true)
-    override var extension: Extension.Installed? by mutableStateOf(null)
-    override var sources: List<ExtensionSourceItem> by mutableStateOf(emptyList())
-}

+ 12 - 22
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt

@@ -1,36 +1,26 @@
 package eu.kanade.tachiyomi.ui.browse.extension.details
 
-import android.annotation.SuppressLint
 import android.os.Bundle
 import androidx.compose.runtime.Composable
 import androidx.core.os.bundleOf
+import cafe.adriel.voyager.navigator.Navigator
 import eu.kanade.presentation.browse.ExtensionDetailsScreen
-import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
-import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
 
-@SuppressLint("RestrictedApi")
-class ExtensionDetailsController(
-    bundle: Bundle? = null,
-) : FullComposeController<ExtensionDetailsPresenter>(bundle) {
+private const val PKGNAME_KEY = "pkg_name"
+
+class ExtensionDetailsController : BasicFullComposeController {
 
-    constructor(pkgName: String) : this(
-        bundleOf(PKGNAME_KEY to pkgName),
-    )
+    @Suppress("unused")
+    constructor(bundle: Bundle) : this(bundle.getString(PKGNAME_KEY)!!)
 
-    override fun createPresenter() = ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!)
+    constructor(pkgName: String) : super(bundleOf(PKGNAME_KEY to pkgName))
+
+    val pkgName: String
+        get() = args.getString(PKGNAME_KEY)!!
 
     @Composable
     override fun ComposeContent() {
-        ExtensionDetailsScreen(
-            navigateUp = router::popCurrentController,
-            presenter = presenter,
-            onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) },
-        )
-    }
-
-    fun onExtensionUninstalled() {
-        router.popCurrentController()
+        Navigator(screen = ExtensionDetailsScreen(pkgName = pkgName))
     }
 }
-
-private const val PKGNAME_KEY = "pkg_name"

+ 0 - 145
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsPresenter.kt

@@ -1,145 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.extension.details
-
-import android.app.Application
-import android.os.Bundle
-import eu.kanade.domain.extension.interactor.GetExtensionSources
-import eu.kanade.domain.source.interactor.ToggleSource
-import eu.kanade.presentation.browse.ExtensionDetailsState
-import eu.kanade.presentation.browse.ExtensionDetailsStateImpl
-import eu.kanade.tachiyomi.extension.ExtensionManager
-import eu.kanade.tachiyomi.network.NetworkHelper
-import eu.kanade.tachiyomi.source.Source
-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.lang.withUIContext
-import eu.kanade.tachiyomi.util.system.LocaleHelper
-import eu.kanade.tachiyomi.util.system.logcat
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.map
-import okhttp3.HttpUrl.Companion.toHttpUrl
-import uy.kohesive.injekt.Injekt
-import uy.kohesive.injekt.api.get
-
-class ExtensionDetailsPresenter(
-    private val pkgName: String,
-    private val state: ExtensionDetailsStateImpl = ExtensionDetailsState() as ExtensionDetailsStateImpl,
-    private val context: Application = Injekt.get(),
-    private val getExtensionSources: GetExtensionSources = Injekt.get(),
-    private val toggleSource: ToggleSource = Injekt.get(),
-    private val network: NetworkHelper = Injekt.get(),
-    private val extensionManager: ExtensionManager = Injekt.get(),
-) : BasePresenter<ExtensionDetailsController>(), ExtensionDetailsState by state {
-
-    override fun onCreate(savedState: Bundle?) {
-        super.onCreate(savedState)
-
-        presenterScope.launchIO {
-            extensionManager.installedExtensionsFlow
-                .map { it.firstOrNull { pkg -> pkg.pkgName == pkgName } }
-                .collectLatest { extension ->
-                    // If extension is null it's most likely uninstalled
-                    if (extension == null) {
-                        withUIContext {
-                            view?.onExtensionUninstalled()
-                        }
-                        return@collectLatest
-                    }
-                    state.extension = extension
-                    fetchExtensionSources()
-                }
-        }
-    }
-
-    private fun CoroutineScope.fetchExtensionSources() {
-        launchIO {
-            getExtensionSources.subscribe(extension!!)
-                .map {
-                    it.sortedWith(
-                        compareBy(
-                            { item -> item.enabled.not() },
-                            { item -> if (item.labelAsName) item.source.name else LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase() },
-                        ),
-                    )
-                }
-                .collectLatest {
-                    state.isLoading = false
-                    state.sources = it
-                }
-        }
-    }
-
-    fun getChangelogUrl(): String {
-        extension ?: return ""
-
-        val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
-        val pkgFactory = extension.pkgFactory
-        if (extension.hasChangelog) {
-            return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
-        }
-
-        // Falling back on GitHub commit history because there is no explicit changelog in extension
-        return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
-    }
-
-    fun getReadmeUrl(): String {
-        extension ?: return ""
-
-        if (!extension.hasReadme) {
-            return "https://tachiyomi.org/help/faq/#extensions"
-        }
-
-        val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
-        val pkgFactory = extension.pkgFactory
-        return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
-    }
-
-    fun clearCookies() {
-        val urls = extension?.sources
-            ?.filterIsInstance<HttpSource>()
-            ?.map { it.baseUrl }
-            ?.distinct() ?: emptyList()
-
-        val cleared = urls.sumOf {
-            network.cookieManager.remove(it.toHttpUrl())
-        }
-
-        logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" }
-    }
-
-    fun uninstallExtension() {
-        val extension = extension ?: return
-        extensionManager.uninstallExtension(extension.pkgName)
-    }
-
-    fun toggleSource(sourceId: Long) {
-        toggleSource.await(sourceId)
-    }
-
-    fun toggleSources(enable: Boolean) {
-        extension?.sources
-            ?.map { it.id }
-            ?.let { toggleSource.await(it, enable) }
-    }
-
-    private fun createUrl(url: String, pkgName: String, pkgFactory: String?, path: String = ""): String {
-        return if (!pkgFactory.isNullOrEmpty()) {
-            when (path.isEmpty()) {
-                true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory"
-                else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path
-            }
-        } else {
-            url + "/src/" + pkgName.replace(".", "/") + path
-        }
-    }
-}
-
-data class ExtensionSourceItem(
-    val source: Source,
-    val enabled: Boolean,
-    val labelAsName: Boolean,
-)
-
-private const val URL_EXTENSION_COMMITS = "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
-private const val URL_EXTENSION_BLOB = "https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"

+ 57 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreen.kt

@@ -0,0 +1,57 @@
+package eu.kanade.tachiyomi.ui.browse.extension.details
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.presentation.browse.ExtensionDetailsScreen
+import eu.kanade.presentation.components.LoadingScreen
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import kotlinx.coroutines.flow.collectLatest
+
+class ExtensionDetailsScreen(
+    private val pkgName: String,
+) : Screen {
+
+    @Composable
+    override fun Content() {
+        val context = LocalContext.current
+        val screenModel = rememberScreenModel { ExtensionDetailsScreenModel(pkgName = pkgName, context = context) }
+        val state by screenModel.state.collectAsState()
+
+        if (state.isLoading) {
+            LoadingScreen()
+            return
+        }
+
+        val router = LocalRouter.currentOrThrow
+        val uriHandler = LocalUriHandler.current
+
+        ExtensionDetailsScreen(
+            navigateUp = router::popCurrentController,
+            state = state,
+            onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) },
+            onClickWhatsNew = { uriHandler.openUri(screenModel.getChangelogUrl()) },
+            onClickReadme = { uriHandler.openUri(screenModel.getReadmeUrl()) },
+            onClickEnableAll = { screenModel.toggleSources(true) },
+            onClickDisableAll = { screenModel.toggleSources(false) },
+            onClickClearCookies = { screenModel.clearCookies() },
+            onClickUninstall = { screenModel.uninstallExtension() },
+            onClickSource = { screenModel.toggleSource(it) },
+        )
+
+        LaunchedEffect(Unit) {
+            screenModel.events.collectLatest { event ->
+                if (event is ExtensionDetailsEvent.Uninstalled) {
+                    router.popCurrentController()
+                }
+            }
+        }
+    }
+}

+ 167 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsScreenModel.kt

@@ -0,0 +1,167 @@
+package eu.kanade.tachiyomi.ui.browse.extension.details
+
+import android.content.Context
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import eu.kanade.domain.extension.interactor.ExtensionSourceItem
+import eu.kanade.domain.extension.interactor.GetExtensionSources
+import eu.kanade.domain.source.interactor.ToggleSource
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.network.NetworkHelper
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.system.LocaleHelper
+import eu.kanade.tachiyomi.util.system.logcat
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+private const val URL_EXTENSION_COMMITS =
+    "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
+private const val URL_EXTENSION_BLOB =
+    "https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"
+
+class ExtensionDetailsScreenModel(
+    pkgName: String,
+    context: Context,
+    private val network: NetworkHelper = Injekt.get(),
+    private val extensionManager: ExtensionManager = Injekt.get(),
+    private val getExtensionSources: GetExtensionSources = Injekt.get(),
+    private val toggleSource: ToggleSource = Injekt.get(),
+) : StateScreenModel<ExtensionDetailsState>(ExtensionDetailsState()) {
+
+    private val _events: Channel<ExtensionDetailsEvent> = Channel()
+    val events: Flow<ExtensionDetailsEvent> = _events.receiveAsFlow()
+
+    init {
+        coroutineScope.launch {
+            launch {
+                extensionManager.installedExtensionsFlow
+                    .map { it.firstOrNull { extension -> extension.pkgName == pkgName } }
+                    .collectLatest { extension ->
+                        if (extension == null) {
+                            _events.send(ExtensionDetailsEvent.Uninstalled)
+                            return@collectLatest
+                        }
+                        mutableState.update { state ->
+                            state.copy(extension = extension)
+                        }
+                    }
+            }
+            launch {
+                state.collectLatest { state ->
+                    if (state.extension == null) return@collectLatest
+                    getExtensionSources.subscribe(state.extension)
+                        .map {
+                            it.sortedWith(
+                                compareBy(
+                                    { !it.enabled },
+                                    { item ->
+                                        item.source.name.takeIf { item.labelAsName }
+                                            ?: LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase()
+                                    },
+                                ),
+                            )
+                        }.collectLatest { sources ->
+                            mutableState.update {
+                                it.copy(
+                                    sources = sources,
+                                )
+                            }
+                        }
+                }
+            }
+        }
+    }
+
+    fun getChangelogUrl(): String {
+        val extension = state.value.extension ?: return ""
+
+        val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
+        val pkgFactory = extension.pkgFactory
+        if (extension.hasChangelog) {
+            return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
+        }
+
+        // Falling back on GitHub commit history because there is no explicit changelog in extension
+        return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
+    }
+
+    fun getReadmeUrl(): String {
+        val extension = state.value.extension ?: return ""
+
+        if (!extension.hasReadme) {
+            return "https://tachiyomi.org/help/faq/#extensions"
+        }
+
+        val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
+        val pkgFactory = extension.pkgFactory
+        return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
+    }
+
+    fun clearCookies() {
+        val extension = state.value.extension ?: return
+
+        val urls = extension.sources
+            .filterIsInstance<HttpSource>()
+            .map { it.baseUrl }
+            .distinct()
+
+        val cleared = urls.sumOf {
+            network.cookieManager.remove(it.toHttpUrl())
+        }
+
+        logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" }
+    }
+
+    fun uninstallExtension() {
+        val extension = state.value.extension ?: return
+        extensionManager.uninstallExtension(extension.pkgName)
+    }
+
+    fun toggleSource(sourceId: Long) {
+        toggleSource.await(sourceId)
+    }
+
+    fun toggleSources(enable: Boolean) {
+        state.value.extension?.sources
+            ?.map { it.id }
+            ?.let { toggleSource.await(it, enable) }
+    }
+
+    private fun createUrl(
+        url: String,
+        pkgName: String,
+        pkgFactory: String?,
+        path: String = "",
+    ): String {
+        return if (!pkgFactory.isNullOrEmpty()) {
+            when (path.isEmpty()) {
+                true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory"
+                else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path
+            }
+        } else {
+            url + "/src/" + pkgName.replace(".", "/") + path
+        }
+    }
+}
+
+sealed class ExtensionDetailsEvent {
+    object Uninstalled : ExtensionDetailsEvent()
+}
+
+data class ExtensionDetailsState(
+    val extension: Extension.Installed? = null,
+    val sources: List<ExtensionSourceItem> = emptyList(),
+) {
+
+    val isLoading: Boolean
+        get() = sources.isEmpty()
+}