Browse Source

Maintain source info in the database. (#6389)

* Maintain Source Info in database

* Review changes and cleanups

* Review changes 2

* Review Changes 3
AntsyLich 2 years ago
parent
commit
9d5b7de1d8
28 changed files with 307 additions and 48 deletions
  1. 8 1
      app/src/main/java/eu/kanade/data/source/SourceMapper.kt
  2. 9 0
      app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt
  3. 4 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  4. 20 0
      app/src/main/java/eu/kanade/domain/source/interactor/GetSourceData.kt
  5. 4 6
      app/src/main/java/eu/kanade/domain/source/interactor/GetSourcesWithFavoriteCount.kt
  6. 19 0
      app/src/main/java/eu/kanade/domain/source/interactor/UpsertSourceData.kt
  7. 1 0
      app/src/main/java/eu/kanade/domain/source/model/Source.kt
  8. 7 0
      app/src/main/java/eu/kanade/domain/source/model/SourceData.kt
  9. 5 0
      app/src/main/java/eu/kanade/domain/source/repository/SourceRepository.kt
  10. 57 0
      app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt
  11. 1 1
      app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt
  12. 6 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt
  13. 6 2
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt
  14. 13 0
      app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
  15. 16 10
      app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt
  16. 14 4
      app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt
  17. 3 0
      app/src/main/java/eu/kanade/tachiyomi/source/Source.kt
  18. 55 12
      app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt
  19. 4 1
      app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
  20. 3 1
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  21. 3 5
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt
  22. 2 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt
  23. 3 4
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseSourceItem.kt
  24. 9 0
      app/src/main/res/layout-sw720dp/manga_info_header.xml
  25. 9 0
      app/src/main/res/layout/manga_info_header.xml
  26. 1 0
      app/src/main/res/values/strings.xml
  27. 20 0
      app/src/main/sqldelight/data/sources.sq
  28. 5 0
      app/src/main/sqldelight/migrations/16.sqm

+ 8 - 1
app/src/main/java/eu/kanade/data/source/SourceMapper.kt

@@ -1,17 +1,24 @@
 package eu.kanade.data.source
 
 import eu.kanade.domain.source.model.Source
+import eu.kanade.domain.source.model.SourceData
 import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.SourceManager
 
 val sourceMapper: (eu.kanade.tachiyomi.source.Source) -> Source = { source ->
     Source(
         source.id,
         source.lang,
         source.name,
-        false,
+        supportsLatest = false,
+        isStub = source is SourceManager.StubSource,
     )
 }
 
 val catalogueSourceMapper: (CatalogueSource) -> Source = { source ->
     sourceMapper(source).copy(supportsLatest = source.supportsLatest)
 }
+
+val sourceDataMapper: (Long, String, String) -> SourceData = { id, lang, name ->
+    SourceData(id, lang, name)
+}

+ 9 - 0
app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt

@@ -2,6 +2,7 @@ package eu.kanade.data.source
 
 import eu.kanade.data.DatabaseHandler
 import eu.kanade.domain.source.model.Source
+import eu.kanade.domain.source.model.SourceData
 import eu.kanade.domain.source.repository.SourceRepository
 import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.SourceManager
@@ -49,4 +50,12 @@ class SourceRepositoryImpl(
             }
         }
     }
+
+    override suspend fun getSourceData(id: Long): SourceData? {
+        return handler.awaitOneOrNull { sourcesQueries.getSourceData(id, sourceDataMapper) }
+    }
+
+    override suspend fun upsertSourceData(id: Long, lang: String, name: String) {
+        handler.await { sourcesQueries.upsert(id, lang, name) }
+    }
 }

+ 4 - 0
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -27,12 +27,14 @@ import eu.kanade.domain.manga.interactor.UpdateManga
 import eu.kanade.domain.manga.repository.MangaRepository
 import eu.kanade.domain.source.interactor.GetEnabledSources
 import eu.kanade.domain.source.interactor.GetLanguagesWithSources
+import eu.kanade.domain.source.interactor.GetSourceData
 import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
 import eu.kanade.domain.source.interactor.GetSourcesWithNonLibraryManga
 import eu.kanade.domain.source.interactor.SetMigrateSorting
 import eu.kanade.domain.source.interactor.ToggleLanguage
 import eu.kanade.domain.source.interactor.ToggleSource
 import eu.kanade.domain.source.interactor.ToggleSourcePin
+import eu.kanade.domain.source.interactor.UpsertSourceData
 import eu.kanade.domain.source.repository.SourceRepository
 import uy.kohesive.injekt.api.InjektModule
 import uy.kohesive.injekt.api.InjektRegistrar
@@ -71,11 +73,13 @@ class DomainModule : InjektModule {
         addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
         addFactory { GetEnabledSources(get(), get()) }
         addFactory { GetLanguagesWithSources(get(), get()) }
+        addFactory { GetSourceData(get()) }
         addFactory { GetSourcesWithFavoriteCount(get(), get()) }
         addFactory { GetSourcesWithNonLibraryManga(get()) }
         addFactory { SetMigrateSorting(get()) }
         addFactory { ToggleLanguage(get()) }
         addFactory { ToggleSource(get()) }
         addFactory { ToggleSourcePin(get()) }
+        addFactory { UpsertSourceData(get()) }
     }
 }

+ 20 - 0
app/src/main/java/eu/kanade/domain/source/interactor/GetSourceData.kt

@@ -0,0 +1,20 @@
+package eu.kanade.domain.source.interactor
+
+import eu.kanade.domain.source.model.SourceData
+import eu.kanade.domain.source.repository.SourceRepository
+import eu.kanade.tachiyomi.util.system.logcat
+import logcat.LogPriority
+
+class GetSourceData(
+    private val repository: SourceRepository,
+) {
+
+    suspend fun await(id: Long): SourceData? {
+        return try {
+            repository.getSourceData(id)
+        } catch (e: Exception) {
+            logcat(LogPriority.ERROR, e)
+            null
+        }
+    }
+}

+ 4 - 6
app/src/main/java/eu/kanade/domain/source/interactor/GetSourcesWithFavoriteCount.kt

@@ -33,20 +33,18 @@ class GetSourcesWithFavoriteCount(
             strength = Collator.PRIMARY
         }
         val sortFn: (Pair<Source, Long>, Pair<Source, Long>) -> Int = { a, b ->
-            val id1 = a.first.name.toLongOrNull()
-            val id2 = b.first.name.toLongOrNull()
             when (sorting) {
                 SetMigrateSorting.Mode.ALPHABETICAL -> {
                     when {
-                        id1 != null && id2 == null -> -1
-                        id2 != null && id1 == null -> 1
+                        a.first.isStub && b.first.isStub.not() -> -1
+                        b.first.isStub && a.first.isStub.not() -> 1
                         else -> collator.compare(a.first.name.lowercase(locale), b.first.name.lowercase(locale))
                     }
                 }
                 SetMigrateSorting.Mode.TOTAL -> {
                     when {
-                        id1 != null && id2 == null -> -1
-                        id2 != null && id1 == null -> 1
+                        a.first.isStub && b.first.isStub.not() -> -1
+                        b.first.isStub && a.first.isStub.not() -> 1
                         else -> a.second.compareTo(b.second)
                     }
                 }

+ 19 - 0
app/src/main/java/eu/kanade/domain/source/interactor/UpsertSourceData.kt

@@ -0,0 +1,19 @@
+package eu.kanade.domain.source.interactor
+
+import eu.kanade.domain.source.model.SourceData
+import eu.kanade.domain.source.repository.SourceRepository
+import eu.kanade.tachiyomi.util.system.logcat
+import logcat.LogPriority
+
+class UpsertSourceData(
+    private val repository: SourceRepository,
+) {
+
+    suspend fun await(sourceData: SourceData) {
+        try {
+            repository.upsertSourceData(sourceData.id, sourceData.lang, sourceData.name)
+        } catch (e: Exception) {
+            logcat(LogPriority.ERROR, e)
+        }
+    }
+}

+ 1 - 0
app/src/main/java/eu/kanade/domain/source/model/Source.kt

@@ -12,6 +12,7 @@ data class Source(
     val lang: String,
     val name: String,
     val supportsLatest: Boolean,
+    val isStub: Boolean,
     val pin: Pins = Pins.unpinned,
     val isUsedLast: Boolean = false,
 ) {

+ 7 - 0
app/src/main/java/eu/kanade/domain/source/model/SourceData.kt

@@ -0,0 +1,7 @@
+package eu.kanade.domain.source.model
+
+data class SourceData(
+    val id: Long,
+    val lang: String,
+    val name: String,
+)

+ 5 - 0
app/src/main/java/eu/kanade/domain/source/repository/SourceRepository.kt

@@ -1,6 +1,7 @@
 package eu.kanade.domain.source.repository
 
 import eu.kanade.domain.source.model.Source
+import eu.kanade.domain.source.model.SourceData
 import kotlinx.coroutines.flow.Flow
 import eu.kanade.tachiyomi.source.Source as LoadedSource
 
@@ -13,4 +14,8 @@ interface SourceRepository {
     fun getSourcesWithFavoriteCount(): Flow<List<Pair<Source, Long>>>
 
     fun getSourcesWithNonLibraryManga(): Flow<List<Pair<LoadedSource, Long>>>
+
+    suspend fun getSourceData(id: Long): SourceData?
+
+    suspend fun upsertSourceData(id: Long, lang: String, name: String)
 }

+ 57 - 0
app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt

@@ -1,5 +1,9 @@
 package eu.kanade.presentation.browse
 
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.asPaddingValues
 import androidx.compose.foundation.layout.navigationBars
@@ -10,13 +14,18 @@ import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import eu.kanade.domain.source.model.Source
 import eu.kanade.presentation.browse.components.BaseSourceItem
+import eu.kanade.presentation.browse.components.SourceIcon
 import eu.kanade.presentation.components.EmptyScreen
 import eu.kanade.presentation.components.ItemBadges
 import eu.kanade.presentation.components.LoadingScreen
@@ -28,6 +37,7 @@ import eu.kanade.presentation.util.topPaddingValues
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceState
 import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter
+import eu.kanade.tachiyomi.util.system.LocaleHelper
 
 @Composable
 fun MigrateSourceScreen(
@@ -107,6 +117,53 @@ fun MigrateSourceItem(
         showLanguageInContent = source.lang != "",
         onClickItem = onClickItem,
         onLongClickItem = onLongClickItem,
+        icon = {
+            if (source.isStub) {
+                Image(
+                    painter = painterResource(R.drawable.ic_warning_white_24dp),
+                    contentDescription = "",
+                    colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
+                )
+            } else {
+                SourceIcon(source = source)
+            }
+        },
         action = { ItemBadges(primaryText = "$count") },
+        content = { source, showLanguageInContent ->
+            Column(
+                modifier = Modifier
+                    .padding(horizontal = horizontalPadding)
+                    .weight(1f),
+            ) {
+                Text(
+                    text = source.name.ifBlank { source.id.toString() },
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                    style = MaterialTheme.typography.bodyMedium,
+                )
+                Row(
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    verticalAlignment = Alignment.CenterVertically,
+                ) {
+                    if (showLanguageInContent) {
+                        Text(
+                            text = LocaleHelper.getDisplayName(source.lang),
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                            style = MaterialTheme.typography.bodySmall,
+                        )
+                    }
+                    if (source.isStub) {
+                        Text(
+                            text = stringResource(R.string.not_installed),
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                            style = MaterialTheme.typography.bodySmall,
+                            color = MaterialTheme.colorScheme.error,
+                        )
+                    }
+                }
+            }
+        },
     )
 }

+ 1 - 1
app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt

@@ -44,7 +44,7 @@ private val defaultContent: @Composable RowScope.(Source, Boolean) -> Unit = { s
             .weight(1f),
     ) {
         Text(
-            text = source.name,
+            text = source.name.ifBlank { source.id.toString() },
             maxLines = 1,
             overflow = TextOverflow.Ellipsis,
             style = MaterialTheme.typography.bodyMedium,

+ 6 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt

@@ -43,7 +43,12 @@ class FullBackupRestoreValidator : AbstractBackupRestoreValidator() {
         val sources = backup.backupSources.associate { it.sourceId to it.name }
         val missingSources = sources
             .filter { sourceManager.get(it.key) == null }
-            .values
+            .values.map {
+                val id = it.toLongOrNull()
+                if (id == null) it
+                else sourceManager.getOrStub(id).toString()
+            }
+            .distinct()
             .sorted()
 
         val trackers = backup.backupManga

+ 6 - 2
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt

@@ -71,7 +71,7 @@ class DownloadCache(
      */
     fun isChapterDownloaded(chapter: Chapter, manga: Manga, skipCache: Boolean): Boolean {
         if (skipCache) {
-            val source = sourceManager.get(manga.source) ?: return false
+            val source = sourceManager.getOrStub(manga.source)
             return provider.findChapterDir(chapter, manga, source) != null
         }
 
@@ -124,11 +124,15 @@ class DownloadCache(
     private fun renew() {
         val onlineSources = sourceManager.getOnlineSources()
 
+        val stubSources = sourceManager.getStubSources()
+
+        val allSource = onlineSources + stubSources
+
         val sourceDirs = rootDir.dir.listFiles()
             .orEmpty()
             .associate { it.name to SourceDirectory(it) }
             .mapNotNullKeys { entry ->
-                onlineSources.find { provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) }?.id
+                allSource.find { provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) }?.id
             }
 
         rootDir.files = sourceDirs

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt

@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension
 import android.content.Context
 import android.graphics.drawable.Drawable
 import com.jakewharton.rxrelay.BehaviorRelay
+import eu.kanade.domain.source.model.SourceData
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
@@ -90,8 +91,20 @@ class ExtensionManager(
             field = value
             availableExtensionsRelay.call(value)
             updatedInstalledExtensionsStatuses(value)
+            setupAvailableExtensionsSourcesDataMap(value)
         }
 
+    private var availableExtensionsSourcesData: Map<Long, SourceData> = mapOf()
+
+    private fun setupAvailableExtensionsSourcesDataMap(extensions: List<Extension.Available>) {
+        if (extensions.isEmpty()) return
+        availableExtensionsSourcesData = extensions
+            .flatMap { ext -> ext.sources.map { it.toSourceData() } }
+            .associateBy { it.id }
+    }
+
+    fun getSourceData(id: Long) = availableExtensionsSourcesData[id]
+
     /**
      * Relay used to notify the untrusted extensions.
      */

+ 16 - 10
app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt

@@ -2,7 +2,8 @@ package eu.kanade.tachiyomi.extension.api
 
 import android.content.Context
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.extension.model.AvailableExtensionSources
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.AvailableSources
 import eu.kanade.tachiyomi.extension.model.Extension
 import eu.kanade.tachiyomi.extension.model.LoadResult
 import eu.kanade.tachiyomi.extension.util.ExtensionLoader
@@ -22,6 +23,7 @@ internal class ExtensionGithubApi {
 
     private val networkService: NetworkHelper by injectLazy()
     private val preferences: PreferencesHelper by injectLazy()
+    private val extensionManager: ExtensionManager by injectLazy()
 
     private var requiresFallbackSource = false
 
@@ -54,15 +56,17 @@ internal class ExtensionGithubApi {
         }
     }
 
-    suspend fun checkForUpdates(context: Context): List<Extension.Installed>? {
+    suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<Extension.Installed>? {
         // Limit checks to once a day at most
-        if (Date().time < preferences.lastExtCheck().get() + TimeUnit.DAYS.toMillis(1)) {
+        if (fromAvailableExtensionList.not() && Date().time < preferences.lastExtCheck().get() + TimeUnit.DAYS.toMillis(1)) {
             return null
         }
 
-        val extensions = findExtensions()
-
-        preferences.lastExtCheck().set(Date().time)
+        val extensions = if (fromAvailableExtensionList) {
+            extensionManager.availableExtensions
+        } else {
+            findExtensions().also { preferences.lastExtCheck().set(Date().time) }
+        }
 
         val installedExtensions = ExtensionLoader.loadExtensions(context)
             .filterIsInstance<LoadResult.Success>()
@@ -105,11 +109,12 @@ internal class ExtensionGithubApi {
             }
     }
 
-    private fun List<ExtensionSourceJsonObject>.toExtensionSources(): List<AvailableExtensionSources> {
+    private fun List<ExtensionSourceJsonObject>.toExtensionSources(): List<AvailableSources> {
         return this.map {
-            AvailableExtensionSources(
-                name = it.name,
+            AvailableSources(
                 id = it.id,
+                lang = it.lang,
+                name = it.name,
                 baseUrl = it.baseUrl,
             )
         }
@@ -147,7 +152,8 @@ private data class ExtensionJsonObject(
 
 @Serializable
 private data class ExtensionSourceJsonObject(
-    val name: String,
     val id: Long,
+    val lang: String,
+    val name: String,
     val baseUrl: String,
 )

+ 14 - 4
app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt

@@ -1,6 +1,7 @@
 package eu.kanade.tachiyomi.extension.model
 
 import android.graphics.drawable.Drawable
+import eu.kanade.domain.source.model.SourceData
 import eu.kanade.tachiyomi.source.Source
 
 sealed class Extension {
@@ -40,7 +41,7 @@ sealed class Extension {
         override val isNsfw: Boolean,
         override val hasReadme: Boolean,
         override val hasChangelog: Boolean,
-        val sources: List<AvailableExtensionSources>,
+        val sources: List<AvailableSources>,
         val apkName: String,
         val iconUrl: String,
     ) : Extension()
@@ -58,8 +59,17 @@ sealed class Extension {
     ) : Extension()
 }
 
-data class AvailableExtensionSources(
-    val name: String,
+data class AvailableSources(
     val id: Long,
+    val lang: String,
+    val name: String,
     val baseUrl: String,
-)
+) {
+    fun toSourceData(): SourceData {
+        return SourceData(
+            id = this.id,
+            lang = this.lang,
+            name = this.name,
+        )
+    }
+}

+ 3 - 0
app/src/main/java/eu/kanade/tachiyomi/source/Source.kt

@@ -1,6 +1,7 @@
 package eu.kanade.tachiyomi.source
 
 import android.graphics.drawable.Drawable
+import eu.kanade.domain.source.model.SourceData
 import eu.kanade.tachiyomi.extension.ExtensionManager
 import eu.kanade.tachiyomi.source.model.Page
 import eu.kanade.tachiyomi.source.model.SChapter
@@ -102,3 +103,5 @@ interface Source : tachiyomi.source.Source {
 fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
 
 fun Source.getPreferenceKey(): String = "source_$id"
+
+fun Source.toSourceData(): SourceData = SourceData(id = id, lang = lang, name = name)

+ 55 - 12
app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt

@@ -1,21 +1,32 @@
 package eu.kanade.tachiyomi.source
 
 import android.content.Context
+import eu.kanade.domain.source.interactor.GetSourceData
+import eu.kanade.domain.source.interactor.UpsertSourceData
+import eu.kanade.domain.source.model.SourceData
 import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.extension.ExtensionManager
 import eu.kanade.tachiyomi.source.model.Page
 import eu.kanade.tachiyomi.source.model.SChapter
 import eu.kanade.tachiyomi.source.model.SManga
 import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.lang.launchIO
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.runBlocking
 import rx.Observable
 import tachiyomi.source.model.ChapterInfo
 import tachiyomi.source.model.MangaInfo
+import uy.kohesive.injekt.injectLazy
 
 class SourceManager(private val context: Context) {
 
+    private val extensionManager: ExtensionManager by injectLazy()
+    private val getSourceData: GetSourceData by injectLazy()
+    private val upsertSourceData: UpsertSourceData by injectLazy()
+
     private val sourcesMap = mutableMapOf<Long, Source>()
     private val stubSourcesMap = mutableMapOf<Long, StubSource>()
 
@@ -34,7 +45,7 @@ class SourceManager(private val context: Context) {
 
     fun getOrStub(sourceKey: Long): Source {
         return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
-            StubSource(sourceKey)
+            runBlocking { createStubSource(sourceKey) }
         }
     }
 
@@ -42,16 +53,32 @@ class SourceManager(private val context: Context) {
 
     fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
 
+    fun getStubSources(): List<StubSource> {
+        val onlineSourceIds = getOnlineSources().map { it.id }
+        return stubSourcesMap.values.filterNot { it.id in onlineSourceIds }
+    }
+
     internal fun registerSource(source: Source) {
         if (!sourcesMap.containsKey(source.id)) {
             sourcesMap[source.id] = source
         }
-        if (!stubSourcesMap.containsKey(source.id)) {
-            stubSourcesMap[source.id] = StubSource(source.id)
-        }
+        registerStubSource(source.toSourceData())
         triggerCatalogueSources()
     }
 
+    private fun registerStubSource(sourceData: SourceData) {
+        launchIO {
+            val dbSourceData = getSourceData.await(sourceData.id)
+
+            if (dbSourceData != sourceData) {
+                upsertSourceData.await(sourceData)
+            }
+            if (stubSourcesMap[sourceData.id]?.toSourceData() != sourceData) {
+                stubSourcesMap[sourceData.id] = StubSource(sourceData)
+            }
+        }
+    }
+
     internal fun unregisterSource(source: Source) {
         sourcesMap.remove(source.id)
         triggerCatalogueSources()
@@ -67,11 +94,24 @@ class SourceManager(private val context: Context) {
         LocalSource(context),
     )
 
+    private suspend fun createStubSource(id: Long): StubSource {
+        getSourceData.await(id)?.let {
+            return StubSource(it)
+        }
+        extensionManager.getSourceData(id)?.let {
+            registerStubSource(it)
+            return StubSource(it)
+        }
+        return StubSource(SourceData(id, "", ""))
+    }
     @Suppress("OverridingDeprecatedMember")
-    inner class StubSource(override val id: Long) : Source {
+    open inner class StubSource(val sourceData: SourceData) : Source {
+
+        override val name: String = sourceData.name
+
+        override val lang: String = sourceData.lang
 
-        override val name: String
-            get() = id.toString()
+        override val id: Long = sourceData.id
 
         override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo {
             throw getSourceNotInstalledException()
@@ -98,14 +138,17 @@ class SourceManager(private val context: Context) {
         }
 
         override fun toString(): String {
-            return name
+            if (name.isNotBlank() && lang.isNotBlank()) {
+                return "$name (${lang.uppercase()})"
+            }
+            return id.toString()
         }
 
-        private fun getSourceNotInstalledException(): SourceNotInstalledException {
-            return SourceNotInstalledException(id)
+        fun getSourceNotInstalledException(): SourceNotInstalledException {
+            return SourceNotInstalledException(toString())
         }
     }
 
-    inner class SourceNotInstalledException(val id: Long) :
-        Exception(context.getString(R.string.source_not_installed, id.toString()))
+    inner class SourceNotInstalledException(val sourceString: String) :
+        Exception(context.getString(R.string.source_not_installed, sourceString))
 }

+ 4 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

@@ -366,7 +366,10 @@ class MainActivity : BaseActivity() {
 
             // Extension updates
             try {
-                ExtensionGithubApi().checkForUpdates(this@MainActivity)?.let { pendingUpdates ->
+                ExtensionGithubApi().checkForUpdates(
+                    this@MainActivity,
+                    fromAvailableExtensionList = true
+                )?.let { pendingUpdates ->
                     preferences.extensionUpdatesCount().set(pendingUpdates.size)
                 }
             } catch (e: Exception) {

+ 3 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt

@@ -1140,7 +1140,9 @@ class MangaController :
 
     private fun downloadChapters(chapters: List<ChapterItem>) {
         if (source is SourceManager.StubSource) {
-            activity?.toast(R.string.loader_not_implemented_error)
+            activity?.let {
+                it.toast(it.getString(R.string.source_not_installed, source?.toString().orEmpty()))
+            }
             return
         }
 

+ 3 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt

@@ -228,11 +228,7 @@ class MangaInfoHeaderAdapter(
          */
         private fun setMangaInfo() {
             // Update full title TextView.
-            binding.mangaFullTitle.text = if (manga.title.isBlank()) {
-                view.context.getString(R.string.unknown)
-            } else {
-                manga.title
-            }
+            binding.mangaFullTitle.text = manga.title.ifBlank { view.context.getString(R.string.unknown) }
 
             // Update author TextView.
             binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) {
@@ -249,6 +245,8 @@ class MangaInfoHeaderAdapter(
             }
 
             // If manga source is known update source TextView.
+            binding.mangaMissingSourceIcon.isVisible = source is SourceManager.StubSource
+
             val mangaSource = source.toString()
             with(binding.mangaSource) {
                 val enabledLanguages = preferences.enabledLanguages().get()

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt

@@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
 import eu.kanade.tachiyomi.util.system.logcat
@@ -87,6 +88,7 @@ class ChapterLoader(
                     is LocalSource.Format.Epub -> EpubPageLoader(format.file)
                 }
             }
+            source is SourceManager.StubSource -> throw source.getSourceNotInstalledException()
             else -> error(context.getString(R.string.loader_not_implemented_error))
         }
     }

+ 3 - 4
app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseSourceItem.kt

@@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.databinding.ClearDatabaseSourceItemBinding
 import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.Source
-import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.icon
 
 data class ClearDatabaseSourceItem(val source: Source, private val mangaCount: Long) : AbstractFlexibleItem<ClearDatabaseSourceItem.Holder>() {
@@ -37,9 +36,9 @@ data class ClearDatabaseSourceItem(val source: Source, private val mangaCount: L
 
             itemView.post {
                 when {
-                    source.id == LocalSource.ID -> binding.thumbnail.setImageResource(R.mipmap.ic_local_source)
-                    source is SourceManager.StubSource -> binding.thumbnail.setImageDrawable(null)
-                    source.icon() != null -> binding.thumbnail.setImageDrawable(source.icon())
+                    source.icon() != null && source.id != LocalSource.ID ->
+                        binding.thumbnail.setImageDrawable(source.icon())
+                    else -> binding.thumbnail.setImageResource(R.mipmap.ic_local_source)
                 }
             }
 

+ 9 - 0
app/src/main/res/layout-sw720dp/manga_info_header.xml

@@ -117,6 +117,15 @@
                 android:textIsSelectable="false"
                 tools:text="Status" />
 
+            <ImageView
+                android:id="@+id/manga_missing_source_icon"
+                android:layout_width="16dp"
+                android:layout_height="match_parent"
+                android:layout_marginEnd="4dp"
+                app:srcCompat="@drawable/ic_warning_white_24dp"
+                app:tint="@color/error"
+                tools:ignore="ContentDescription" />
+
             <TextView
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"

+ 9 - 0
app/src/main/res/layout/manga_info_header.xml

@@ -133,6 +133,15 @@
                 android:textIsSelectable="false"
                 tools:ignore="HardcodedText" />
 
+            <ImageView
+                android:id="@+id/manga_missing_source_icon"
+                android:layout_width="16dp"
+                android:layout_height="match_parent"
+                android:layout_marginEnd="4dp"
+                app:srcCompat="@drawable/ic_warning_white_24dp"
+                app:tint="@color/error"
+                tools:ignore="ContentDescription" />
+
             <TextView
                 android:id="@+id/manga_source"
                 android:layout_width="wrap_content"

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -737,6 +737,7 @@
     <string name="migrate">Migrate</string>
     <string name="copy">Copy</string>
     <string name="empty_screen">Well, this is awkward</string>
+    <string name="not_installed">Not installed</string>
 
     <!-- Downloads activity and service -->
     <string name="download_queue_error">Couldn\'t download chapters. You can try again in the downloads section</string>

+ 20 - 0
app/src/main/sqldelight/data/sources.sq

@@ -0,0 +1,20 @@
+CREATE TABLE sources(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    lang TEXT NOT NULL,
+    name TEXT NOT NULL
+);
+
+getSourceData:
+SELECT *
+FROM sources
+WHERE _id = :id;
+
+upsert:
+INSERT INTO sources(_id, lang, name)
+VALUES (:id, :lang, :name)
+ON CONFLICT(_id)
+DO UPDATE
+SET
+    lang = :lang,
+    name = :name
+WHERE _id = :id;

+ 5 - 0
app/src/main/sqldelight/migrations/16.sqm

@@ -0,0 +1,5 @@
+CREATE TABLE sources(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    lang TEXT NOT NULL,
+    name TEXT NOT NULL
+);