$alertMsg ${versesSorted.joinToString(", ")}
diff --git a/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt b/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt
index 2062a273f..7e5bad8f9 100644
--- a/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt
+++ b/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt
@@ -17,6 +17,7 @@ import com.quranapp.android.utils.reader.QuranScriptUtils
import com.quranapp.android.utils.reader.QuranScriptVariant
import com.quranapp.android.utils.reader.ReaderTextSizeUtils
import com.quranapp.android.utils.reader.TranslUtils
+import com.quranapp.android.utils.reader.factory.QuranTranslationFactory
import com.quranapp.android.utils.reader.isKFQPCScript
import com.quranapp.android.utils.reader.isQuranAtlasScript
import com.quranapp.android.utils.reader.tafsir.TafsirManager
@@ -193,6 +194,7 @@ object ReaderPreferences {
val appCtx = context.applicationContext
val storedScript = DataStoreManager.read(KEY_SCRIPT)
val storedVariant = DataStoreManager.read(KEY_SCRIPT_VARIANT)
+ val storedTranslations = DataStoreManager.read(KEY_TRANSLATIONS)
var working = QuranScriptUtils.validatePreferredScript(storedScript)
@@ -226,7 +228,19 @@ object ReaderPreferences {
else -> ""
}
- if (working != storedScript || targetVariant != storedVariant) {
+ val availableTranslationSlugs = QuranTranslationFactory(appCtx).use { factory ->
+ factory.getAvailableTranslationBooksInfo().keys
+ }
+ val repairedTranslations = storedTranslations.filterTo(hashSetOf()) {
+ it in availableTranslationSlugs
+ }.ifEmpty {
+ TranslUtils.defaultTranslationSlugs()
+ }
+
+ if (working != storedScript ||
+ targetVariant != storedVariant ||
+ repairedTranslations != storedTranslations
+ ) {
DataStoreManager.edit {
if (working != storedScript) {
this[KEY_SCRIPT.key] = working
@@ -235,6 +249,10 @@ object ReaderPreferences {
if (targetVariant != storedVariant) {
this[KEY_SCRIPT_VARIANT.key] = targetVariant
}
+
+ if (repairedTranslations != storedTranslations) {
+ this[KEY_TRANSLATIONS.key] = repairedTranslations
+ }
}
}
}
diff --git a/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt b/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt
index 0f771631c..e4411b8d2 100644
--- a/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt
+++ b/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt
@@ -6,6 +6,7 @@ import com.quranapp.android.db.migrations.ExternalQuranDatabaseMigrations
import com.quranapp.android.db.searchindex.SearchIndexDatabase
import com.quranapp.android.db.translation.QuranTranslDBHelper
import com.quranapp.android.repository.QuranRepository
+import com.quranapp.android.repository.TopicsRepository
import com.quranapp.android.repository.UserRepository
object DatabaseProvider {
@@ -16,12 +17,24 @@ object DatabaseProvider {
@Volatile
private var userRepository: UserRepository? = null
+ @Volatile
+ private var quranDatabase: QuranDatabase? = null
+
@Volatile
private var quranRepository: QuranRepository? = null
+ @Volatile
+ private var externalQuranDatabase: ExternalQuranDatabase? = null
+
@Volatile
private var searchIndexDatabase: SearchIndexDatabase? = null
+ @Volatile
+ private var topicsDatabase: TopicsDatabase? = null
+
+ @Volatile
+ private var topicsRepository: TopicsRepository? = null
+
@Volatile
private var quranTranslDbHelper: QuranTranslDBHelper? = null
@@ -49,28 +62,34 @@ object DatabaseProvider {
}
private fun getQuranDatabase(context: Context): QuranDatabase {
- return Room.databaseBuilder(
- context.applicationContext,
- QuranDatabase::class.java,
- "quranapp"
- )
- .createFromAsset("db/quranapp.db")
- .fallbackToDestructiveMigration(true)
- .build()
+ return quranDatabase ?: synchronized(this) {
+ quranDatabase ?: Room.databaseBuilder(
+ context.applicationContext,
+ QuranDatabase::class.java,
+ "quranapp"
+ )
+ .createFromAsset("db/quranapp.db")
+ .fallbackToDestructiveMigration(true)
+ .build()
+ .also { quranDatabase = it }
+ }
}
fun getExternalQuranDatabase(context: Context): ExternalQuranDatabase {
- return Room.databaseBuilder(
- context.applicationContext,
- ExternalQuranDatabase::class.java,
- "quranapp_external"
- )
- .addMigrations(
- ExternalQuranDatabaseMigrations.MIGRATION_1_2,
- ExternalQuranDatabaseMigrations.MIGRATION_2_3,
+ return externalQuranDatabase ?: synchronized(this) {
+ externalQuranDatabase ?: Room.databaseBuilder(
+ context.applicationContext,
+ ExternalQuranDatabase::class.java,
+ "quranapp_external"
)
- .fallbackToDestructiveMigration(false)
- .build()
+ .addMigrations(
+ ExternalQuranDatabaseMigrations.MIGRATION_1_2,
+ ExternalQuranDatabaseMigrations.MIGRATION_2_3,
+ )
+ .fallbackToDestructiveMigration(false)
+ .build()
+ .also { externalQuranDatabase = it }
+ }
}
fun getQuranRepository(context: Context): QuranRepository {
@@ -95,6 +114,29 @@ object DatabaseProvider {
}
}
+ private fun getTopicsDatabase(context: Context): TopicsDatabase {
+ return topicsDatabase ?: synchronized(this) {
+ topicsDatabase ?: Room.databaseBuilder(
+ context.applicationContext,
+ TopicsDatabase::class.java,
+ "topics"
+ )
+ .createFromAsset("db/topics.db")
+ .fallbackToDestructiveMigration(true)
+ .build()
+ .also { topicsDatabase = it }
+ }
+ }
+
+ fun getTopicsRepository(context: Context): TopicsRepository {
+ return topicsRepository ?: synchronized(this) {
+ topicsRepository ?: TopicsRepository(
+ context.applicationContext,
+ getTopicsDatabase(context),
+ ).also { topicsRepository = it }
+ }
+ }
+
fun getQuranTranslDBHelper(context: Context): QuranTranslDBHelper {
return quranTranslDbHelper ?: synchronized(this) {
quranTranslDbHelper ?: QuranTranslDBHelper(context.applicationContext).also {
@@ -102,4 +144,18 @@ object DatabaseProvider {
}
}
}
+
+ fun closeAll() {
+ synchronized(this) {
+ userDatabase?.close(); userDatabase = null
+ quranDatabase?.close(); quranDatabase = null
+ externalQuranDatabase?.close(); externalQuranDatabase = null
+ searchIndexDatabase?.close(); searchIndexDatabase = null
+ topicsDatabase?.close(); topicsDatabase = null
+ userRepository = null
+ quranRepository = null
+ topicsRepository = null
+ quranTranslDbHelper = null
+ }
+ }
}
diff --git a/app/src/main/java/com/quranapp/android/db/TopicsDatabase.kt b/app/src/main/java/com/quranapp/android/db/TopicsDatabase.kt
new file mode 100644
index 000000000..c03568b44
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/TopicsDatabase.kt
@@ -0,0 +1,27 @@
+package com.quranapp.android.db
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import com.quranapp.android.db.converters.QuranConverters
+import com.quranapp.android.db.converters.TopicsDbConverters
+import com.quranapp.android.db.dao.TopicsDao
+import com.quranapp.android.db.entities.topics.RelationshipEntity
+import com.quranapp.android.db.entities.topics.TopicAyahEntity
+import com.quranapp.android.db.entities.topics.TopicEntity
+import com.quranapp.android.db.entities.topics.TopicLocalizationEntity
+
+@Database(
+ entities = [
+ TopicEntity::class,
+ TopicLocalizationEntity::class,
+ TopicAyahEntity::class,
+ RelationshipEntity::class,
+ ],
+ version = 1,
+ exportSchema = false
+)
+@TypeConverters(QuranConverters::class, TopicsDbConverters::class)
+abstract class TopicsDatabase : RoomDatabase() {
+ abstract fun topicsDao(): TopicsDao
+}
diff --git a/app/src/main/java/com/quranapp/android/db/UserDatabase.kt b/app/src/main/java/com/quranapp/android/db/UserDatabase.kt
index b9d865258..f013194e7 100644
--- a/app/src/main/java/com/quranapp/android/db/UserDatabase.kt
+++ b/app/src/main/java/com/quranapp/android/db/UserDatabase.kt
@@ -8,8 +8,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.quranapp.android.db.converters.DbConverters
import com.quranapp.android.db.dao.BookmarkDao
import com.quranapp.android.db.dao.ReadHistoryDao
-import com.quranapp.android.db.entities.BookmarkEntity
-import com.quranapp.android.db.entities.ReadHistoryEntity
+import com.quranapp.android.db.entities.user.BookmarkEntity
+import com.quranapp.android.db.entities.user.ReadHistoryEntity
@Database(
entities = [BookmarkEntity::class, ReadHistoryEntity::class],
@@ -42,4 +42,4 @@ abstract class UserDatabase : RoomDatabase() {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt b/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt
index 1bd59a13c..8d42330a9 100644
--- a/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt
+++ b/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt
@@ -3,7 +3,7 @@ package com.quranapp.android.db.bookmark
import android.content.Context
import androidx.core.content.edit
import com.quranapp.android.db.DatabaseProvider
-import com.quranapp.android.db.entities.BookmarkEntity
+import com.quranapp.android.db.entities.user.BookmarkEntity
import com.quranapp.android.db.readHistory.ReadHistoryMigration
import com.quranapp.android.utils.Log
import com.quranapp.android.utils.univ.DateUtils
@@ -87,4 +87,4 @@ class UserDataMigrationManager(
migrateBookmarksIfNeeded()
migrateReadHistoryIfNeeded()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt b/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt
index 05cd91678..15459d733 100644
--- a/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt
+++ b/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt
@@ -26,4 +26,4 @@ class QuranConverters {
@TypeConverter
fun toMushafLineType(value: String?): MushafLineType? =
value?.let { MushafLineType.valueOf(it) }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/db/converters/TopicsDbConverters.kt b/app/src/main/java/com/quranapp/android/db/converters/TopicsDbConverters.kt
new file mode 100644
index 000000000..0871e803b
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/converters/TopicsDbConverters.kt
@@ -0,0 +1,24 @@
+package com.quranapp.android.db.converters
+
+import androidx.room.TypeConverter
+import com.quranapp.android.db.entities.topics.RelationshipType
+import com.quranapp.android.db.entities.topics.TopicFlags
+import com.quranapp.android.db.entities.quran.MushafLineType
+import com.quranapp.android.db.entities.quran.NavigationType
+import com.quranapp.android.db.entities.quran.RevelationType
+
+class TopicsDbConverters {
+ @TypeConverter
+ fun fromRelationshipType(value: RelationshipType?): String? = value?.dbValue
+
+ @TypeConverter
+ fun toRelationshipType(value: String?): RelationshipType? =
+ value?.let { RelationshipType.fromDbValue(it) }
+
+ @TypeConverter
+ fun fromTopicFlags(value: TopicFlags?): Int? = value?.dbValue
+
+ @TypeConverter
+ fun toTopicFlags(value: Int?): TopicFlags? =
+ value?.let { TopicFlags.fromDbValue(it) }
+}
diff --git a/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt b/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt
index 7d0d8d5f5..d2ccd7829 100644
--- a/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt
+++ b/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt
@@ -6,7 +6,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
-import com.quranapp.android.db.entities.BookmarkEntity
+import com.quranapp.android.db.entities.user.BookmarkEntity
import kotlinx.coroutines.flow.Flow
@Dao
@@ -113,4 +113,4 @@ interface BookmarkDao {
fromVerse: Int,
toVerse: Int
): Flow
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt b/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt
index 279a396de..1cc1237a4 100644
--- a/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt
+++ b/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt
@@ -5,8 +5,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
-import com.quranapp.android.db.entities.BookmarkEntity
-import com.quranapp.android.db.entities.ReadHistoryEntity
+import com.quranapp.android.db.entities.user.ReadHistoryEntity
import kotlinx.coroutines.flow.Flow
@Dao
diff --git a/app/src/main/java/com/quranapp/android/db/dao/TopicsDao.kt b/app/src/main/java/com/quranapp/android/db/dao/TopicsDao.kt
new file mode 100644
index 000000000..f6d4761a7
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/dao/TopicsDao.kt
@@ -0,0 +1,432 @@
+package com.quranapp.android.db.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
+import com.quranapp.android.db.entities.topics.RelationshipType
+import com.quranapp.android.db.relations.topics.TopicParentEdgeRow
+import com.quranapp.android.db.relations.topics.TopicHierarchyEdgeRow
+import com.quranapp.android.db.relations.topics.TopicRelationshipRow
+import com.quranapp.android.db.relations.topics.TopicSearchCandidateRow
+import com.quranapp.android.db.relations.topics.TopicSummaryRow
+import com.quranapp.android.db.relations.topics.TopicVerseRow
+
+@Dao
+@RewriteQueriesToDropUnusedColumns
+interface TopicsDao {
+ @Query(
+ """
+ SELECT
+ t.id AS topicId,
+ t.slug AS slug,
+ t.type AS type,
+ t.image_url AS imageUrl,
+ t.icon AS icon,
+ t.flags AS flags,
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title,
+ ar.title AS arabicTitle,
+ COALESCE(loc.short_description, en.short_description) AS shortDescription,
+ COALESCE(loc.description, en.description) AS description,
+ (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships child_rel
+ WHERE child_rel.tgt_topic_id = t.id
+ AND child_rel.type = :parentType
+ ) AS childCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships related_rel
+ WHERE related_rel.type = 'related'
+ AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id)
+ ) AS relatedCount
+ FROM topics t
+ LEFT JOIN topic_localizations loc
+ ON loc.topic_id = t.id AND loc.lang_code = :langCode
+ LEFT JOIN topic_localizations en
+ ON en.topic_id = t.id AND en.lang_code = 'en'
+ LEFT JOIN topic_localizations ar
+ ON ar.topic_id = t.id AND ar.lang_code = 'ar'
+ WHERE (COALESCE(t.flags, 0) & :flagMask) != 0
+ AND NOT EXISTS (
+ SELECT 1
+ FROM relationships parent_rel
+ WHERE parent_rel.src_topic_id = t.id
+ AND parent_rel.type = :parentType
+ )
+ ORDER BY LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, ''))
+ """
+ )
+ suspend fun getRootTopics(
+ flagMask: Int,
+ parentType: RelationshipType,
+ langCode: String,
+ ): List
+
+ @Query(
+ """
+ SELECT
+ t.id AS topicId,
+ t.slug AS slug,
+ t.type AS type,
+ t.image_url AS imageUrl,
+ t.icon AS icon,
+ t.flags AS flags,
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title,
+ ar.title AS arabicTitle,
+ COALESCE(loc.short_description, en.short_description) AS shortDescription,
+ COALESCE(loc.description, en.description) AS description,
+ (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships child_rel
+ WHERE child_rel.tgt_topic_id = t.id
+ AND child_rel.type = :parentType
+ ) AS childCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships related_rel
+ WHERE related_rel.type = 'related'
+ AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id)
+ ) AS relatedCount
+ FROM topics t
+ LEFT JOIN topic_localizations loc
+ ON loc.topic_id = t.id AND loc.lang_code = :langCode
+ LEFT JOIN topic_localizations en
+ ON en.topic_id = t.id AND en.lang_code = 'en'
+ LEFT JOIN topic_localizations ar
+ ON ar.topic_id = t.id AND ar.lang_code = 'ar'
+ WHERE t.id = :topicId
+ LIMIT 1
+ """
+ )
+ suspend fun getTopicById(
+ topicId: Int,
+ parentType: RelationshipType,
+ langCode: String,
+ ): TopicSummaryRow?
+
+ @Query(
+ """
+ SELECT
+ t.id AS topicId,
+ t.slug AS slug,
+ t.type AS type,
+ t.image_url AS imageUrl,
+ t.icon AS icon,
+ t.flags AS flags,
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title,
+ ar.title AS arabicTitle,
+ COALESCE(loc.short_description, en.short_description) AS shortDescription,
+ COALESCE(loc.description, en.description) AS description,
+ (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships child_rel
+ WHERE child_rel.tgt_topic_id = t.id
+ AND child_rel.type = :parentType
+ ) AS childCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships related_rel
+ WHERE related_rel.type = 'related'
+ AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id)
+ ) AS relatedCount
+ FROM topics t
+ LEFT JOIN topic_localizations loc
+ ON loc.topic_id = t.id AND loc.lang_code = :langCode
+ LEFT JOIN topic_localizations en
+ ON en.topic_id = t.id AND en.lang_code = 'en'
+ LEFT JOIN topic_localizations ar
+ ON ar.topic_id = t.id AND ar.lang_code = 'ar'
+ WHERE t.slug = :slug
+ LIMIT 1
+ """
+ )
+ suspend fun getTopicBySlug(
+ slug: String,
+ parentType: RelationshipType,
+ langCode: String,
+ ): TopicSummaryRow?
+
+ @Query(
+ """
+ SELECT
+ t.id AS topicId,
+ t.slug AS slug,
+ t.type AS type,
+ t.image_url AS imageUrl,
+ t.icon AS icon,
+ t.flags AS flags,
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title,
+ ar.title AS arabicTitle,
+ COALESCE(loc.short_description, en.short_description) AS shortDescription,
+ COALESCE(loc.description, en.description) AS description,
+ (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships child_rel
+ WHERE child_rel.tgt_topic_id = t.id
+ AND child_rel.type = :parentType
+ ) AS childCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships related_rel
+ WHERE related_rel.type = 'related'
+ AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id)
+ ) AS relatedCount
+ FROM relationships r
+ INNER JOIN topics t
+ ON t.id = r.src_topic_id
+ LEFT JOIN topic_localizations loc
+ ON loc.topic_id = t.id AND loc.lang_code = :langCode
+ LEFT JOIN topic_localizations en
+ ON en.topic_id = t.id AND en.lang_code = 'en'
+ LEFT JOIN topic_localizations ar
+ ON ar.topic_id = t.id AND ar.lang_code = 'ar'
+ WHERE r.tgt_topic_id = :parentTopicId
+ AND r.type = :parentType
+ ORDER BY r.sort_order, LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, ''))
+ """
+ )
+ suspend fun getChildTopics(
+ parentTopicId: Int,
+ parentType: RelationshipType,
+ langCode: String,
+ ): List
+
+ @Query(
+ """
+ SELECT ayah_id AS ayahId
+ FROM topic_ayahs
+ WHERE topic_id = :topicId
+ ORDER BY ayah_id
+ LIMIT :limit
+ """
+ )
+ suspend fun getTopicVerses(topicId: Int, limit: Int): List
+
+ @Query(
+ """
+ SELECT ayah_id AS ayahId
+ FROM topic_ayahs
+ WHERE topic_id = :topicId
+ ORDER BY ayah_id
+ """
+ )
+ suspend fun getAllTopicVerses(topicId: Int): List
+
+ @Query(
+ """
+ SELECT
+ r.type AS relationshipType,
+ t.id AS topicId,
+ t.slug AS slug,
+ t.type AS type,
+ t.image_url AS imageUrl,
+ t.icon AS icon,
+ t.flags AS flags,
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title,
+ ar.title AS arabicTitle,
+ COALESCE(loc.short_description, en.short_description) AS shortDescription,
+ COALESCE(loc.description, en.description) AS description,
+ (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships child_rel
+ WHERE child_rel.tgt_topic_id = t.id
+ AND child_rel.type = :parentType
+ ) AS childCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships related_rel
+ WHERE related_rel.type = 'related'
+ AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id)
+ ) AS relatedCount
+ FROM relationships r
+ INNER JOIN topics t
+ ON t.id = CASE
+ WHEN r.src_topic_id = :topicId THEN r.tgt_topic_id
+ ELSE r.src_topic_id
+ END
+ LEFT JOIN topic_localizations loc
+ ON loc.topic_id = t.id AND loc.lang_code = :langCode
+ LEFT JOIN topic_localizations en
+ ON en.topic_id = t.id AND en.lang_code = 'en'
+ LEFT JOIN topic_localizations ar
+ ON ar.topic_id = t.id AND ar.lang_code = 'ar'
+ WHERE (r.src_topic_id = :topicId OR r.tgt_topic_id = :topicId)
+ AND r.type = 'related'
+ ORDER BY
+ r.sort_order,
+ LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, ''))
+ LIMIT :limit
+ """
+ )
+ suspend fun getTopicRelationships(
+ topicId: Int,
+ parentType: RelationshipType,
+ langCode: String,
+ limit: Int,
+ ): List
+
+ @Query("SELECT id FROM topics")
+ suspend fun getAllTopicIds(): List
+
+ @Query(
+ """
+ SELECT r.src_topic_id AS childTopicId, r.tgt_topic_id AS parentTopicId
+ FROM relationships r
+ WHERE r.type = 'parent'
+ """
+ )
+ suspend fun getAllParentEdges(): List
+
+ @Query(
+ """
+ SELECT
+ r.src_topic_id AS childTopicId,
+ r.tgt_topic_id AS parentTopicId,
+ r.type AS relationshipType
+ FROM relationships r
+ WHERE r.type IN ('parent', 'ontology_parent', 'thematic_parent')
+ """
+ )
+ suspend fun getAllHierarchyEdges(): List
+
+ @Query(
+ """
+ WITH RECURSIVE ontology_tree(id) AS (
+ SELECT t.id
+ FROM topics t
+ WHERE (COALESCE(t.flags, 0) & 2) != 0
+ AND NOT EXISTS (
+ SELECT 1
+ FROM relationships pr
+ WHERE pr.src_topic_id = t.id
+ AND pr.type = 'ontology_parent'
+ )
+ UNION ALL
+ SELECT r.src_topic_id
+ FROM relationships r
+ INNER JOIN ontology_tree ot ON r.tgt_topic_id = ot.id
+ WHERE r.type = 'ontology_parent'
+ )
+ SELECT id FROM ontology_tree
+ """
+ )
+ suspend fun getVisibleOntologyTopicIds(): List
+
+ @Query(
+ """
+ WITH RECURSIVE thematic_tree(id) AS (
+ SELECT t.id
+ FROM topics t
+ WHERE (COALESCE(t.flags, 0) & 1) != 0
+ AND NOT EXISTS (
+ SELECT 1
+ FROM relationships pr
+ WHERE pr.src_topic_id = t.id
+ AND pr.type = 'thematic_parent'
+ )
+ UNION ALL
+ SELECT r.src_topic_id
+ FROM relationships r
+ INNER JOIN thematic_tree tt ON r.tgt_topic_id = tt.id
+ WHERE r.type = 'thematic_parent'
+ )
+ SELECT id FROM thematic_tree
+ """
+ )
+ suspend fun getVisibleThematicTopicIds(): List
+
+ @Query(
+ """
+ SELECT
+ t.id AS topicId,
+ t.slug AS slug,
+ t.type AS type,
+ t.image_url AS imageUrl,
+ t.icon AS icon,
+ t.flags AS flags,
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title,
+ ar.title AS arabicTitle,
+ COALESCE(loc.short_description, en.short_description) AS shortDescription,
+ COALESCE(loc.description, en.description) AS description,
+ (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships child_rel
+ WHERE child_rel.tgt_topic_id = t.id
+ AND child_rel.type = :childCountRelType
+ ) AS childCount,
+ (
+ SELECT COUNT(*)
+ FROM relationships related_rel
+ WHERE related_rel.type = 'related'
+ AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id)
+ ) AS relatedCount
+ FROM topics t
+ LEFT JOIN topic_localizations loc
+ ON loc.topic_id = t.id AND loc.lang_code = :langCode
+ LEFT JOIN topic_localizations en
+ ON en.topic_id = t.id AND en.lang_code = 'en'
+ LEFT JOIN topic_localizations ar
+ ON ar.topic_id = t.id AND ar.lang_code = 'ar'
+ WHERE t.id IN (:topicIds)
+ """
+ )
+ suspend fun getTopicSummariesByIds(
+ topicIds: List,
+ childCountRelType: RelationshipType,
+ langCode: String,
+ ): List
+
+ @Query(
+ """
+ SELECT
+ t.id AS topicId,
+ t.slug AS slug,
+ t.type AS type,
+ t.image_url AS imageUrl,
+ t.icon AS icon,
+ t.flags AS flags,
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title,
+ COALESCE(loc.short_description, en.short_description) AS shortDescription,
+ COALESCE(loc.description, en.description) AS description,
+ (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount
+ FROM topics t
+ LEFT JOIN topic_localizations loc
+ ON loc.topic_id = t.id AND loc.lang_code = :langCode
+ LEFT JOIN topic_localizations en
+ ON en.topic_id = t.id AND en.lang_code = 'en'
+ LEFT JOIN topic_localizations ar
+ ON ar.topic_id = t.id AND ar.lang_code = 'ar'
+ WHERE
+ COALESCE(loc.title, en.title, ar.title, t.slug, '') LIKE '%' || :query || '%'
+ OR COALESCE(loc.short_description, en.short_description, '') LIKE '%' || :query || '%'
+ OR COALESCE(loc.description, en.description, '') LIKE '%' || :query || '%'
+ ORDER BY LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, ''))
+ LIMIT :limit
+ """
+ )
+ suspend fun searchTopicCandidates(
+ query: String,
+ langCode: String,
+ limit: Int,
+ ): List
+
+ @Query(
+ """
+ SELECT
+ r.src_topic_id AS childTopicId,
+ r.tgt_topic_id AS parentTopicId
+ FROM relationships r
+ WHERE r.type IN ('ontology_parent', 'thematic_parent', 'parent')
+ AND r.src_topic_id IN (:topicIds)
+ """
+ )
+ suspend fun getDirectParentsByChildTopicIds(
+ topicIds: List,
+ ): List
+}
diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/RelationshipEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/RelationshipEntity.kt
new file mode 100644
index 000000000..df3bd7157
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/entities/topics/RelationshipEntity.kt
@@ -0,0 +1,62 @@
+package com.quranapp.android.db.entities.topics
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "relationships",
+ foreignKeys = [
+ ForeignKey(
+ entity = TopicEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["src_topic_id"],
+ onUpdate = ForeignKey.NO_ACTION,
+ onDelete = ForeignKey.NO_ACTION,
+ ),
+ ForeignKey(
+ entity = TopicEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["tgt_topic_id"],
+ onUpdate = ForeignKey.NO_ACTION,
+ onDelete = ForeignKey.NO_ACTION,
+ ),
+ ],
+ indices = [
+ Index(name = "idx_relationships_source", value = ["src_topic_id"]),
+ Index(name = "idx_relationships_target", value = ["tgt_topic_id"]),
+ Index(name = "idx_relationships_type", value = ["type"]),
+ Index(name = "idx_relationships_source_type", value = ["src_topic_id", "type"]),
+ ],
+)
+data class RelationshipEntity(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id")
+ val id: Int? = null,
+ @ColumnInfo(name = "src_topic_id")
+ val srcTopicId: Int,
+ @ColumnInfo(name = "tgt_topic_id")
+ val tgtTopicId: Int,
+ @ColumnInfo(name = "type")
+ val type: RelationshipType,
+ @ColumnInfo(name = "sort_order", defaultValue = "0")
+ val sortOrder: Int? = 0,
+ @ColumnInfo(name = "metadata_json")
+ val metadataJson: String?,
+)
+
+enum class RelationshipType(val dbValue: String) {
+ NONE("none"),
+ PARENT("parent"),
+ RELATED("related"),
+ THEMATIC_PARENT("thematic_parent"),
+ ONTOLOGY_PARENT("ontology_parent");
+
+ companion object {
+ fun fromDbValue(value: String): RelationshipType =
+ entries.firstOrNull { it.dbValue == value }
+ ?: RelationshipType.NONE
+ }
+}
diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/TopicAyahEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicAyahEntity.kt
new file mode 100644
index 000000000..127022623
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicAyahEntity.kt
@@ -0,0 +1,30 @@
+package com.quranapp.android.db.entities.topics
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+
+@Entity(
+ tableName = "topic_ayahs",
+ primaryKeys = ["topic_id", "ayah_id"],
+ foreignKeys = [
+ ForeignKey(
+ entity = TopicEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["topic_id"],
+ onUpdate = ForeignKey.NO_ACTION,
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [
+ Index(name = "idx_topic_ayahs_ayah", value = ["ayah_id"]),
+ Index(name = "idx_topic_ayahs_topic", value = ["topic_id"]),
+ ],
+)
+data class TopicAyahEntity(
+ @ColumnInfo(name = "topic_id")
+ val topicId: Int,
+ @ColumnInfo(name = "ayah_id")
+ val ayahId: Int,
+)
diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/TopicEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicEntity.kt
new file mode 100644
index 000000000..daff8b2c3
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicEntity.kt
@@ -0,0 +1,53 @@
+package com.quranapp.android.db.entities.topics
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "topics",
+ indices = [
+ Index(name = "idx_topics_type", value = ["type"]),
+ Index(value = ["slug"], unique = true),
+ ],
+)
+data class TopicEntity(
+ @PrimaryKey
+ @ColumnInfo(name = "id")
+ val id: Int?,
+ @ColumnInfo(name = "slug")
+ val slug: String?,
+ @ColumnInfo(name = "type")
+ val type: String,
+ @ColumnInfo(name = "image_url")
+ val imageUrl: String?,
+ @ColumnInfo(name = "icon")
+ val icon: String?,
+ @ColumnInfo(name = "flags", defaultValue = "0")
+ val flags: TopicFlags? = TopicFlags.NONE,
+ @ColumnInfo(name = "created_at")
+ val createdAt: Long?,
+ @ColumnInfo(name = "updated_at")
+ val updatedAt: Long?,
+)
+
+
+enum class TopicFlags(val dbValue: Int) {
+ NONE(0),
+ THEMATIC(1),
+ ONTOLOGY(2),
+ THEMATIC_AND_ONTOLOGY(3);
+
+ val isThematic: Boolean
+ get() = (dbValue and THEMATIC.dbValue) != 0
+
+ val isOntology: Boolean
+ get() = (dbValue and ONTOLOGY.dbValue) != 0
+
+ companion object {
+ fun fromDbValue(value: Int): TopicFlags =
+ entries.firstOrNull { it.dbValue == value }
+ ?: TopicFlags.NONE
+ }
+}
diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/TopicLocalizationEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicLocalizationEntity.kt
new file mode 100644
index 000000000..44b3fe723
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicLocalizationEntity.kt
@@ -0,0 +1,36 @@
+package com.quranapp.android.db.entities.topics
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+
+@Entity(
+ tableName = "topic_localizations",
+ primaryKeys = ["topic_id", "lang_code"],
+ foreignKeys = [
+ ForeignKey(
+ entity = TopicEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["topic_id"],
+ onUpdate = ForeignKey.NO_ACTION,
+ onDelete = ForeignKey.NO_ACTION,
+ ),
+ ],
+ indices = [
+ Index(name = "idx_topic_localizations_lang", value = ["lang_code"]),
+ Index(name = "idx_topic_localizations_title", value = ["title"]),
+ ],
+)
+data class TopicLocalizationEntity(
+ @ColumnInfo(name = "topic_id")
+ val topicId: Int,
+ @ColumnInfo(name = "lang_code")
+ val langCode: String,
+ @ColumnInfo(name = "title")
+ val title: String,
+ @ColumnInfo(name = "short_description")
+ val shortDescription: String?,
+ @ColumnInfo(name = "description")
+ val description: String?,
+)
diff --git a/app/src/main/java/com/quranapp/android/db/entities/BookmarkEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/user/BookmarkEntity.kt
similarity index 93%
rename from app/src/main/java/com/quranapp/android/db/entities/BookmarkEntity.kt
rename to app/src/main/java/com/quranapp/android/db/entities/user/BookmarkEntity.kt
index 621c20077..d901d5519 100644
--- a/app/src/main/java/com/quranapp/android/db/entities/BookmarkEntity.kt
+++ b/app/src/main/java/com/quranapp/android/db/entities/user/BookmarkEntity.kt
@@ -1,4 +1,4 @@
-package com.quranapp.android.db.entities
+package com.quranapp.android.db.entities.user
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
@@ -34,4 +34,4 @@ data class BookmarkKey(
val chapterNo: Int,
val fromVerse: Int,
val toVerse: Int
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/quranapp/android/db/entities/ReadHistoryEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/user/ReadHistoryEntity.kt
similarity index 93%
rename from app/src/main/java/com/quranapp/android/db/entities/ReadHistoryEntity.kt
rename to app/src/main/java/com/quranapp/android/db/entities/user/ReadHistoryEntity.kt
index aba738545..dce9faefb 100644
--- a/app/src/main/java/com/quranapp/android/db/entities/ReadHistoryEntity.kt
+++ b/app/src/main/java/com/quranapp/android/db/entities/user/ReadHistoryEntity.kt
@@ -1,8 +1,7 @@
-package com.quranapp.android.db.entities
+package com.quranapp.android.db.entities.user
import androidx.room.ColumnInfo
import androidx.room.Entity
-import androidx.room.Ignore
import androidx.room.PrimaryKey
@Entity(tableName = "read_history")
diff --git a/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt b/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt
index 476704445..6b9b1fe72 100644
--- a/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt
+++ b/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt
@@ -4,7 +4,7 @@ import android.content.Context
import android.database.sqlite.SQLiteDatabase
import com.quranapp.android.compose.components.reader.ReaderMode
import com.quranapp.android.db.UserDatabase
-import com.quranapp.android.db.entities.ReadHistoryEntity
+import com.quranapp.android.db.entities.user.ReadHistoryEntity
import com.quranapp.android.utils.Log
import com.quranapp.android.utils.reader.ReadType
import java.io.File
diff --git a/app/src/main/java/com/quranapp/android/db/relations/topics/TopicParentEdgeRow.kt b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicParentEdgeRow.kt
new file mode 100644
index 000000000..01b2d3cb7
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicParentEdgeRow.kt
@@ -0,0 +1,10 @@
+package com.quranapp.android.db.relations.topics
+
+/**
+ * A `parent` relationship: [childTopicId] is the child, [parentTopicId] is the parent
+ * (matches [relationships] row: src_topic_id = child, tgt_topic_id = parent).
+ */
+data class TopicParentEdgeRow(
+ val childTopicId: Int,
+ val parentTopicId: Int,
+)
diff --git a/app/src/main/java/com/quranapp/android/db/relations/topics/TopicRows.kt b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicRows.kt
new file mode 100644
index 000000000..169108680
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicRows.kt
@@ -0,0 +1,58 @@
+package com.quranapp.android.db.relations.topics
+
+import com.quranapp.android.db.entities.topics.RelationshipType
+import com.quranapp.android.db.entities.topics.TopicFlags
+
+data class TopicSummaryRow(
+ val topicId: Int,
+ val slug: String?,
+ val type: String,
+ val imageUrl: String?,
+ val icon: String?,
+ val flags: TopicFlags?,
+ val title: String,
+ val shortDescription: String?,
+ val description: String?,
+ val ayahCount: Int,
+ val childCount: Int,
+ val relatedCount: Int,
+)
+
+data class TopicSearchCandidateRow(
+ val topicId: Int,
+ val slug: String?,
+ val type: String,
+ val imageUrl: String?,
+ val icon: String?,
+ val flags: TopicFlags?,
+ val title: String,
+ val shortDescription: String?,
+ val description: String?,
+ val ayahCount: Int,
+)
+
+data class TopicVerseRow(
+ val ayahId: Int,
+)
+
+data class TopicRelationshipRow(
+ val relationshipType: RelationshipType,
+ val topicId: Int,
+ val slug: String?,
+ val type: String,
+ val imageUrl: String?,
+ val icon: String?,
+ val flags: TopicFlags?,
+ val title: String,
+ val shortDescription: String?,
+ val description: String?,
+ val ayahCount: Int,
+ val childCount: Int,
+ val relatedCount: Int,
+)
+
+data class TopicHierarchyEdgeRow(
+ val childTopicId: Int,
+ val parentTopicId: Int,
+ val relationshipType: RelationshipType,
+)
diff --git a/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt b/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt
index ec1c1bbd3..106286856 100644
--- a/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt
+++ b/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt
@@ -4,6 +4,7 @@ import android.content.Context
import com.quranapp.android.components.search.SearchHistoryModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import java.io.Closeable
data class SearchHistoryEntry(
val id: Int,
@@ -11,11 +12,15 @@ data class SearchHistoryEntry(
val date: String,
)
-class SearchHistoryStore(context: Context) {
+class SearchHistoryStore(context: Context) : Closeable {
private val appContext = context.applicationContext
private val helper: SearchHistoryDBHelper by lazy { SearchHistoryDBHelper(appContext) }
+ override fun close() {
+ helper.close()
+ }
+
suspend fun loadAll(): List = withContext(Dispatchers.IO) {
helper.getHistories("").mapNotNull { model ->
(model as? SearchHistoryModel)?.let {
diff --git a/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt b/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt
index 8bd5e8363..94ca7d6f1 100644
--- a/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt
+++ b/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt
@@ -25,6 +25,10 @@ class QuranRepository(
private val database: QuranDatabase,
private val extDatabase: ExternalQuranDatabase
) {
+ companion object {
+ private const val ARBITRARY_BATCH_CHUNK_SIZE = 400
+ }
+
private val mushafDao get() = database.mushafDao()
private val arabicSearchDao get() = database.arabicSearchDao()
private val ayahDao get() = database.ayahDao()
@@ -175,11 +179,27 @@ class QuranRepository(
scriptCode: String,
arabicEnabled: Boolean,
): ChapterVerseBatch? {
- val distinct = verseNos.distinct()
+ val distinct = verseNos.asSequence()
+ .filter { it > 0 }
+ .distinct()
+ .sorted()
+ .toList()
if (distinct.isEmpty()) return null
+ if (distinct.isContiguousRange()) {
+ return loadVersesBatch(
+ chapterNo = chapterNo,
+ fromVerse = distinct.first(),
+ toVerse = distinct.last(),
+ scriptCode = scriptCode,
+ arabicEnabled = arabicEnabled,
+ )
+ }
+
val ayahIds = distinct.map { QuranMeta.getAyahId(chapterNo, it) }
- val ayahs = ayahDao.getAyahsByIds(ayahIds)
+ val ayahs = ayahIds.chunked(ARBITRARY_BATCH_CHUNK_SIZE).flatMap { idsChunk ->
+ ayahDao.getAyahsByIds(idsChunk)
+ }
if (ayahs.isEmpty()) return null
@@ -189,13 +209,19 @@ class QuranRepository(
val ayahByVerse = ayahs.associateBy { it.ayahNo }
val verseIds = ayahs.map { it.ayahId }
- val wordsFlat = if (arabicEnabled) ayahWordDao.getWordsForAyahs(verseIds, scriptCode)
- else emptyList()
+ val wordsFlat = if (arabicEnabled) {
+ verseIds.chunked(ARBITRARY_BATCH_CHUNK_SIZE).flatMap { verseIdChunk ->
+ ayahWordDao.getWordsForAyahs(verseIdChunk, scriptCode)
+ }
+ } else emptyList()
val wordsByAyahId = groupWordsByAyahIdWithLastFlags(wordsFlat)
val pageByAyahId = if (verseIds.isNotEmpty()) {
- mushafDao.getPagesForAyahIds(mushafId, verseIds)
+ verseIds.chunked(ARBITRARY_BATCH_CHUNK_SIZE)
+ .flatMap { verseIdChunk ->
+ mushafDao.getPagesForAyahIds(mushafId, verseIdChunk)
+ }
.associate { it.ayahId to it.pageNumber }
} else {
emptyMap()
@@ -758,6 +784,14 @@ class QuranRepository(
}
}
+private fun List.isContiguousRange(): Boolean {
+ if (size <= 1) return true
+ for (i in 1 until size) {
+ if (this[i] != this[i - 1] + 1) return false
+ }
+ return true
+}
+
private fun mergeAyahIdIntervals(intervals: List>): List> {
if (intervals.isEmpty()) return emptyList()
val sorted = intervals.sortedBy { it.first }
diff --git a/app/src/main/java/com/quranapp/android/repository/TopicsRepository.kt b/app/src/main/java/com/quranapp/android/repository/TopicsRepository.kt
new file mode 100644
index 000000000..e4e993191
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/repository/TopicsRepository.kt
@@ -0,0 +1,679 @@
+package com.quranapp.android.repository
+
+import android.content.Context
+import com.quranapp.android.compose.utils.preferences.ReaderPreferences
+import com.quranapp.android.db.TopicsDatabase
+import com.quranapp.android.db.dao.TopicsDao
+import com.quranapp.android.db.entities.topics.RelationshipType
+import com.quranapp.android.db.entities.topics.TopicFlags
+import com.quranapp.android.db.relations.topics.TopicRelationshipRow
+import com.quranapp.android.db.relations.topics.TopicSearchCandidateRow
+import com.quranapp.android.db.relations.topics.TopicSummaryRow
+import com.quranapp.android.utils.quran.QuranMeta
+import com.quranapp.android.utils.reader.factory.QuranTranslationFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+
+data class TopicVersePreview(
+ val chapterNo: Int,
+ val verseNo: Int,
+ val translation: String,
+)
+
+data class TopicSearchHit(
+ val topicId: Int,
+ val slug: String?,
+ val title: String,
+ val shortDescription: String?,
+ val description: String?,
+ val ayahCount: Int,
+ val pathLabel: String,
+ val breadcrumbIds: List,
+ val preferredTree: RelationshipType,
+ val score: Int,
+)
+
+/**
+ * Topics not in ontology/thematic trees, assigned for display via parent-graph heuristics.
+ */
+data class SupplementalTopicAssignment(
+ val ontologyAssigned: Set,
+ val thematicAssigned: Set,
+ val ontologySupplementalRootIds: List,
+ val thematicSupplementalRootIds: List,
+ val parentToChildren: Map>,
+ val childToParents: Map>,
+)
+
+private class IntDisjointSet {
+ private val parent = mutableMapOf()
+
+ private fun ensure(x: Int) {
+ if (x !in parent) parent[x] = x
+ }
+
+ fun find(x: Int): Int {
+ ensure(x)
+ val p = parent.getValue(x)
+ if (p != x) {
+ parent[x] = find(p)
+ }
+ return parent.getValue(x)
+ }
+
+ fun union(a: Int, b: Int) {
+ val ra = find(a)
+ val rb = find(b)
+ if (ra != rb) parent[ra] = rb
+ }
+}
+
+private data class TopicHierarchyGraph(
+ val parentsByChild: Map>>,
+)
+
+class TopicsRepository(
+ private val context: Context,
+ private val database: TopicsDatabase,
+) {
+ private val topicsDao: TopicsDao get() = database.topicsDao()
+
+ @Volatile
+ private var supplementalAssignment: SupplementalTopicAssignment? = null
+
+ @Volatile
+ private var hierarchyGraph: TopicHierarchyGraph? = null
+
+ private val supplementalBuildMutex = Mutex()
+ private val hierarchyGraphMutex = Mutex()
+
+ companion object {
+ const val SUPPLEMENTAL_ROOT_PAGE_SIZE: Int = 40
+ private const val TOPIC_IDS_QUERY_CHUNK: Int = 450
+ private const val TOPIC_SEARCH_CANDIDATE_LIMIT: Int = 140
+ private const val TOPIC_SEARCH_MAX_PATH_DEPTH: Int = 8
+ }
+
+ private fun preferredLanguageCode(): String {
+ // For now, only English is supported
+ return "en"
+ }
+
+ suspend fun getOntologyRootTopics(): List = withContext(Dispatchers.IO) {
+ topicsDao.getRootTopics(
+ flagMask = TopicFlags.ONTOLOGY.dbValue,
+ parentType = RelationshipType.ONTOLOGY_PARENT,
+ langCode = preferredLanguageCode(),
+ )
+ }
+
+ suspend fun getThematicRootTopics(): List = withContext(Dispatchers.IO) {
+ topicsDao.getRootTopics(
+ flagMask = TopicFlags.THEMATIC.dbValue,
+ parentType = RelationshipType.THEMATIC_PARENT,
+ langCode = preferredLanguageCode(),
+ )
+ }
+
+ /**
+ * Ensures supplemental assignment is built (used for hidden topics).
+ * Safe to call multiple times.
+ */
+ suspend fun warmSupplementalAssignment(): SupplementalTopicAssignment =
+ withContext(Dispatchers.IO) {
+ getOrBuildSupplementalAssignmentLocked()
+ }
+
+ suspend fun getSupplementalRootTopicsPage(
+ parentType: RelationshipType,
+ offset: Int,
+ limit: Int,
+ ): List = withContext(Dispatchers.IO) {
+ val plan = getOrBuildSupplementalAssignmentLocked()
+
+ val ids = when (parentType) {
+ RelationshipType.ONTOLOGY_PARENT -> plan.ontologySupplementalRootIds
+ RelationshipType.THEMATIC_PARENT -> plan.thematicSupplementalRootIds
+ else -> emptyList()
+ }
+
+ val slice = ids.drop(offset.coerceAtLeast(0)).take(limit.coerceAtLeast(0))
+
+ if (slice.isEmpty()) return@withContext emptyList()
+
+ val rows = getTopicSummariesByIdsBatched(slice, parentType)
+ val byId = rows.associateBy { it.topicId }
+ val assigned = assignedSetFor(parentType, plan)
+
+ slice.mapNotNull { byId[it] }.map { row ->
+ val cnt =
+ plan.parentToChildren[row.topicId]?.count { childId -> childId in assigned } ?: 0
+ row.copy(childCount = cnt)
+ }
+ }
+
+ suspend fun getTopicSummaryForExplorer(
+ topicId: Int,
+ parentType: RelationshipType,
+ ): TopicSummaryRow? = withContext(Dispatchers.IO) {
+ val summaries = getTopicSummariesForExplorer(listOf(topicId), parentType)
+
+ summaries.firstOrNull()
+ }
+
+ suspend fun getTopicSummariesForExplorer(
+ topicIds: List,
+ parentType: RelationshipType,
+ ): List = withContext(Dispatchers.IO) {
+ val distinct = topicIds.distinct()
+
+ if (distinct.isEmpty()) return@withContext emptyList()
+
+ val plan = getOrBuildSupplementalAssignmentLocked()
+ val assigned = assignedSetFor(parentType, plan)
+ val rows = getTopicSummariesByIdsBatched(distinct, parentType)
+ val rowsById = rows.associateBy { it.topicId }
+
+ distinct.mapNotNull { rowsById[it] }.map { base ->
+ if (base.topicId !in assigned) {
+ base
+ } else {
+ val cnt =
+ plan.parentToChildren[base.topicId]?.count { childId -> childId in assigned }
+ ?: 0
+
+ base.copy(childCount = cnt)
+ }
+ }
+ }
+
+ suspend fun getTopicSummaryById(
+ topicId: Int,
+ parentType: RelationshipType,
+ ): TopicSummaryRow? = withContext(Dispatchers.IO) {
+ val lang = preferredLanguageCode()
+ val base = topicsDao.getTopicById(topicId, parentType, lang) ?: return@withContext null
+
+ val plan = getOrBuildSupplementalAssignmentLocked()
+ val assigned = assignedSetFor(parentType, plan)
+
+ if (topicId !in assigned) return@withContext base
+
+ val cnt = plan.parentToChildren[topicId]?.count { childId -> childId in assigned } ?: 0
+
+ base.copy(childCount = cnt)
+ }
+
+ suspend fun getChildTopicsRespectingSupplemental(
+ topicId: Int,
+ parentType: RelationshipType,
+ ): List = withContext(Dispatchers.IO) {
+ val lang = preferredLanguageCode()
+ val primary = topicsDao.getChildTopics(topicId, parentType, lang)
+
+ if (primary.isNotEmpty()) return@withContext primary
+
+ val plan = getOrBuildSupplementalAssignmentLocked()
+ val assigned = assignedSetFor(parentType, plan)
+
+ if (topicId !in assigned) return@withContext emptyList()
+ val childIds = plan.parentToChildren[topicId].orEmpty().filter { it in assigned }
+
+ if (childIds.isEmpty()) return@withContext emptyList()
+
+ getTopicSummariesByIdsBatched(childIds, parentType)
+ .map { row ->
+ val cnt = plan.parentToChildren[row.topicId]?.count { cid -> cid in assigned } ?: 0
+
+ row.copy(childCount = cnt)
+ }
+ .sortedBy { it.title.lowercase() }
+ }
+
+ suspend fun getBroaderCatalogChildren(
+ topicId: Int,
+ parentType: RelationshipType,
+ excludeTopicIds: Set = emptySet(),
+ ): List = withContext(Dispatchers.IO) {
+ val plan = getOrBuildSupplementalAssignmentLocked()
+ val assigned = assignedSetFor(parentType, plan)
+
+ if (assigned.isEmpty()) return@withContext emptyList()
+
+ val broaderChildIds = plan.parentToChildren[topicId]
+ .orEmpty()
+ .filter { childId -> childId in assigned && childId !in excludeTopicIds }
+
+ if (broaderChildIds.isEmpty()) return@withContext emptyList()
+
+ getTopicSummariesByIdsBatched(broaderChildIds, parentType)
+ .map { row ->
+ val cnt = plan.parentToChildren[row.topicId]
+ ?.count { cid -> cid in assigned }
+ ?: 0
+
+ row.copy(childCount = cnt)
+ }
+ .sortedBy { it.title.lowercase() }
+ }
+
+ suspend fun getTopicById(
+ topicId: Int,
+ tree: RelationshipType,
+ ): TopicSummaryRow? = withContext(Dispatchers.IO) {
+ topicsDao.getTopicById(topicId, tree, preferredLanguageCode())
+ }
+
+ suspend fun getTopicBySlug(
+ slug: String,
+ tree: RelationshipType,
+ ): TopicSummaryRow? = withContext(Dispatchers.IO) {
+ topicsDao.getTopicBySlug(slug, tree, preferredLanguageCode())
+ }
+
+ suspend fun getChildTopics(
+ topicId: Int,
+ tree: RelationshipType,
+ ): List = withContext(Dispatchers.IO) {
+ topicsDao.getChildTopics(topicId, tree, preferredLanguageCode())
+ }
+
+ suspend fun getTopicVerseRefs(topicId: Int, limit: Int = 8): List =
+ withContext(Dispatchers.IO) {
+ topicsDao.getTopicVerses(topicId, limit)
+ .toVerseRefs()
+ }
+
+ suspend fun getAllTopicVerseRefs(topicId: Int): List =
+ withContext(Dispatchers.IO) {
+ topicsDao.getAllTopicVerses(topicId).toVerseRefs()
+ }
+
+ suspend fun getTopicVersePreviews(topicId: Int, limit: Int = 5): List =
+ withContext(Dispatchers.IO) {
+ val rows = topicsDao.getTopicVerses(topicId, limit)
+ if (rows.isEmpty()) return@withContext emptyList()
+
+ val primarySlug = ReaderPreferences.primaryTranslationSlug()
+
+ QuranTranslationFactory(context.applicationContext).use { factory ->
+ rows.map { row ->
+ val (chapterNo, verseNo) = QuranMeta.getVerseNoFromAyahId(row.ayahId)
+ TopicVersePreview(
+ chapterNo = chapterNo,
+ verseNo = verseNo,
+ translation = factory
+ .getTranslationsSingleSlugVerse(primarySlug, chapterNo, verseNo)
+ ?.text
+ .orEmpty(),
+ )
+ }
+ }
+ }
+
+ suspend fun getTopicRelationships(
+ topicId: Int,
+ tree: RelationshipType,
+ limit: Int = 8,
+ ): List = withContext(Dispatchers.IO) {
+ topicsDao.getTopicRelationships(
+ topicId = topicId,
+ parentType = tree,
+ langCode = preferredLanguageCode(),
+ limit = limit,
+ )
+ }
+
+ suspend fun searchTopicHits(
+ query: String,
+ limit: Int = 30,
+ ): List = withContext(Dispatchers.IO) {
+ val normalizedQuery = query.trim()
+ if (normalizedQuery.isEmpty() || limit <= 0) return@withContext emptyList()
+
+ val candidates = topicsDao.searchTopicCandidates(
+ query = normalizedQuery,
+ langCode = preferredLanguageCode(),
+ limit = TOPIC_SEARCH_CANDIDATE_LIMIT,
+ )
+ if (candidates.isEmpty()) return@withContext emptyList()
+
+ val graph = getOrBuildHierarchyGraphLocked()
+
+ val trailsByTopic = candidates.associate { candidate ->
+ candidate.topicId to findBestAncestorTrail(
+ topicId = candidate.topicId,
+ graph = graph,
+ maxDepth = TOPIC_SEARCH_MAX_PATH_DEPTH,
+ )
+ }
+
+ val allTopicIdsNeeded = buildSet {
+ candidates.forEach { add(it.topicId) }
+ trailsByTopic.values.forEach { trail -> addAll(trail) }
+ }
+
+ val topicTitles = getTopicSummariesByIdsBatched(
+ topicIds = allTopicIdsNeeded.toList(),
+ parentType = RelationshipType.ONTOLOGY_PARENT,
+ ).associate { it.topicId to it.title }
+
+ candidates
+ .asSequence()
+ .map { candidate ->
+ val ancestorTrail = trailsByTopic[candidate.topicId].orEmpty()
+ val fullPathIds = ancestorTrail + candidate.topicId
+ val pathText = fullPathIds
+ .mapNotNull { topicTitles[it] }
+ .joinToString(" » ")
+
+ val preferredTree = inferPreferredTree(candidate, ancestorTrail, graph)
+ val score = scoreTopicSearchCandidate(candidate, normalizedQuery, pathText)
+
+ TopicSearchHit(
+ topicId = candidate.topicId,
+ slug = candidate.slug,
+ title = candidate.title,
+ shortDescription = candidate.shortDescription,
+ description = candidate.description,
+ ayahCount = candidate.ayahCount,
+ pathLabel = pathText.ifBlank { candidate.title },
+ breadcrumbIds = ancestorTrail,
+ preferredTree = preferredTree,
+ score = score,
+ )
+ }
+ .sortedWith(
+ compareByDescending { it.score }
+ .thenByDescending { it.ayahCount }
+ .thenBy { it.title.lowercase() }
+ )
+ .take(limit)
+ .toList()
+ }
+
+ private suspend fun getOrBuildHierarchyGraphLocked(): TopicHierarchyGraph {
+ hierarchyGraph?.let { return it }
+
+ return hierarchyGraphMutex.withLock {
+ hierarchyGraph?.let { return@withLock it }
+
+ val edges = topicsDao.getAllHierarchyEdges()
+ val parentsByChild = edges
+ .groupBy { it.childTopicId }
+ .mapValues { (_, rows) ->
+ rows
+ .map { row -> row.parentTopicId to row.relationshipType }
+ .distinctBy { it.first to it.second }
+ .sortedWith(
+ compareBy> { relationshipOrder(it.second) }
+ .thenBy { it.first }
+ )
+ }
+
+ TopicHierarchyGraph(
+ parentsByChild = parentsByChild,
+ ).also { hierarchyGraph = it }
+ }
+ }
+
+ private fun findBestAncestorTrail(
+ topicId: Int,
+ graph: TopicHierarchyGraph,
+ maxDepth: Int,
+ ): List {
+ val paths = mutableListOf>>()
+
+ fun dfs(
+ nodeId: Int,
+ depth: Int,
+ visited: MutableSet,
+ path: MutableList>,
+ ) {
+ if (depth >= maxDepth) {
+ paths += path.toList()
+ return
+ }
+
+ val parents = graph.parentsByChild[nodeId].orEmpty()
+ .filter { (parentId, _) -> parentId !in visited }
+ if (parents.isEmpty()) {
+ paths += path.toList()
+ return
+ }
+
+ parents.forEach { (parentId, relType) ->
+ visited += parentId
+ path += parentId to relType
+ dfs(
+ nodeId = parentId,
+ depth = depth + 1,
+ visited = visited,
+ path = path,
+ )
+ path.removeAt(path.lastIndex)
+ visited.remove(parentId)
+ }
+ }
+
+ dfs(
+ nodeId = topicId,
+ depth = 0,
+ visited = mutableSetOf(topicId),
+ path = mutableListOf(),
+ )
+
+ val best = paths.maxWithOrNull(
+ compareBy>> { relationStrength(it) }
+ .thenBy { it.size }
+ ).orEmpty()
+
+ return best.map { it.first }.reversed()
+ }
+
+ private fun inferPreferredTree(
+ candidate: TopicSearchCandidateRow,
+ ancestorTrail: List,
+ graph: TopicHierarchyGraph,
+ ): RelationshipType {
+ val path = ancestorTrail + candidate.topicId
+ val edgeTypes = path.windowed(size = 2, step = 1, partialWindows = false)
+ .mapNotNull { (parentId, childId) ->
+ graph.parentsByChild[childId]
+ ?.firstOrNull { (pId, _) -> pId == parentId }
+ ?.second
+ }
+
+ return when {
+ edgeTypes.any { it == RelationshipType.ONTOLOGY_PARENT } -> RelationshipType.ONTOLOGY_PARENT
+ edgeTypes.any { it == RelationshipType.THEMATIC_PARENT } -> RelationshipType.THEMATIC_PARENT
+ candidate.flags?.isOntology == true -> RelationshipType.ONTOLOGY_PARENT
+ candidate.flags?.isThematic == true -> RelationshipType.THEMATIC_PARENT
+ else -> RelationshipType.ONTOLOGY_PARENT
+ }
+ }
+
+ private fun scoreTopicSearchCandidate(
+ candidate: TopicSearchCandidateRow,
+ query: String,
+ pathText: String,
+ ): Int {
+ val normalizedQuery = normalizeSearchText(query)
+ val normalizedTitle = normalizeSearchText(candidate.title)
+ val normalizedShort = normalizeSearchText(candidate.shortDescription.orEmpty())
+ val normalizedDescription = normalizeSearchText(candidate.description.orEmpty())
+ val normalizedPath = normalizeSearchText(pathText)
+
+ val base = when {
+ normalizedTitle == normalizedQuery -> 1000
+ normalizedTitle.startsWith(normalizedQuery) -> 900
+ Regex("\\b${Regex.escape(normalizedQuery)}\\b").containsMatchIn(normalizedTitle) -> 820
+ normalizedTitle.contains(normalizedQuery) -> 760
+ normalizedShort.contains(normalizedQuery) -> 540
+ normalizedDescription.contains(normalizedQuery) -> 380
+ else -> 120
+ }
+
+ val pathBonus = when {
+ normalizedPath.startsWith(normalizedQuery) -> 55
+ normalizedPath.contains(normalizedQuery) -> 35
+ else -> 0
+ }
+
+ val positionBonus = normalizedTitle.indexOf(normalizedQuery)
+ .takeIf { it >= 0 }
+ ?.let { 40 - it.coerceAtMost(40) }
+ ?: 0
+
+ val lengthPenalty = (normalizedTitle.length - normalizedQuery.length)
+ .coerceAtLeast(0)
+ .coerceAtMost(80)
+
+ val verseBonus = (candidate.ayahCount * 2).coerceAtMost(50)
+
+ return base + pathBonus + positionBonus + verseBonus - lengthPenalty
+ }
+
+ private fun normalizeSearchText(value: String): String =
+ value
+ .lowercase()
+ .replace(Regex("[^\\p{L}\\p{N}\\s]"), " ")
+ .replace(Regex("\\s+"), " ")
+ .trim()
+
+ private fun relationStrength(path: List>): Int =
+ path.sumOf { (_, rel) ->
+ when (rel) {
+ RelationshipType.ONTOLOGY_PARENT -> 6
+ RelationshipType.THEMATIC_PARENT -> 5
+ RelationshipType.PARENT -> 3
+ RelationshipType.RELATED -> 1
+ RelationshipType.NONE -> 0
+ }
+ }
+
+ private fun relationshipOrder(type: RelationshipType): Int =
+ when (type) {
+ RelationshipType.ONTOLOGY_PARENT -> 0
+ RelationshipType.THEMATIC_PARENT -> 1
+ RelationshipType.PARENT -> 2
+ RelationshipType.RELATED -> 3
+ RelationshipType.NONE -> 4
+ }
+
+ private fun assignedSetFor(
+ parentType: RelationshipType,
+ plan: SupplementalTopicAssignment,
+ ): Set = when (parentType) {
+ RelationshipType.ONTOLOGY_PARENT -> plan.ontologyAssigned
+ RelationshipType.THEMATIC_PARENT -> plan.thematicAssigned
+ else -> emptySet()
+ }
+
+ private suspend fun getOrBuildSupplementalAssignmentLocked(): SupplementalTopicAssignment {
+ supplementalAssignment?.let { return it }
+
+ return supplementalBuildMutex.withLock {
+ supplementalAssignment?.let { return@withLock it }
+
+ val visibleOntology = topicsDao.getVisibleOntologyTopicIds().toSet()
+ val visibleThematic = topicsDao.getVisibleThematicTopicIds().toSet()
+ val allIds = topicsDao.getAllTopicIds().toSet()
+ val hidden = allIds - visibleOntology - visibleThematic
+ val edges = topicsDao.getAllParentEdges()
+
+ val uf = IntDisjointSet()
+
+ for (e in edges) {
+ uf.union(e.childTopicId, e.parentTopicId)
+ }
+
+ for (h in hidden) {
+ uf.find(h)
+ }
+
+ val compTouches = mutableMapOf>()
+
+ for (tid in allIds) {
+ val r = uf.find(tid)
+ val (o, t) = compTouches[r] ?: (false to false)
+ compTouches[r] = (o || tid in visibleOntology) to (t || tid in visibleThematic)
+ }
+
+ val ontologyAssigned = mutableSetOf()
+ val thematicAssigned = mutableSetOf()
+
+ for (h in hidden) {
+ val r = uf.find(h)
+ val (touchO, touchT) = compTouches[r] ?: (false to false)
+ when {
+ touchO && !touchT -> ontologyAssigned.add(h)
+ touchT && !touchO -> thematicAssigned.add(h)
+ else -> thematicAssigned.add(h)
+ }
+ }
+
+ val parentToChildren = mutableMapOf>()
+ val childToParents = mutableMapOf>()
+
+ for (e in edges) {
+ parentToChildren.getOrPut(e.parentTopicId) { mutableListOf() }.add(e.childTopicId)
+ childToParents.getOrPut(e.childTopicId) { mutableListOf() }.add(e.parentTopicId)
+ }
+
+ parentToChildren.values.forEach { it.sort() }
+ childToParents.values.forEach { it.sort() }
+
+ suspend fun supplementalRootsFor(assigned: Set): List {
+ val roots = assigned.filter { childId ->
+ childToParents[childId].isNullOrEmpty()
+ }
+
+ if (roots.isEmpty()) return emptyList()
+
+ val rows = getTopicSummariesByIdsBatched(roots, RelationshipType.ONTOLOGY_PARENT)
+ val order = rows.associateBy { it.topicId }
+
+ return roots
+ .mapNotNull { order[it] }
+ .sortedBy { it.title.lowercase() }
+ .map { it.topicId }
+ }
+
+ val ontologyRoots = supplementalRootsFor(ontologyAssigned)
+ val thematicRoots = supplementalRootsFor(thematicAssigned)
+
+ SupplementalTopicAssignment(
+ ontologyAssigned = ontologyAssigned,
+ thematicAssigned = thematicAssigned,
+ ontologySupplementalRootIds = ontologyRoots,
+ thematicSupplementalRootIds = thematicRoots,
+ parentToChildren = parentToChildren.mapValues { (_, v) -> v.toList() },
+ childToParents = childToParents.mapValues { (_, v) -> v.toList() },
+ ).also { supplementalAssignment = it }
+ }
+ }
+
+ private suspend fun getTopicSummariesByIdsBatched(
+ topicIds: List,
+ parentType: RelationshipType,
+ ): List {
+ val distinct = topicIds.distinct()
+
+ if (distinct.isEmpty()) return emptyList()
+
+ val lang = preferredLanguageCode()
+
+ return distinct.chunked(TOPIC_IDS_QUERY_CHUNK).flatMap { chunk ->
+ topicsDao.getTopicSummariesByIds(chunk, parentType, lang)
+ }
+ }
+
+ private fun List.toVerseRefs(): List {
+ return map { QuranMeta.getVerseNoFromAyahId(it.ayahId) }
+ .map { (chapterNo, verseNo) -> "$chapterNo:$verseNo" }
+ }
+}
diff --git a/app/src/main/java/com/quranapp/android/repository/UserRepository.kt b/app/src/main/java/com/quranapp/android/repository/UserRepository.kt
index 8881e85a7..c9323de5f 100644
--- a/app/src/main/java/com/quranapp/android/repository/UserRepository.kt
+++ b/app/src/main/java/com/quranapp/android/repository/UserRepository.kt
@@ -7,8 +7,8 @@ import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.quranapp.android.R
import com.quranapp.android.db.UserDatabase
-import com.quranapp.android.db.entities.BookmarkEntity
-import com.quranapp.android.db.entities.ReadHistoryEntity
+import com.quranapp.android.db.entities.user.BookmarkEntity
+import com.quranapp.android.db.entities.user.ReadHistoryEntity
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
@@ -215,4 +215,4 @@ class UserRepository(
suspend fun deleteAllHistories() {
readHistoryDao.deleteAll()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt b/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt
index cd9f1c207..ed8f9d3c5 100644
--- a/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt
+++ b/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt
@@ -6,8 +6,14 @@ import com.quranapp.android.components.quran.ExclusiveVersesDataset
import com.quranapp.android.components.quran.QuranExclusiveVerses
import com.quranapp.android.components.quran.QuranScienceItem
import com.quranapp.android.compose.screens.science.loadScienceItems
+import com.quranapp.android.db.DatabaseProvider
+import com.quranapp.android.repository.TopicSearchHit
sealed class CollectionSearchResult {
+ data class TopicsDbItem(
+ val hit: TopicSearchHit,
+ ) : CollectionSearchResult()
+
data class ExclusiveVerseItem(
val dataset: ExclusiveVersesDataset,
val verse: ExclusiveVerse,
@@ -19,10 +25,22 @@ sealed class CollectionSearchResult {
}
object ExclusiveVersesSearchProvider {
+ const val TOPIC_RESULTS_LIMIT = 40
+ private const val EXCLUSIVE_RESULTS_LIMIT = 40
+ private const val SCIENCE_RESULTS_LIMIT = 30
+ const val COMBINED_RESULTS_LIMIT = 90
+
suspend fun search(context: Context, query: String): List {
val normalizedQuery = query.trim()
if (normalizedQuery.isEmpty()) return emptyList()
+ val topicsRepo = DatabaseProvider.getTopicsRepository(context)
+ val topicMatches = topicsRepo.searchTopicHits(
+ query = normalizedQuery,
+ limit = TOPIC_RESULTS_LIMIT,
+ )
+ .map { hit -> CollectionSearchResult.TopicsDbItem(hit) }
+
val exclusiveMatches = ExclusiveVersesDataset.entries.flatMap { dataset ->
QuranExclusiveVerses.get(context, dataset)
.asSequence()
@@ -40,7 +58,7 @@ object ExclusiveVersesSearchProvider {
)
}
.toList()
- }
+ }.take(EXCLUSIVE_RESULTS_LIMIT)
val scienceMatches = loadScienceItems(context)
.asSequence()
@@ -52,7 +70,9 @@ object ExclusiveVersesSearchProvider {
CollectionSearchResult.ScienceTopicItem(topic)
}
.toList()
+ .take(SCIENCE_RESULTS_LIMIT)
- return exclusiveMatches + scienceMatches
+ return (topicMatches + exclusiveMatches + scienceMatches)
+ .take(COMBINED_RESULTS_LIMIT)
}
}
diff --git a/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt b/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt
index 49a6c2447..6872c0a78 100644
--- a/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt
+++ b/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt
@@ -12,7 +12,7 @@ import com.quranapp.android.R
import com.quranapp.android.activities.ActivityReader
import com.quranapp.android.components.reader.ChapterVersePair
import com.quranapp.android.compose.components.reader.ReaderMode
-import com.quranapp.android.db.entities.ReadHistoryEntity
+import com.quranapp.android.db.entities.user.ReadHistoryEntity
import com.quranapp.android.utils.Log
import com.quranapp.android.utils.app.NotificationUtils
import com.quranapp.android.utils.reader.ReadType
diff --git a/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt b/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt
index a90d9c5f4..d4205a39d 100644
--- a/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt
+++ b/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt
@@ -53,6 +53,85 @@ object ParserUtils {
return chapters
}
+ /**
+ * Compresses verse references by chapter, collapsing contiguous verses into ranges.
+ * Example: 1:1, 1:2, 1:3, 2:5 -> [1:1-3, 2:5]
+ */
+ @JvmStatic
+ fun compressVerseRefsByChapter(verseRefs: Collection): List {
+ if (verseRefs.isEmpty()) return emptyList()
+
+ val grouped = linkedMapOf>()
+
+ verseRefs.forEach { ref ->
+ val chapter = ref.substringBefore(':').trim().toIntOrNull() ?: return@forEach
+ val versePart = ref.substringAfter(':', "").trim()
+
+ if (versePart.isBlank()) return@forEach
+
+ val verses = grouped.getOrPut(chapter) { linkedSetOf() }
+
+ versePart.split(',').forEach { token ->
+ val piece = token.trim()
+ if (piece.isBlank()) return@forEach
+
+ val rangeParts = piece.split('-')
+
+ when (rangeParts.size) {
+ 1 -> {
+ rangeParts[0].toIntOrNull()?.let(verses::add)
+ }
+
+ 2 -> {
+ val from = rangeParts[0].toIntOrNull()
+ val to = rangeParts[1].toIntOrNull()
+ if (from != null && to != null) {
+ val start = minOf(from, to)
+ val end = maxOf(from, to)
+ for (verseNo in start..end) {
+ verses.add(verseNo)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return grouped.entries.flatMap { (chapter, versesSet) ->
+ val sorted = versesSet.toList().sorted()
+ if (sorted.isEmpty()) return@flatMap emptyList()
+
+ val collapsed = mutableListOf()
+ var start = sorted.first()
+ var prev = start
+
+ for (i in 1 until sorted.size) {
+ val current = sorted[i]
+
+ if (current == prev + 1) {
+ prev = current
+ } else {
+ collapsed += if (start == prev) {
+ "$chapter:$start"
+ } else {
+ "$chapter:$start-$prev"
+ }
+
+ start = current
+ prev = current
+ }
+ }
+
+ collapsed += if (start == prev) {
+ "$chapter:$start"
+ } else {
+ "$chapter:$start-$prev"
+ }
+
+ collapsed
+ }
+ }
+
suspend fun prepareChapterText(
ctx: Context,
repository: QuranRepository,
diff --git a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt
index 35654ebd8..18d56bd37 100644
--- a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt
+++ b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt
@@ -29,6 +29,7 @@ object QuranProphetParser {
private const val PROPHETS_ATTR_NAME_EN = "name-en"
private const val PROPHETS_ATTR_NAME = "name"
private const val PROPHETS_ATTR_ICON_RES = "drawable"
+ private const val PROPHETS_ATTR_THUMBNAIL = "thumbnail"
/**
* Parsed strings and chapter labels depend on [appPlatformLocale]. Cached per locale tag.
@@ -85,6 +86,9 @@ object QuranProphetParser {
PROPHETS_ATTR_ICON_RES,
-1
),
+ thumbnail = parser.getAttributeValue(null, PROPHETS_ATTR_THUMBNAIL)?.let {
+ "ghraw://AlfaazPlus/QuranAppInventory/master/images/" + it
+ }
)
lastReference = lastProphet
diff --git a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt
index 8e72f61c2..f1d5fc4be 100644
--- a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt
+++ b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt
@@ -26,6 +26,7 @@ object QuranPropheticDuasParser {
private const val PROPHETS_ATTR_ORDER = "order"
private const val PROPHETS_ATTR_NAME = "name"
private const val PROPHETS_ATTR_ICON_RES = "drawable"
+ private const val PROPHETS_ATTR_THUMBNAIL = "thumbnail"
/**
* Parsed strings and chapter labels depend on [appPlatformLocale]. Cached per locale tag.
@@ -84,6 +85,11 @@ object QuranPropheticDuasParser {
PROPHETS_ATTR_ICON_RES,
-1
),
+ thumbnail = parser.getAttributeValue(null,
+ PROPHETS_ATTR_THUMBNAIL
+ )?.let {
+ "ghraw://AlfaazPlus/QuranAppInventory/master/images/" + it
+ }
)
lastReference = lastProphet
diff --git a/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt b/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt
index 5955222bb..361f532b6 100644
--- a/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt
+++ b/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import com.quranapp.android.R
import com.quranapp.android.activities.reference.ActivityPropheticDuas
+import com.quranapp.android.components.ReferenceVerseModel
import com.quranapp.android.components.quran.ExclusiveVerse
import com.quranapp.android.components.quran.ExclusiveVersesDataset
import com.quranapp.android.utils.reader.factory.ReaderFactory
@@ -46,33 +47,36 @@ object ExclusiveVerseNavigator {
ReaderFactory.startReferenceVerse(
context,
- nameTitle,
- description,
- emptySet(),
- verse.chapters,
- verse.versesRaw,
+ ReferenceVerseModel(
+ title = nameTitle,
+ desc = description,
+ chapters = verse.chapters,
+ verses = verse.versesRaw,
+ ),
)
}
private fun openEtiquette(context: Context, verse: ExclusiveVerse) {
- verse.verses.firstOrNull()?.let { reference ->
- ReaderFactory.startVerseRange(
- context,
- reference.first,
- reference.second,
- reference.third,
- )
- }
+ ReaderFactory.startReferenceVerse(
+ context,
+ ReferenceVerseModel(
+ title = verse.title,
+ desc = verse.description,
+ chapters = verse.chapters,
+ verses = verse.versesRaw,
+ ),
+ )
}
private fun openMajorSins(context: Context, verse: ExclusiveVerse) {
ReaderFactory.startReferenceVerse(
context,
- verse.title,
- verse.description,
- emptySet(),
- verse.chapters,
- verse.versesRaw,
+ ReferenceVerseModel(
+ title = verse.title,
+ desc = verse.description,
+ chapters = verse.chapters,
+ verses = verse.versesRaw,
+ )
)
}
@@ -88,11 +92,12 @@ object ExclusiveVerseNavigator {
)
ReaderFactory.startReferenceVerse(
context,
- nameTitle,
- description,
- emptySet(),
- verse.chapters,
- verse.versesRaw,
+ ReferenceVerseModel(
+ title = nameTitle,
+ desc = description,
+ chapters = verse.chapters,
+ verses = verse.versesRaw,
+ )
)
}
}
diff --git a/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt b/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt
index 3c68b8c9d..7af8586c7 100644
--- a/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt
+++ b/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt
@@ -248,12 +248,14 @@ fun String.getQuranScriptPreview(isDark: Boolean): Int = when (this) {
}
/**
+ * In KBs
* Download size -> Uncompressed size
*/
fun String.getQuranScriptFontPackSizeMb(): Pair = when (this) {
- QuranScriptUtils.SCRIPT_KFQPC_V1 -> Pair(52, 90)
- QuranScriptUtils.SCRIPT_KFQPC_V2 -> Pair(129, 200)
- QuranScriptUtils.SCRIPT_KFQPC_V4 -> Pair(132, 320)
+ QuranScriptUtils.SCRIPT_DK_INDOPAK -> Pair(1835, 1835)
+ QuranScriptUtils.SCRIPT_KFQPC_V1 -> Pair(52000, 90000)
+ QuranScriptUtils.SCRIPT_KFQPC_V2 -> Pair(129000, 200000)
+ QuranScriptUtils.SCRIPT_KFQPC_V4 -> Pair(132000, 320000)
else -> Pair(0, 0)
}
diff --git a/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt b/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt
index 1249a97ba..cae9e90eb 100644
--- a/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt
+++ b/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt
@@ -529,6 +529,13 @@ object ReaderItemsBuilder {
)
} else emptyMap()
+ val translationsByVerseNo = loadQuickReferenceTranslationsByVerseNo(
+ factory = factory,
+ slugs = params.slugs,
+ chapterNo = chapterNo,
+ verseNos = verseNos,
+ )
+
for ((idx, verseNo) in verseNos.withIndex()) {
val ayah = batch.ayahByVerseNo[verseNo] ?: continue
val words = batch.wordsByVerseNo[verseNo] ?: emptyList()
@@ -538,9 +545,7 @@ object ReaderItemsBuilder {
ensureQuranTextStyleForPage(pageNo)
}
- val translations = factory.getTranslationsVerseRange(
- params.slugs, chapterNo, verseNo, verseNo
- ).firstOrNull() ?: emptyList()
+ val translations = translationsByVerseNo[verseNo].orEmpty()
val verse = VerseWithDetails(
words = words,
@@ -589,6 +594,49 @@ object ReaderItemsBuilder {
return ReaderPreparedData(out, textStyles)
}
+ private fun loadQuickReferenceTranslationsByVerseNo(
+ factory: QuranTranslationFactory,
+ slugs: Set,
+ chapterNo: Int,
+ verseNos: List,
+ ): Map> {
+ if (verseNos.isEmpty() || slugs.isEmpty()) return emptyMap()
+
+ val uniqueSorted = verseNos.asSequence()
+ .filter { it > 0 }
+ .distinct()
+ .sorted()
+ .toList()
+
+ if (uniqueSorted.isEmpty()) return emptyMap()
+
+ val out = HashMap>(uniqueSorted.size)
+ var runStart = uniqueSorted.first()
+ var prev = runStart
+
+ fun flushRange(start: Int, end: Int) {
+ val grouped = factory.getTranslationsVerseRange(slugs, chapterNo, start, end)
+ for ((idx, verseNo) in (start..end).withIndex()) {
+ out[verseNo] = grouped.getOrNull(idx).orEmpty()
+ }
+ }
+
+ for (i in 1 until uniqueSorted.size) {
+ val verseNo = uniqueSorted[i]
+ if (verseNo == prev + 1) {
+ prev = verseNo
+ continue
+ }
+
+ flushRange(runStart, prev)
+ runStart = verseNo
+ prev = verseNo
+ }
+
+ flushRange(runStart, prev)
+ return out
+ }
+
/**
* Builds several mushaf pages: batched mushaf_map + juz queries, two-phase ayah word preload.
*/
diff --git a/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt b/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt
index 0bdf2c8d1..70b414818 100644
--- a/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt
+++ b/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt
@@ -16,13 +16,14 @@ import com.quranapp.android.api.models.translation.TranslationBookInfoModel
import com.quranapp.android.components.quran.subcomponents.Footnote
import com.quranapp.android.components.quran.subcomponents.Translation
import com.quranapp.android.compose.utils.preferences.ReaderPreferences
+import com.quranapp.android.db.DatabaseProvider
import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_CHAPTER_NO
import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_FOOTNOTES
import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_TEXT
-import com.quranapp.android.db.DatabaseProvider
import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_VERSE_NO
import com.quranapp.android.db.translation.QuranTranslDBHelper
import com.quranapp.android.db.translation.QuranTranslInfoContract.QuranTranslInfoEntry
+import com.quranapp.android.utils.Log
import com.quranapp.android.utils.quran.QuranConstants
import com.quranapp.android.utils.reader.TranslUtils
import org.json.JSONArray
@@ -199,8 +200,12 @@ class QuranTranslationFactory(private val context: Context) : Closeable {
return getTranslationsSingleVerse(ReaderPreferences.getTranslations(), chapNo, verseNo)
}
- fun getTranslationsSingleSlugVerse(slug: String, chapNo: Int, verseNo: Int): Translation {
- return getTranslationsSingleVerse(Collections.singleton(slug), chapNo, verseNo)[0]
+ fun getTranslationsSingleSlugVerse(slug: String, chapNo: Int, verseNo: Int): Translation? {
+ return getTranslationsSingleVerse(
+ Collections.singleton(slug),
+ chapNo,
+ verseNo
+ ).firstOrNull()
}
/**
@@ -374,7 +379,7 @@ class QuranTranslationFactory(private val context: Context) : Closeable {
)
getTranslationsFromCursor(translSlug, cursor)
} catch (e: Exception) {
- e.printStackTrace()
+ Log.saveError(e, "getTranslationsFromQuery")
null
}
}
diff --git a/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt b/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt
index 1d17fe1ed..7db14fa04 100644
--- a/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt
+++ b/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt
@@ -5,11 +5,12 @@ import android.content.Intent
import com.quranapp.android.activities.ActivityReader
import com.quranapp.android.activities.ActivityTafsir
import com.quranapp.android.activities.reference.ActivityReference
+import com.quranapp.android.components.ReferenceThumbnail
import com.quranapp.android.components.ReferenceVerseModel
import com.quranapp.android.components.reader.ChapterVersePair
import com.quranapp.android.compose.components.reader.ReaderMode
import com.quranapp.android.compose.utils.preferences.ReaderPreferences
-import com.quranapp.android.db.entities.ReadHistoryEntity
+import com.quranapp.android.db.entities.user.ReadHistoryEntity
import com.quranapp.android.utils.quran.QuranMeta
import com.quranapp.android.utils.reader.QuranScriptVariant
import com.quranapp.android.utils.reader.ReadType
@@ -43,7 +44,7 @@ object ReaderFactory {
fun startMushafPage(context: Context, pageNo: Int) {
val mushafCode = ReaderPreferences.getQuranScript()
val variant = ReaderPreferences.getQuranScriptVariant()
-
+
context.startActivity(
ReaderLaunchParams(
data = ReaderIntentData.MushafPage(
@@ -132,40 +133,12 @@ object ReaderFactory {
return prepareVerseRangeIntent(chapterNo, range.first, range.second)
}
- fun startReferenceVerse(
- context: Context,
- title: String,
- desc: String?,
- translSlug: Set,
- chapters: Set,
- verses: Set
- ) {
- val intent = prepareReferenceVerseIntent(
- title, desc, translSlug, chapters, verses
- )
- intent.setClass(context, ActivityReference::class.java)
- context.startActivity(intent)
- }
-
fun startReferenceVerse(context: Context, referenceVerseModel: ReferenceVerseModel) {
val intent = prepareReferenceVerseIntent(referenceVerseModel)
intent.setClass(context, ActivityReference::class.java)
context.startActivity(intent)
}
- fun prepareReferenceVerseIntent(
- title: String,
- desc: String?,
- translSlug: Set,
- chapters: Set,
- verses: Set
- ): Intent {
- val referenceVerseModel = ReferenceVerseModel(
- title, desc, translSlug, chapters, verses
- )
- return prepareReferenceVerseIntent(referenceVerseModel)
- }
-
fun prepareReferenceVerseIntent(referenceVerseModel: ReferenceVerseModel): Intent {
return Intent().apply {
putExtras(referenceVerseModel.toBundle())
diff --git a/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt b/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt
index ab7e2128c..61b749ea1 100644
--- a/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt
+++ b/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt
@@ -1,13 +1,11 @@
package com.quranapp.android.utils.verse
import android.content.Context
-import com.quranapp.android.compose.utils.preferences.ReaderPreferences
import com.quranapp.android.compose.utils.preferences.VersePreferences
import com.quranapp.android.db.relations.VerseWithDetails
import com.quranapp.android.repository.QuranRepository
import com.quranapp.android.utils.others.ShortcutUtils
import com.quranapp.android.utils.quran.QuranMeta
-import com.quranapp.android.utils.reader.TranslUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.random.Random
@@ -115,11 +113,4 @@ object VerseUtils {
return chapterNo == votdChapNo && verseNo == votdVerseNo
}
-
- fun obtainOptimalSlugForVotd(): String {
- val savedTranslations = ReaderPreferences.getTranslations()
-
- return savedTranslations.firstOrNull { !TranslUtils.isTransliteration(it) }
- ?: TranslUtils.TRANSL_SLUG_DEFAULT
- }
}
diff --git a/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt b/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt
index a3e5e42ec..247c7bbe3 100644
--- a/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt
+++ b/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt
@@ -10,7 +10,7 @@ import androidx.work.WorkerParameters
import com.quranapp.android.R
import com.quranapp.android.activities.ActivityReader
import com.quranapp.android.activities.reference.ActivityReference
-import com.quranapp.android.compose.utils.appFallbackLanguageCodes
+import com.quranapp.android.components.ReferenceVerseModel
import com.quranapp.android.compose.utils.preferences.VersePreferences
import com.quranapp.android.utils.app.NotificationUtils
import com.quranapp.android.utils.reader.ReaderIntentData
@@ -119,11 +119,13 @@ class RecommendedReminderWorker(
val desc = recommendation.description.takeIf { it.isNotBlank() }
ReaderFactory.prepareReferenceVerseIntent(
- recommendation.title,
- desc,
- emptySet(),
- chapters,
- verseSpecs,
+ ReferenceVerseModel(
+ recommendation.title,
+ desc,
+ emptySet(),
+ chapters,
+ verseSpecs,
+ )
).apply {
setClass(context, ActivityReference::class.java)
}
diff --git a/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt
index 3619df99a..9538892ad 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt
@@ -4,7 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.quranapp.android.db.DatabaseProvider
-import com.quranapp.android.db.entities.BookmarkEntity
+import com.quranapp.android.db.entities.user.BookmarkEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
diff --git a/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt
index 6f8d2681e..15cac5e99 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt
@@ -7,8 +7,9 @@ import com.quranapp.android.db.DatabaseProvider
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
-class ChapterNavigatorViewModel(application: Application) : AndroidViewModel(application) {
- val repository = DatabaseProvider.getQuranRepository(application)
+class ChapterNavigatorViewModel(private val application: Application) :
+ AndroidViewModel(application) {
+ val repository get() = DatabaseProvider.getQuranRepository(application)
val surahs = repository.getAllSurahs()
.stateIn(
@@ -16,4 +17,4 @@ class ChapterNavigatorViewModel(application: Application) : AndroidViewModel(app
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt
index ca0d22bfb..ffc42b56b 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt
@@ -41,8 +41,8 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
-class QuranSearchViewModel(application: Application) : AndroidViewModel(application) {
- private val repository = DatabaseProvider.getQuranRepository(application)
+class QuranSearchViewModel(private val application: Application) : AndroidViewModel(application) {
+ private val repository get() = DatabaseProvider.getQuranRepository(application)
private val searchHistoryStore = SearchHistoryStore(application)
private val _searchQuery = MutableStateFlow("")
@@ -60,6 +60,11 @@ class QuranSearchViewModel(application: Application) : AndroidViewModel(applicat
private val _availableTranslations = MutableStateFlow>(emptyList())
val availableTranslations: StateFlow> = _availableTranslations
+ override fun onCleared() {
+ searchHistoryStore.close()
+ super.onCleared()
+ }
+
init {
loadAvailableTranslations()
}
@@ -91,7 +96,9 @@ class QuranSearchViewModel(application: Application) : AndroidViewModel(applicat
val topicResults: StateFlow> = debouncedQuery
.mapLatest { query ->
- ExclusiveVersesSearchProvider.search(getApplication(), query)
+ withContext(Dispatchers.IO) {
+ ExclusiveVersesSearchProvider.search(getApplication(), query)
+ }
}
.stateIn(
viewModelScope,
@@ -207,15 +214,19 @@ class QuranSearchViewModel(application: Application) : AndroidViewModel(applicat
quickLinks: List,
): List {
val q = query.trim()
+
if (q.isEmpty() || quickLinks.isNotEmpty()) return emptyList()
+
val ql = q.lowercase()
val filtered = _searchHistory.value
.asSequence()
.filter { it.text.lowercase() != ql }
.filter { it.text.contains(q, ignoreCase = true) }
.toList()
+
val prefix = filtered.filter { it.text.startsWith(q, ignoreCase = true) }
val rest = filtered.filter { !it.text.startsWith(q, ignoreCase = true) }
+
return (prefix + rest).distinctBy { it.id }.take(5)
}
}
diff --git a/app/src/main/java/com/quranapp/android/viewModels/QuranicTopicsViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/QuranicTopicsViewModel.kt
new file mode 100644
index 000000000..bf02ad3c5
--- /dev/null
+++ b/app/src/main/java/com/quranapp/android/viewModels/QuranicTopicsViewModel.kt
@@ -0,0 +1,509 @@
+package com.quranapp.android.viewModels
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.quranapp.android.db.DatabaseProvider
+import com.quranapp.android.db.entities.topics.RelationshipType
+import com.quranapp.android.db.relations.topics.TopicRelationshipRow
+import com.quranapp.android.db.relations.topics.TopicSummaryRow
+import com.quranapp.android.repository.TopicVersePreview
+import com.quranapp.android.repository.TopicSearchHit
+import com.quranapp.android.repository.TopicsRepository
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val MAX_TOPIC_DETAIL_CACHE_ENTRIES = 64
+
+enum class TopicsTree(
+ val routeName: String,
+ val parentType: RelationshipType,
+) {
+ Ontology("ontology", RelationshipType.ONTOLOGY_PARENT),
+ Thematic("thematic", RelationshipType.THEMATIC_PARENT);
+
+ companion object {
+ fun fromRouteName(value: String?): TopicsTree =
+ entries.firstOrNull { it.routeName == value } ?: Ontology
+ }
+}
+
+data class TopicNode(
+ val id: Int,
+ val slug: String,
+ val title: String,
+ val type: String,
+ val imageUrl: String?,
+ val icon: String?,
+ val shortDescription: String?,
+ val description: String?,
+ val verseCount: Int,
+ val childCount: Int,
+ val relatedCount: Int,
+) {
+ val isLeaf: Boolean get() = childCount == 0
+}
+
+data class TopicRelationship(
+ val type: RelationshipType,
+ val topic: TopicNode,
+)
+
+data class TopicsUiState(
+ val isLoadingRoots: Boolean = true,
+ val ontologyRoots: List = emptyList(),
+ val thematicRoots: List = emptyList(),
+
+ val ontologyPrimaryRootCount: Int = 0,
+ val ontologySupplementalTotal: Int = 0,
+ val ontologySupplementalLoaded: Int = 0,
+ val hasMoreOntologySupplemental: Boolean = false,
+ val isLoadingMoreOntologySupplemental: Boolean = false,
+
+ val thematicPrimaryRootCount: Int = 0,
+ val thematicSupplementalTotal: Int = 0,
+ val thematicSupplementalLoaded: Int = 0,
+ val hasMoreThematicSupplemental: Boolean = false,
+ val isLoadingMoreThematicSupplemental: Boolean = false,
+
+ val topicDetails: Map = emptyMap(),
+ val error: String? = null,
+)
+
+data class TopicDetailUiState(
+ val isLoading: Boolean = false,
+ val topic: TopicNode? = null,
+ val childTopics: List = emptyList(),
+ val broaderCatalogChildren: List = emptyList(),
+ val verseRefs: List = emptyList(),
+ val versePreviews: List = emptyList(),
+ val breadcrumbs: List = emptyList(),
+ val relationships: List = emptyList(),
+ val error: String? = null,
+)
+
+private data class TopicLoadResult(
+ val topic: TopicNode?,
+ val children: List,
+ val broaderCatalogChildren: List,
+ val verseRefs: List,
+ val versePreviews: List,
+ val breadcrumbs: List,
+ val relationships: List,
+)
+
+private data class RootsLoadResult(
+ val ontologyPrimary: List,
+ val ontologySupplementalTotal: Int,
+ val ontologySupplementalFirstPage: List,
+
+ val thematicPrimary: List,
+ val thematicSupplementalTotal: Int,
+ val thematicSupplementalFirstPage: List,
+) {
+ val ontologySupplementalLoaded: Int get() = ontologySupplementalFirstPage.size
+ val thematicSupplementalLoaded: Int get() = thematicSupplementalFirstPage.size
+}
+
+class QuranicTopicsViewModel(application: Application) : AndroidViewModel(application) {
+ private val repository = DatabaseProvider.getTopicsRepository(application)
+ private val inFlightTopicLoads = mutableSetOf()
+
+ private val _uiState = MutableStateFlow(TopicsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadRoots()
+ }
+
+ fun loadRoots() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoadingRoots = true, error = null) }
+
+ runCatching {
+ val assign = repository.warmSupplementalAssignment()
+
+ val ontologyPrimary = repository.getOntologyRootTopics().toTopicNodes()
+ val thematicPrimary = repository.getThematicRootTopics().toTopicNodes()
+
+ val pageSize = TopicsRepository.SUPPLEMENTAL_ROOT_PAGE_SIZE
+
+ val ontologyExtra = repository
+ .getSupplementalRootTopicsPage(
+ TopicsTree.Ontology.parentType,
+ offset = 0,
+ limit = pageSize,
+ )
+ .toTopicNodes()
+
+ val thematicExtra = repository
+ .getSupplementalRootTopicsPage(
+ TopicsTree.Thematic.parentType,
+ offset = 0,
+ limit = pageSize,
+ )
+ .toTopicNodes()
+
+ RootsLoadResult(
+ ontologyPrimary = ontologyPrimary,
+ thematicPrimary = thematicPrimary,
+ ontologySupplementalTotal = assign.ontologySupplementalRootIds.size,
+ thematicSupplementalTotal = assign.thematicSupplementalRootIds.size,
+ ontologySupplementalFirstPage = ontologyExtra,
+ thematicSupplementalFirstPage = thematicExtra,
+ )
+ }.onSuccess { result ->
+ _uiState.update {
+ it.copy(
+ isLoadingRoots = false,
+ ontologyPrimaryRootCount = result.ontologyPrimary.size,
+ thematicPrimaryRootCount = result.thematicPrimary.size,
+ ontologySupplementalTotal = result.ontologySupplementalTotal,
+ thematicSupplementalTotal = result.thematicSupplementalTotal,
+ ontologySupplementalLoaded = result.ontologySupplementalFirstPage.size,
+ thematicSupplementalLoaded = result.thematicSupplementalFirstPage.size,
+ hasMoreOntologySupplemental = result.ontologySupplementalLoaded < result.ontologySupplementalTotal,
+ hasMoreThematicSupplemental = result.thematicSupplementalLoaded < result.thematicSupplementalTotal,
+ ontologyRoots = result.ontologyPrimary + result.ontologySupplementalFirstPage,
+ thematicRoots = result.thematicPrimary + result.thematicSupplementalFirstPage,
+ )
+ }
+ }.onFailure { throwable ->
+ _uiState.update {
+ it.copy(
+ isLoadingRoots = false,
+ error = throwable.localizedMessage ?: throwable.javaClass.simpleName,
+ )
+ }
+ }
+ }
+ }
+
+ fun loadMoreSupplementalRoots(tree: TopicsTree) {
+ viewModelScope.launch {
+ val snap = _uiState.value
+ when (tree) {
+ TopicsTree.Ontology -> {
+ if (!snap.hasMoreOntologySupplemental || snap.isLoadingMoreOntologySupplemental) return@launch
+
+ _uiState.update { it.copy(isLoadingMoreOntologySupplemental = true) }
+
+ runCatching {
+ val pageSize = TopicsRepository.SUPPLEMENTAL_ROOT_PAGE_SIZE
+ val offset = snap.ontologySupplementalLoaded
+
+ repository.getSupplementalRootTopicsPage(
+ TopicsTree.Ontology.parentType,
+ offset = offset,
+ limit = pageSize,
+ ).toTopicNodes()
+ }.onSuccess { more ->
+ _uiState.update {
+ val newLoaded = it.ontologySupplementalLoaded + more.size
+
+ it.copy(
+ ontologyRoots = it.ontologyRoots + more,
+ ontologySupplementalLoaded = newLoaded,
+ hasMoreOntologySupplemental = newLoaded < it.ontologySupplementalTotal,
+ isLoadingMoreOntologySupplemental = false,
+ )
+ }
+ }.onFailure {
+ _uiState.update { it.copy(isLoadingMoreOntologySupplemental = false) }
+ }
+ }
+
+ TopicsTree.Thematic -> {
+ if (!snap.hasMoreThematicSupplemental || snap.isLoadingMoreThematicSupplemental) return@launch
+
+ _uiState.update { it.copy(isLoadingMoreThematicSupplemental = true) }
+
+ runCatching {
+ val pageSize = TopicsRepository.SUPPLEMENTAL_ROOT_PAGE_SIZE
+ val offset = snap.thematicSupplementalLoaded
+
+ repository.getSupplementalRootTopicsPage(
+ TopicsTree.Thematic.parentType,
+ offset = offset,
+ limit = pageSize,
+ ).toTopicNodes()
+ }.onSuccess { more ->
+ _uiState.update {
+ val newLoaded = it.thematicSupplementalLoaded + more.size
+
+ it.copy(
+ thematicRoots = it.thematicRoots + more,
+ thematicSupplementalLoaded = newLoaded,
+ hasMoreThematicSupplemental = newLoaded < it.thematicSupplementalTotal,
+ isLoadingMoreThematicSupplemental = false,
+ )
+ }
+ }.onFailure {
+ _uiState.update { it.copy(isLoadingMoreThematicSupplemental = false) }
+ }
+ }
+ }
+ }
+ }
+
+ fun loadTopic(topicId: Int, tree: TopicsTree, breadcrumbIds: List = emptyList()) {
+ val detailKey = buildTopicDetailKey(tree, topicId, breadcrumbIds)
+
+ val cached = _uiState.value.topicDetails[detailKey]
+
+ if (cached?.topic != null) return
+
+ val shouldLoad = synchronized(inFlightTopicLoads) {
+ inFlightTopicLoads.add(detailKey)
+ }
+
+ if (!shouldLoad) return
+
+ viewModelScope.launch {
+ _uiState.update {
+ val currentDetails = it.topicDetails[detailKey] ?: TopicDetailUiState()
+
+ val nextDetails = currentDetails.copy(
+ isLoading = true,
+ error = null,
+ )
+
+ it.copy(
+ topicDetails = putDetailWithCap(
+ current = it.topicDetails,
+ key = detailKey,
+ value = nextDetails,
+ )
+ )
+ }
+
+ runCatching {
+ repository.warmSupplementalAssignment()
+
+ val topic = repository.getTopicSummaryForExplorer(topicId, tree.parentType)
+ ?.toTopicNode()
+
+ if (topic == null) {
+ TopicLoadResult(
+ topic = null,
+ children = emptyList(),
+ broaderCatalogChildren = emptyList(),
+ verseRefs = emptyList(),
+ versePreviews = emptyList(),
+ breadcrumbs = emptyList(),
+ relationships = emptyList(),
+ )
+ } else {
+ coroutineScope {
+ val normalizedBreadcrumbIds = breadcrumbIds
+ .distinct()
+ .filter { it != topicId }
+
+ val childrenDeferred = async {
+ repository.getChildTopicsRespectingSupplemental(
+ topic.id,
+ tree.parentType
+ ).toTopicNodes()
+ }
+
+ val breadcrumbsDeferred = async {
+ repository.getTopicSummariesForExplorer(
+ topicIds = normalizedBreadcrumbIds,
+ parentType = tree.parentType,
+ ).toTopicNodes()
+ }
+
+ val verseRefsDeferred = async {
+ repository.getAllTopicVerseRefs(topic.id)
+ }
+
+ val versePreviewsDeferred = async {
+ repository.getTopicVersePreviews(topic.id)
+ }
+
+ val relationshipsDeferred = async {
+ repository.getTopicRelationships(topic.id, tree.parentType)
+ .toTopicRelationships()
+ }
+
+ val children = childrenDeferred.await()
+ val breadcrumbs = breadcrumbsDeferred.await()
+
+ val broaderCatalogChildren = repository.getBroaderCatalogChildren(
+ topicId = topic.id,
+ parentType = tree.parentType,
+ excludeTopicIds = buildSet {
+ add(topic.id)
+ addAll(children.map { child -> child.id })
+ addAll(breadcrumbs.map { breadcrumb -> breadcrumb.id })
+ },
+ ).toTopicNodes()
+
+ val relationships = relationshipsDeferred.await()
+ .distinctBy { it.topic.id }
+ .filterNot { relationship ->
+ relationship.topic.id == topic.id ||
+ children.any { child -> child.id == relationship.topic.id } ||
+ broaderCatalogChildren.any { child -> child.id == relationship.topic.id } ||
+ breadcrumbs.any { breadcrumb -> breadcrumb.id == relationship.topic.id }
+ }
+
+ TopicLoadResult(
+ topic = topic,
+ children = children,
+ broaderCatalogChildren = broaderCatalogChildren,
+ verseRefs = verseRefsDeferred.await(),
+ versePreviews = versePreviewsDeferred.await(),
+ breadcrumbs = breadcrumbs,
+ relationships = relationships,
+ )
+ }
+ }
+ }.onSuccess { result ->
+ _uiState.update {
+ val nextDetails = TopicDetailUiState(
+ isLoading = false,
+ topic = result.topic,
+ childTopics = result.children,
+ broaderCatalogChildren = result.broaderCatalogChildren,
+ verseRefs = result.verseRefs,
+ versePreviews = result.versePreviews,
+ breadcrumbs = result.breadcrumbs,
+ relationships = result.relationships,
+ )
+
+ it.copy(
+ topicDetails = putDetailWithCap(
+ current = it.topicDetails,
+ key = detailKey,
+ value = nextDetails,
+ ),
+ )
+ }
+ }.onFailure { throwable ->
+ _uiState.update {
+ val currentDetails = it.topicDetails[detailKey] ?: TopicDetailUiState()
+
+ val nextDetails = currentDetails.copy(
+ isLoading = false,
+ error = throwable.localizedMessage ?: throwable.javaClass.simpleName,
+ )
+
+ it.copy(
+ topicDetails = putDetailWithCap(
+ current = it.topicDetails,
+ key = detailKey,
+ value = nextDetails,
+ ),
+ )
+ }
+ }.also {
+ synchronized(inFlightTopicLoads) {
+ inFlightTopicLoads.remove(detailKey)
+ }
+ }
+ }
+ }
+
+ suspend fun searchTopicsForTree(
+ query: String,
+ tree: TopicsTree,
+ limit: Int = 60,
+ ): List {
+ val normalized = query.trim()
+ if (normalized.isEmpty()) return emptyList()
+
+ val preferred = when (tree) {
+ TopicsTree.Ontology -> RelationshipType.ONTOLOGY_PARENT
+ TopicsTree.Thematic -> RelationshipType.THEMATIC_PARENT
+ }
+
+ val hits = repository.searchTopicHits(
+ query = normalized,
+ limit = limit,
+ )
+
+ val matchingTree = hits.filter { it.preferredTree == preferred }
+
+ return if (matchingTree.isNotEmpty()) {
+ matchingTree
+ } else {
+ hits
+ }
+ }
+}
+
+private fun List.toTopicNodes(): List =
+ map { it.toTopicNode() }
+
+private fun TopicSummaryRow.toTopicNode(): TopicNode =
+ TopicNode(
+ id = topicId,
+ slug = slug.orEmpty(),
+ title = title,
+ type = type,
+ imageUrl = imageUrl,
+ icon = icon,
+ shortDescription = shortDescription,
+ description = description,
+ verseCount = ayahCount,
+ childCount = childCount,
+ relatedCount = relatedCount,
+ )
+
+private fun List.toTopicRelationships(): List =
+ map {
+ TopicRelationship(
+ type = it.relationshipType,
+ topic = TopicNode(
+ id = it.topicId,
+ slug = it.slug.orEmpty(),
+ title = it.title,
+ type = it.type,
+ imageUrl = it.imageUrl,
+ icon = it.icon,
+ shortDescription = it.shortDescription,
+ description = it.description,
+ verseCount = it.ayahCount,
+ childCount = it.childCount,
+ relatedCount = it.relatedCount,
+ ),
+ )
+ }
+
+private fun putDetailWithCap(
+ current: Map,
+ key: String,
+ value: TopicDetailUiState,
+): Map {
+ val mutable = LinkedHashMap(current)
+
+ mutable.remove(key)
+ mutable[key] = value
+
+ while (mutable.size > MAX_TOPIC_DETAIL_CACHE_ENTRIES) {
+ val oldestKey = mutable.keys.firstOrNull() ?: break
+
+ mutable.remove(oldestKey)
+ }
+
+ return mutable
+}
+
+fun buildTopicDetailKey(
+ tree: TopicsTree,
+ topicId: Int,
+ breadcrumbIds: List,
+): String {
+ val normalizedTrail = breadcrumbIds
+ .distinct()
+ .filter { it != topicId }
+ .joinToString(",")
+
+ return "${tree.routeName}|$topicId|$normalizedTrail"
+}
diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt
index f71b7684e..6d2b21ac9 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt
@@ -13,9 +13,9 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
@OptIn(ExperimentalCoroutinesApi::class)
-class ReadHistoryViewModel(application: Application) : AndroidViewModel(application) {
- private val userRepository = DatabaseProvider.getUserRepository(application)
- private val quranRepository = DatabaseProvider.getQuranRepository(application)
+class ReadHistoryViewModel(private val application: Application) : AndroidViewModel(application) {
+ private val userRepository get() = DatabaseProvider.getUserRepository(application)
+ private val quranRepository get() = DatabaseProvider.getQuranRepository(application)
val chapterNames = appLocaleFlow.mapLatest {
quranRepository.getChapterNames(QuranMeta.chapterRange.toList())
diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt
index 4724bb459..0a2d45315 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt
@@ -28,8 +28,8 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
-class ReaderIndexViewModel(application: Application) : AndroidViewModel(application) {
- val repository = DatabaseProvider.getQuranRepository(application)
+class ReaderIndexViewModel(private val application: Application) : AndroidViewModel(application) {
+ val repository get() = DatabaseProvider.getQuranRepository(application)
private val filtersJson = Json {
ignoreUnknownKeys = true
@@ -169,4 +169,4 @@ class ReaderIndexViewModel(application: Application) : AndroidViewModel(applicat
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt
index c3eb5335e..b44cd81f1 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt
@@ -7,10 +7,10 @@ import com.quranapp.android.utils.mediaplayer.RecitationController
import com.quranapp.android.utils.reader.FontResolver
-open class ReaderProviderViewModel(application: Application) : AndroidViewModel(application) {
+open class ReaderProviderViewModel(private val application: Application) : AndroidViewModel(application) {
val controller = RecitationController.getInstance(application)
- val userRepository = DatabaseProvider.getUserRepository(application)
- val repository = DatabaseProvider.getQuranRepository(application)
+ val userRepository get() = DatabaseProvider.getUserRepository(application)
+ val repository get() = DatabaseProvider.getQuranRepository(application)
val fontResolver = FontResolver.getInstance(application)
- val externalQuranDb = DatabaseProvider.getExternalQuranDatabase(application)
+ val externalQuranDb get() = DatabaseProvider.getExternalQuranDatabase(application)
}
diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt
index 0f04bc2c5..79f4eae96 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt
@@ -16,7 +16,7 @@ import com.quranapp.android.compose.components.reader.ReaderPreparedData
import com.quranapp.android.compose.components.reader.TranslationPageItem
import com.quranapp.android.compose.components.reader.TranslationPageSection
import com.quranapp.android.compose.utils.preferences.ReaderPreferences
-import com.quranapp.android.db.entities.ReadHistoryEntity
+import com.quranapp.android.db.entities.user.ReadHistoryEntity
import com.quranapp.android.utils.Log
import com.quranapp.android.utils.others.ShortcutUtils
import com.quranapp.android.utils.quran.QuranMeta
diff --git a/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt
index 7d0db8fb3..e6db42d8e 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt
@@ -11,9 +11,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
-class RecitationPlayerViewModel(application: Application) : AndroidViewModel(application) {
+class RecitationPlayerViewModel(private val application: Application) :
+ AndroidViewModel(application) {
val controller = RecitationController.getInstance(application)
- val repository = DatabaseProvider.getQuranRepository(application)
+ val repository get() = DatabaseProvider.getQuranRepository(application)
init {
controller.connect()
diff --git a/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt
index 738f55647..89ae41a9f 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt
@@ -72,7 +72,7 @@ class TafsirReaderViewModel(application: Application) : AndroidViewModel(applica
val uiState: StateFlow = _uiState.asStateFlow()
private val context get() = getApplication()
- val repository = DatabaseProvider.getQuranRepository(context)
+ val repository get() = DatabaseProvider.getQuranRepository(context)
private var contentLoadJob: Job? = null
diff --git a/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt
index faedf139f..75b93191e 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt
@@ -9,8 +9,8 @@ import com.quranapp.android.components.transls.TranslModel
import com.quranapp.android.components.transls.TranslationGroupModel
import com.quranapp.android.compose.utils.DataLoadError
import com.quranapp.android.compose.utils.preferences.ReaderPreferences
-import com.quranapp.android.utils.reader.TranslUtils
import com.quranapp.android.search.SearchIndexScheduler
+import com.quranapp.android.utils.reader.TranslUtils
import com.quranapp.android.utils.reader.factory.QuranTranslationFactory
import com.quranapp.android.utils.univ.FileUtils
import kotlinx.coroutines.Dispatchers
@@ -133,6 +133,11 @@ class TranslationViewModel(application: Application) : AndroidViewModel(applicat
}
private fun deleteTranslation(slug: String) {
+ if (TranslUtils.isPrebuilt(slug)) {
+ return
+ }
+
+ var updatedSelectedSlugs: Set = emptySet()
QuranTranslationFactory(application).use {
it.deleteTranslation(slug)
SearchIndexScheduler.enqueueRemoveSlug(application.applicationContext, slug)
@@ -146,12 +151,17 @@ class TranslationViewModel(application: Application) : AndroidViewModel(applicat
)
}.filterNot { it.translations.isEmpty() }
+ updatedSelectedSlugs = current.selectedSlugs - slug
current.copy(
translationGroups = updatedGroups,
- selectedSlugs = current.selectedSlugs - slug
+ selectedSlugs = updatedSelectedSlugs
)
}
}
+
+ viewModelScope.launch {
+ ReaderPreferences.setTranslations(updatedSelectedSlugs)
+ }
}
@@ -244,4 +254,4 @@ class TranslationViewModel(application: Application) : AndroidViewModel(applicat
translFactory.close()
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt
index 54d9ca8ac..bcd102df5 100644
--- a/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt
+++ b/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt
@@ -35,8 +35,8 @@ data class WbwSettingsUiState(
class WbwSettingsViewModel(
application: Application
) : AndroidViewModel(application) {
- private val db = DatabaseProvider.getExternalQuranDatabase(context)
private val context get() = getApplication()
+ private val db get() = DatabaseProvider.getExternalQuranDatabase(context)
private val _uiState = MutableStateFlow(WbwSettingsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
diff --git a/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt b/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt
index 9696f84c3..f35c8e68c 100644
--- a/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt
+++ b/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt
@@ -90,7 +90,7 @@ private data class VotdWidgetUiState(
val verseInfo: String,
val backgroundBitmap: Bitmap,
val arabicTextBitmap: Bitmap?,
- val translationBitmap: Bitmap,
+ val translationBitmap: Bitmap?,
val openReaderIntent: Intent,
val headerHeightDp: Float,
val footerHeightDp: Float,
@@ -218,9 +218,9 @@ private fun VotdGlanceContent(context: Context, state: VotdWidgetUiState?) {
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
- state.arabicTextBitmap?.let { arabicBitmap ->
+ state.arabicTextBitmap?.let {
Image(
- provider = ImageProvider(arabicBitmap),
+ provider = ImageProvider(it),
contentDescription = null,
modifier = GlanceModifier.fillMaxWidth()
.height(state.arabicHeightDp.dp),
@@ -230,13 +230,15 @@ private fun VotdGlanceContent(context: Context, state: VotdWidgetUiState?) {
Spacer(modifier = GlanceModifier.height(state.textVerticalSpacingDp.dp))
}
- Image(
- provider = ImageProvider(state.translationBitmap),
- contentDescription = null,
- modifier = GlanceModifier.fillMaxWidth()
- .height(state.translationHeightDp.dp),
- contentScale = ContentScale.Fit
- )
+ state.translationBitmap?.let {
+ Image(
+ provider = ImageProvider(it),
+ contentDescription = null,
+ modifier = GlanceModifier.fillMaxWidth()
+ .height(state.translationHeightDp.dp),
+ contentScale = ContentScale.Fit
+ )
+ }
}
Box(
@@ -366,21 +368,19 @@ private suspend fun buildVotdWidgetState(
val verseNo = vwd.verseNo
val translation = QuranTranslationFactory(context).use { factory ->
- val bookInfo = factory.getTranslationBookInfo(VerseUtils.obtainOptimalSlugForVotd())
+ val bookInfo = factory.getTranslationBookInfo(ReaderPreferences.primaryTranslationSlug())
factory.getTranslationsSingleSlugVerse(bookInfo.slug, chapterNo, verseNo)
}
- val translationText = translation.text
-
- val translationBitmap = createTextBitmap(
+ val translationBitmap = if (translation != null) createTextBitmap(
context = context,
- text = StringUtils.removeHTML(translationText, false),
+ text = StringUtils.removeHTML(translation.text, false),
typeface = if (translation.isUrdu) context.getFont(R.font.noto_nastaliq_urdu_regular) else null,
textSize = context.sp2px(20f),
color = Color.White.toArgb(),
targetMaxWidth = textMaxWidthPx,
targetMaxHeight = translationHeightPx
- )
+ ) else null
val openIntent = ReaderFactory.prepareSingleVerseIntent(chapterNo, verseNo).apply {
setClass(context, ActivityReader::class.java)
diff --git a/app/src/main/res/drawable/hierarchy.xml b/app/src/main/res/drawable/hierarchy.xml
new file mode 100644
index 000000000..bd6adc8fd
--- /dev/null
+++ b/app/src/main/res/drawable/hierarchy.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/prophet_al_yasaa.webp b/app/src/main/res/drawable/prophet_al_yasaa.webp
new file mode 100644
index 000000000..b87d69a72
Binary files /dev/null and b/app/src/main/res/drawable/prophet_al_yasaa.webp differ
diff --git a/app/src/main/res/drawable/prophet_al_yasaa.xml b/app/src/main/res/drawable/prophet_al_yasaa.xml
deleted file mode 100644
index 78e32e9f5..000000000
--- a/app/src/main/res/drawable/prophet_al_yasaa.xml
+++ /dev/null
@@ -1,103 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_ayyub.webp b/app/src/main/res/drawable/prophet_ayyub.webp
new file mode 100644
index 000000000..632eed91c
Binary files /dev/null and b/app/src/main/res/drawable/prophet_ayyub.webp differ
diff --git a/app/src/main/res/drawable/prophet_ayyub.xml b/app/src/main/res/drawable/prophet_ayyub.xml
deleted file mode 100644
index 23b2224bf..000000000
--- a/app/src/main/res/drawable/prophet_ayyub.xml
+++ /dev/null
@@ -1,96 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_dawud.webp b/app/src/main/res/drawable/prophet_dawud.webp
new file mode 100644
index 000000000..ad5a3264e
Binary files /dev/null and b/app/src/main/res/drawable/prophet_dawud.webp differ
diff --git a/app/src/main/res/drawable/prophet_dawud.xml b/app/src/main/res/drawable/prophet_dawud.xml
deleted file mode 100644
index b16b00fd3..000000000
--- a/app/src/main/res/drawable/prophet_dawud.xml
+++ /dev/null
@@ -1,106 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_dhul_kifl.webp b/app/src/main/res/drawable/prophet_dhul_kifl.webp
new file mode 100644
index 000000000..2268e3478
Binary files /dev/null and b/app/src/main/res/drawable/prophet_dhul_kifl.webp differ
diff --git a/app/src/main/res/drawable/prophet_dhul_kifl.xml b/app/src/main/res/drawable/prophet_dhul_kifl.xml
deleted file mode 100644
index 379e74f57..000000000
--- a/app/src/main/res/drawable/prophet_dhul_kifl.xml
+++ /dev/null
@@ -1,165 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_harun.webp b/app/src/main/res/drawable/prophet_harun.webp
new file mode 100644
index 000000000..1c2550c18
Binary files /dev/null and b/app/src/main/res/drawable/prophet_harun.webp differ
diff --git a/app/src/main/res/drawable/prophet_harun.xml b/app/src/main/res/drawable/prophet_harun.xml
deleted file mode 100644
index b0c428ff3..000000000
--- a/app/src/main/res/drawable/prophet_harun.xml
+++ /dev/null
@@ -1,152 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_hud.webp b/app/src/main/res/drawable/prophet_hud.webp
new file mode 100644
index 000000000..20178698b
Binary files /dev/null and b/app/src/main/res/drawable/prophet_hud.webp differ
diff --git a/app/src/main/res/drawable/prophet_hud.xml b/app/src/main/res/drawable/prophet_hud.xml
deleted file mode 100644
index d9485b32c..000000000
--- a/app/src/main/res/drawable/prophet_hud.xml
+++ /dev/null
@@ -1,144 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_iliyas.webp b/app/src/main/res/drawable/prophet_iliyas.webp
new file mode 100644
index 000000000..10ede9ced
Binary files /dev/null and b/app/src/main/res/drawable/prophet_iliyas.webp differ
diff --git a/app/src/main/res/drawable/prophet_iliyas.xml b/app/src/main/res/drawable/prophet_iliyas.xml
deleted file mode 100644
index b7a129429..000000000
--- a/app/src/main/res/drawable/prophet_iliyas.xml
+++ /dev/null
@@ -1,153 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_ismail.webp b/app/src/main/res/drawable/prophet_ismail.webp
new file mode 100644
index 000000000..79f720c0f
Binary files /dev/null and b/app/src/main/res/drawable/prophet_ismail.webp differ
diff --git a/app/src/main/res/drawable/prophet_ismail.xml b/app/src/main/res/drawable/prophet_ismail.xml
deleted file mode 100644
index 70e33fc10..000000000
--- a/app/src/main/res/drawable/prophet_ismail.xml
+++ /dev/null
@@ -1,198 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_shuaib.webp b/app/src/main/res/drawable/prophet_shuaib.webp
new file mode 100644
index 000000000..0d2c56f75
Binary files /dev/null and b/app/src/main/res/drawable/prophet_shuaib.webp differ
diff --git a/app/src/main/res/drawable/prophet_shuaib.xml b/app/src/main/res/drawable/prophet_shuaib.xml
deleted file mode 100644
index 32177ea80..000000000
--- a/app/src/main/res/drawable/prophet_shuaib.xml
+++ /dev/null
@@ -1,182 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_yahya.webp b/app/src/main/res/drawable/prophet_yahya.webp
new file mode 100644
index 000000000..0659d278b
Binary files /dev/null and b/app/src/main/res/drawable/prophet_yahya.webp differ
diff --git a/app/src/main/res/drawable/prophet_yahya.xml b/app/src/main/res/drawable/prophet_yahya.xml
deleted file mode 100644
index 25cc04ab4..000000000
--- a/app/src/main/res/drawable/prophet_yahya.xml
+++ /dev/null
@@ -1,109 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_yaqub.webp b/app/src/main/res/drawable/prophet_yaqub.webp
new file mode 100644
index 000000000..dd21ac720
Binary files /dev/null and b/app/src/main/res/drawable/prophet_yaqub.webp differ
diff --git a/app/src/main/res/drawable/prophet_yaqub.xml b/app/src/main/res/drawable/prophet_yaqub.xml
deleted file mode 100644
index 58b42b0f4..000000000
--- a/app/src/main/res/drawable/prophet_yaqub.xml
+++ /dev/null
@@ -1,153 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_yunus.webp b/app/src/main/res/drawable/prophet_yunus.webp
new file mode 100644
index 000000000..57652554f
Binary files /dev/null and b/app/src/main/res/drawable/prophet_yunus.webp differ
diff --git a/app/src/main/res/drawable/prophet_yunus.xml b/app/src/main/res/drawable/prophet_yunus.xml
deleted file mode 100644
index e13bd94ec..000000000
--- a/app/src/main/res/drawable/prophet_yunus.xml
+++ /dev/null
@@ -1,103 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/prophet_zakariyya.webp b/app/src/main/res/drawable/prophet_zakariyya.webp
new file mode 100644
index 000000000..839d7efee
Binary files /dev/null and b/app/src/main/res/drawable/prophet_zakariyya.webp differ
diff --git a/app/src/main/res/drawable/prophet_zakariyya.xml b/app/src/main/res/drawable/prophet_zakariyya.xml
deleted file mode 100644
index 8486014a0..000000000
--- a/app/src/main/res/drawable/prophet_zakariyya.xml
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/topic_thumbnail.webp b/app/src/main/res/drawable/topic_thumbnail.webp
new file mode 100644
index 000000000..75114a879
Binary files /dev/null and b/app/src/main/res/drawable/topic_thumbnail.webp differ
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 78468075c..05bc45c84 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -437,4 +437,8 @@
هل تريد تنزيل الصوت كلمة بكلمة لجميع السور؟ قد يستهلك هذا مساحة تخزين وبيانات جوال بشكل كبير.
انتظر حتى تنتهي تنزيلات السور الفردية قبل تنزيل الكل.
انتظر حتى يكتمل التنزيل الشامل قبل تنزيل سورة واحدة.
+ مستكشف موضوعات القرآن
+ الموضوعات حسب التصنيف
+ محتوى هذه الصفحة متاح باللغة الإنجليزية فقط.
+ الحجم التقريبي: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml
index c904909f4..2b136ba91 100644
--- a/app/src/main/res/values-bn/strings.xml
+++ b/app/src/main/res/values-bn/strings.xml
@@ -434,4 +434,8 @@
শব্দভিত্তিক তিলাওয়াতের সব সূরার অডিও ডাউনলোড করবেন? এতে বেশি স্টোরেজ ও মোবাইল ডেটা লাগতে পারে।
সব ডাউনলোড করার আগে পৃথক সূরার ডাউনলোড শেষ হতে দিন।
একক সূরা ডাউনলোডের আগে পূর্ণ ডাউনলোড শেষ হওয়া পর্যন্ত অপেক্ষা করুন।
+ কুরআনিক বিষয় অনুসন্ধান
+ বিষয়ভিত্তিক টপিকসমূহ
+ এই পৃষ্ঠার বিষয়বস্তু কেবল ইংরেজি ভাষায় উপলব্ধ।
+ আনুমানিক আকার: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml
index cf5082169..d817af702 100644
--- a/app/src/main/res/values-ckb/strings.xml
+++ b/app/src/main/res/values-ckb/strings.xml
@@ -434,4 +434,8 @@
دەتەوێت دەنگی تلاوەتی وشە بە وشەی هەموو سوورەتەکان دابەزێنیت؟ ئەمە ڕەنگە بەتوندی شوێنی هەڵگرتن و داتای مۆبایل بەکاربهێنێت.
پێش دابەزاندنی هەموو، چاوەڕوانی تەواوبوونی دابەزاندنی سوورەتە تاکەکان بکە.
پێش دابەزاندنی سوورەتێکی تاک، چاوەڕوانی تەواوبوونی دابەزاندنی گشتی بکە.
+ گەڕانی بابەتەکانی قورئان
+ بابەتەکان بە پێی تێما
+ ناوەڕۆکی ئەم پەڕەیە تەنها بە زمانی ئینگلیزی بەردەستە.
+ قەبارەی خەمڵێندراو: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index fa419a1d2..3e1170b7e 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -437,4 +437,8 @@
Wort-für-Wort-Rezitationsaudio für alle Suren herunterladen? Das kann viel Speicher und mobile Daten verbrauchen.
Warte, bis einzelne Suren-Downloads abgeschlossen sind, bevor du alles herunterlädst.
Warte, bis der vollständige Download abgeschlossen ist, bevor du eine einzelne Sure herunterlädst.
+ Themen-Explorer
+ Themen nach Kategorie
+ Die Inhalte auf dieser Seite sind nur auf Englisch verfügbar.
+ Geschätzte Größe: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 5c1b96bb1..4f05282bf 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -437,4 +437,8 @@
¿Descargar el audio de recitación palabra por palabra de todas las suras? Esto puede consumir bastante almacenamiento y datos móviles.
Espera a que terminen las descargas de suras individuales antes de descargar todo.
Espera a que termine la descarga completa antes de descargar una sola sura.
+ Explorador de temas
+ Temas por categoría
+ El contenido de esta página solo está disponible en inglés.
+ Tamaño estimado: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 984211710..2ae62ca65 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -434,4 +434,8 @@
صوت تلاوت کلمهبهکلمه همه سورهها دانلود شود؟ این کار ممکن است حجم زیادی از حافظه و دیتای موبایل مصرف کند.
پیش از دانلود همه، صبر کنید دانلود سورههای تکی تمام شود.
پیش از دانلود یک سوره، صبر کنید دانلود کامل به پایان برسد.
+ کاوش موضوعات قرآن
+ موضوعات بر اساس دستهبندی
+ محتوای این صفحه فقط به زبان انگلیسی در دسترس است.
+ حجم تخمینی: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml
index b425512e2..96fb2fab1 100644
--- a/app/src/main/res/values-fil/strings.xml
+++ b/app/src/main/res/values-fil/strings.xml
@@ -430,4 +430,8 @@
I-download ang word-by-word recitation audio ng lahat ng surah? Maaaring malaki ang magamit nitong storage at mobile data.
Hintayin munang matapos ang mga indibidwal na download ng surah bago i-download lahat.
Hintayin munang matapos ang buong download bago mag-download ng isang surah.
+ Tagahanap ng mga Paksa
+ Mga paksa ayon sa tema
+ Ang nilalaman sa pahinang ito ay magagamit lamang sa wikang Ingles.
+ Tinatayang laki: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 62ee228e8..fb2b0e4a8 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -437,4 +437,8 @@
Télécharger l\'audio de récitation mot à mot de toutes les sourates ? Cela peut consommer beaucoup de stockage et de données mobiles.
Attendez la fin des téléchargements de sourates individuelles avant de tout télécharger.
Attendez la fin du téléchargement complet avant de télécharger une seule sourate.
+ Explorateur de thèmes
+ Thèmes par catégorie
+ Le contenu de cette page est disponible uniquement en anglais.
+ Taille estimée : %s
\ No newline at end of file
diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml
index c69db3cb4..1dcbb8710 100644
--- a/app/src/main/res/values-gu/strings.xml
+++ b/app/src/main/res/values-gu/strings.xml
@@ -437,4 +437,8 @@
બધી સૂરાઓ માટે શબ્દ-દર-શબ્દ તિલાવત ઓડિયો ડાઉનલોડ કરશો? તે વધુ સ્ટોરેજ અને મોબાઇલ ડેટા વાપરી શકે છે.
બધું ડાઉનલોડ કરતા પહેલાં અલગ સૂરાઓના ડાઉનલોડ પૂર્ણ થવા દો.
એક જ સૂરા ડાઉનલોડ કરતા પહેલાં સંપૂર્ણ ડાઉનલોડ પૂર્ણ થવાની રાહ જુઓ.
+ વિષય એક્સપ્લોરર
+ થીમ મુજબ વિષયો
+ આ પેજનું સામગ્રી માત્ર અંગ્રેજી ભાષામાં ઉપલબ્ધ છે.
+ અંદાજિત કદ: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 98d2dc687..8c586db07 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -441,4 +441,8 @@
क्या सभी सूरह का शब्द-दर-शब्द तिलावत ऑडियो डाउनलोड करना है? इससे काफी स्टोरेज और मोबाइल डेटा खर्च हो सकता है।
सब डाउनलोड करने से पहले अलग-अलग सूरह के डाउनलोड पूरे होने दें।
एक सूरह डाउनलोड करने से पहले पूरा डाउनलोड खत्म होने दें।
+ विषय अन्वेषक
+ विषयवार टॉपिक
+ इस पेज की सामग्री केवल अंग्रेज़ी भाषा में उपलब्ध है।
+ अनुमानित आकार: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index c0837f18c..24523c63a 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -429,4 +429,8 @@
Unduh audio tilawah kata-per-kata untuk semua surah? Ini dapat menggunakan banyak penyimpanan dan data seluler.
Tunggu unduhan surah satuan selesai sebelum mengunduh semuanya.
Tunggu unduhan penuh selesai sebelum mengunduh satu surah.
+ Penjelajah Topik
+ Topik berdasarkan tema
+ Konten di halaman ini hanya tersedia dalam bahasa Inggris.
+ Perkiraan ukuran: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 970d851f1..1a5319853 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -428,4 +428,8 @@
Scaricare l\'audio di recitazione parola per parola per tutte le sure? Potrebbe consumare molto spazio e dati mobili.
Attendi il completamento dei download delle singole sure prima di scaricare tutto.
Attendi il completamento del download completo prima di scaricare una singola sura.
+ Esplora argomenti
+ Argomenti per tema
+ I contenuti di questa pagina sono disponibili solo in inglese.
+ Dimensione stimata: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-ky/strings.xml b/app/src/main/res/values-ky/strings.xml
index 7b19db521..482d5adef 100644
--- a/app/src/main/res/values-ky/strings.xml
+++ b/app/src/main/res/values-ky/strings.xml
@@ -431,4 +431,8 @@
Бардык сүрөлөр үчүн сөзмө-сөз кыраат аудиосун жүктөп алайынбы? Бул көп сактагычты жана мобилдик трафикти колдонушу мүмкүн.
Баарын жүктөөдөн мурда өз-өзүнчө сүрө жүктөөлөрү бүтүшүн күтүңүз.
Бир сүрөнү жүктөөдөн мурда толук жүктөө бүтүшүн күтүңүз.
+ Темалар изилдегичи
+ Темалар боюнча бөлүмдөр
+ Бул беттеги мазмун англис тилинде гана жеткиликтүү.
+ Болжолдуу өлчөмү: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml
index 06a6c15cb..4ae6f2dfa 100644
--- a/app/src/main/res/values-ml/strings.xml
+++ b/app/src/main/res/values-ml/strings.xml
@@ -432,4 +432,8 @@
എല്ലാ സൂറങ്ങൾക്കുമുള്ള വാക്ക്പ്രതി തിലാവത്ത് ഓഡിയോ ഡൗൺലോഡ് ചെയ്യണോ? ഇത് കൂടുതൽ സ്റ്റോറേജും മൊബൈൽ ഡാറ്റയും ഉപയോഗിക്കാം.
എല്ലാം ഡൗൺലോഡ് ചെയ്യുന്നതിന് മുമ്പ് ഓരോ സൂറയുടെയും ഡൗൺലോഡ് പൂർത്തിയാകാൻ കാത്തിരിക്കുക.
ഒരു സൂറ ഡൗൺലോഡ് ചെയ്യുന്നതിന് മുമ്പ് മുഴുവൻ ഡൗൺലോഡ് പൂർത്തിയാകുന്നത് വരെ കാത്തിരിക്കുക.
+ വിഷയാന്വേഷകൻ
+ തീം അടിസ്ഥാനത്തിലുള്ള വിഷയങ്ങൾ
+ ഈ പേജിലെ ഉള്ളടക്കം ഇംഗ്ലീഷ് ഭാഷയിൽ മാത്രമാണ് ലഭ്യം.
+ അനുമാനിത വലുപ്പം: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index d41a5476c..58f17c7d7 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -437,4 +437,8 @@
Baixar o áudio de recitação palavra por palavra de todas as suratas? Isto pode consumir bastante armazenamento e dados móveis.
Aguarde o fim dos downloads de suratas individuais antes de baixar tudo.
Aguarde o download completo terminar antes de baixar uma única surata.
+ Explorador de temas
+ Tópicos por tema
+ O conteúdo desta página está disponível apenas em inglês.
+ Tamanho estimado: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index a5edbe0f1..128be3663 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -445,4 +445,8 @@
Скачать пословное аудио чтения для всех сур? Это может занять много памяти и мобильного трафика.
Дождитесь завершения загрузки отдельных сур перед загрузкой всего.
Дождитесь завершения полной загрузки перед загрузкой одной суры.
+ Навигатор по темам
+ Темы по категориям
+ Содержимое этой страницы доступно только на английском языке.
+ Предполагаемый размер: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-sd/strings.xml b/app/src/main/res/values-sd/strings.xml
index 228431898..0508891b2 100644
--- a/app/src/main/res/values-sd/strings.xml
+++ b/app/src/main/res/values-sd/strings.xml
@@ -427,4 +427,8 @@
ڇا سڀني سورتن جي لفظ بہ لفظ تلاوت آڊيو ڊائون لوڊ ڪجي؟ هن سان گهڻي اسٽوريج ۽ موبائل ڊيٽا استعمال ٿي سگهي ٿي۔
سڀ ڊائون لوڊ ڪرڻ کان اڳ انفرادي سورتن جا ڊائون لوڊ مڪمل ٿيڻ ڏيو۔
هڪ سورت ڊائون لوڊ ڪرڻ کان اڳ مڪمل ڊائون لوڊ ختم ٿيڻ جو انتظار ڪريو۔
+ موضوعن جو ڳولاڪار
+ موضوع موجب عنوان
+ هن صفحي جو مواد صرف انگريزي ٻولي ۾ موجود آهي.
+ اندازي مطابق سائيز: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
index a9e75cb22..428663c3b 100644
--- a/app/src/main/res/values-ta/strings.xml
+++ b/app/src/main/res/values-ta/strings.xml
@@ -427,4 +427,8 @@
அனைத்து ஸூராக்களுக்கும் சொல்-சொல்லாக திலாவத் ஒலியைப் பதிவிறக்கவா? இது அதிக சேமிப்பு மற்றும் மொபைல் டேட்டாவை பயன்படுத்தலாம்.
அனைத்தையும் பதிவிறக்குவதற்கு முன் தனித் ஸூராக்களின் பதிவிறக்கம் முடியும் வரை காத்திருக்கவும்.
ஒரு ஸூராவைப் பதிவிறக்குவதற்கு முன் முழு பதிவிறக்கம் முடியும் வரை காத்திருக்கவும்.
+ தலைப்பு ஆராய்ச்சி
+ கருப்பொருள் அடிப்படையிலான தலைப்புகள்
+ இந்தப் பக்கத்தின் உள்ளடக்கம் ஆங்கில மொழியில் மட்டுமே கிடைக்கும்.
+ மதிப்பிடப்பட்ட அளவு: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 8e816825c..8ff56fbf5 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -434,4 +434,8 @@
Tüm sureler için kelime kelime tilavet sesini indirmek istiyor musunuz? Bu işlem önemli miktarda depolama ve mobil veri kullanabilir.
Hepsini indirmeden önce tek tek sure indirmelerinin bitmesini bekleyin.
Tek bir sure indirmeden önce toplu indirmenin tamamlanmasını bekleyin.
+ Konu Gezgini
+ Temaya göre konular
+ Bu sayfadaki içerikler yalnızca İngilizce dilinde mevcuttur.
+ Tahmini boyut: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml
index b5617ce0f..cee6c239b 100644
--- a/app/src/main/res/values-ur/strings.xml
+++ b/app/src/main/res/values-ur/strings.xml
@@ -441,4 +441,8 @@
کیا تمام سورتوں کی لفظ بہ لفظ تلاوت آڈیو ڈاؤن لوڈ کرنی ہے؟ اس سے کافی اسٹوریج اور موبائل ڈیٹا استعمال ہو سکتا ہے۔
سب ڈاؤن لوڈ کرنے سے پہلے انفرادی سورتوں کے ڈاؤن لوڈ مکمل ہونے دیں۔
ایک سورت ڈاؤن لوڈ کرنے سے پہلے مکمل ڈاؤن لوڈ ختم ہونے دیں۔
+ موضوعات ایکسپلورر
+ موضوع کے لحاظ سے عنوانات
+ اس صفحے کا مواد صرف انگریزی زبان میں دستیاب ہے۔
+ اندازاً سائز: %s
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 3b02413da..fa4dd36e1 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -427,4 +427,8 @@
要下载所有苏拉的逐词诵读音频吗?这可能会占用较多存储空间和移动数据。
请先等待单个苏拉下载完成,再执行全部下载。
请先等待完整下载结束,再下载单个苏拉。
+ 主题导航
+ 按主题分类
+ 本页面内容仅提供英文版本。
+ 预计大小:%s
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index deb41c8a9..f14281286 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -427,4 +427,8 @@
要下載所有蘇拉的逐詞誦讀音訊嗎?這可能會耗用較多儲存空間與行動數據。
請先等待單個蘇拉下載完成,再執行全部下載。
請先等待完整下載結束,再下載單個蘇拉。
+ 主題導覽
+ 依主題分類
+ 本頁內容僅提供英文版本。
+ 預估大小:%s
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 29ac97dc9..b6afd5a8d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -456,4 +456,8 @@
Wait for individual chapter downloads to finish before downloading all.
Wait for the full download to finish before downloading a single chapter.
+ Ontology Explorer
+ Topics by Theme
+ Content on this page is available in English only.
+ Estimated size: %s
diff --git a/app/src/main/res/xml/quran_prophetic_duas.xml b/app/src/main/res/xml/quran_prophetic_duas.xml
index 3eca10496..6c65f1579 100644
--- a/app/src/main/res/xml/quran_prophetic_duas.xml
+++ b/app/src/main/res/xml/quran_prophetic_duas.xml
@@ -1,18 +1,18 @@
- 7:23
- 21:83,38:41
- 2:250
- 2:126-129,14:35-41,26:83-89,37:99-100,60:4-5
- 5:114
- 29:30,26:169
- 3:26-27,4:75,6:162-163,9:59,17:24,17:80,20:114,21:112,23:93-94,23:97-98,23:109,23:118,39:46,46:15
- 7:126,7:143,7:151,7:155-156,2:67,5:25,10:85-86,20:25-35,28:16-17,28:21,28:24
- 11:45,11:47,23:26,23:29,26:117-118,54:10,71:26-28
- 23:39
- 7:89
- 27:19,38:35
- 21:87
- 12:33,12:101
- 3:38,19:4-6,21:89
-
\ No newline at end of file
+ 7:23
+ 21:83,38:41
+ 2:250
+ 2:126-129,14:35-41,26:83-89,37:99-100,60:4-5
+ 5:114
+ 29:30,26:169
+ 3:26-27,4:75,6:162-163,9:59,17:24,17:80,20:114,21:112,23:93-94,23:97-98,23:109,23:118,39:46,46:15
+ 7:126,7:143,7:151,7:155-156,2:67,5:25,10:85-86,20:25-35,28:16-17,28:21,28:24
+ 11:45,11:47,23:26,23:29,26:117-118,54:10,71:26-28
+ 23:39
+ 7:89
+ 27:19,38:35
+ 21:87
+ 12:33,12:101
+ 3:38,19:4-6,21:89
+
diff --git a/app/src/main/res/xml/quran_prophets_reference.xml b/app/src/main/res/xml/quran_prophets_reference.xml
index 83b43d079..979105c30 100644
--- a/app/src/main/res/xml/quran_prophets_reference.xml
+++ b/app/src/main/res/xml/quran_prophets_reference.xml
@@ -1,78 +1,228 @@
-
+
2:31-37,3:33,3:59,7:11-35,7:172,17:61,17:70,18:50,19:58,20:115-121,36:60,38:69
-
+
6:86,38:48
-
+
4:163,6:84,21:83,38:41
-
+
2:251,4:163,5:78,6:84,17:55,21:78-79,27:15,27:16,34:10-13,38:17,38:24-30
-
+
21:85,38:48
-
+
2:248,4:163,6:84,7:122,7:142,7:150,10:75,10:89,19:28,19:53,20:30,20:70,20:90-94,21:48,23:45,25:35,26:13,26:48,28:34,37:114,37:120
-
+
7:65-71,11:50-60,11:89,26:124
-
+
2:124-140,2:258,2:260,3:33,3:65,3:67-68,3:84,3:95,3:97,4:54,4:125,4:163,6:74-75,6:83-84,6:161,9:70,9:114,11:69,11:74-76,12:6,12:38,14:35,15:51-52,15:57,16:120,16:123,19:41,19:46-47,19:58,21:51,21:60,21:62,21:69,22:26,22:43,22:78,26:69,29:16,29:24-26,29:31-32,33:7,37:83,37:104,37:109,38:45,42:13,43:26,51:24,51:31,53:37,57:26,60:4,87:19
-
+
19:56,21:85
-
+
6:85,37:123-130
-
+
2:87,2:136,2:253,3:45-59,3:84,4:157-159,4:163,4:171,5:46,5:78,5:110-116,6:85,19:19-21,19:29-37,19:88,19:91-92,21:91,33:7,42:13,43:59-63,57:27,61:6,61:14
-
+
2:133,2:136,2:140,3:84,4:163,6:84,11:71,12:6,12:38,14:39,19:49,21:72,29:27,37:112-113,38:45
-
+
2:125,2:127,2:133,2:136,2:140,3:84,4:163,6:86,14:39,19:54,21:85,38:48
-
+
6:86,7:80,9:70,11:70-71,11:74,11:77,11:81,11:89,15:59,15:61,15:68,15:71,21:71,21:74,22:43,25:40,26:160-161,26:167,27:54-56,29:26,29:28-33,29:40,37:133,38:13,50:13,51:36,53:53,54:33-34,54:36,54:43,66:10,69:9
-
+
3:144,5:41,11:2,13:43,16:101,17:1,25:1,33:9,33:40,47:2,48:29,61:6,73:1,74:1,88:21
-
+
2:51,2:53-55,2:60-61,2:67-71,2:87,2:92,2:108,2:136,2:246,2:248,3:84,4:153,4:164,5:20-25,6:84,6:91,6:154,7:103-160,10:75-88,11:17,11:96,11:110,14:5-6,14:8,17:2,17:101-102,18:60-78,19:51,20:9-11,20:17-40,20:49-52,20:57-77,20:83-97,21:48,22:44,23:45,23:49,25:35,26:10-52,26:61-67,27:7-10,28:3,28:7,28:10,28:15-20,28:28-38,28:43-46,28:48,28:76,29:39,32:23,33:7,33:69,37:114,37:120,40:23,40:26-27,40:37,40:53,41:45,42:13,43:46,43:49,43:52,44:17,46:12,46:30,51:38,53:36,61:5,79:15,87:19
-
+
3:33,4:163,6:84,7:59,7:61,7:69,9:70,10:71,11:25-34,11:36-48,11:89,14:9,17:3,17:17,19:58,21:76,22:42,23:23-28,25:37,26:105-106,26:116,29:14,33:7,37:75-79,38:12,40:5,40:31,42:13,50:12,51:46,53:52,54:9,57:26,66:10,71:1,71:21-26
-
+
7:73-79,11:61-66,11:89,26:142,27:45,91:13
-
+
7:85-93,11:84-95,26:177,29:36
-
+
2:102,4:163,6:84,21:78-82,27:15-44,34:12-14,38:30,38:34
-
+
3:39,6:85,19:7,19:12,21:90
-
+
2:132-133,2:136,2:140,3:84,3:93,4:163,6:84,11:71,12:6,12:13,12:18,12:38,12:66,12:68,12:83,19:6,19:49,19:58,21:72,29:27,38:45
-
+
4:163,6:86,10:98,21:87,37:139,68:48
-
+
6:84,12:4-101,40:34
-
+
3:37-38,6:85,19:2-11,21:89
-
\ No newline at end of file
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ff7f41a68..338d2f74f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -35,6 +35,7 @@ navigation-ui-ktx = "2.9.7"
accompanist = "0.37.3"
glance = "1.2.0-rc01"
+coil = "3.4.0"
[libraries]
@@ -111,6 +112,8 @@ navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
glance = { module = "androidx.glance:glance", version.ref = "glance" }
glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
+coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
+coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
[plugins]
kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinGradlePlugin" }