Fixed: Downloading was not working.

* Fixed: `Online Books` were showing incorrect book sizes. The OPDS stream now provides sizes in bytes instead of kilobytes, so the code has been updated accordingly.
* Added the `io.coil-kt.coil3:coil-compose` library to load favicons for online books, as OPDS now returns favicon URLs instead of Base64-encoded strings.
* Since favicons are no longer provided in Base64 format when downloading ZIM files, we now extract the favicon from the Archive using libkiwix after the download completes. This allows us to display it locally on various screens such as the library, Wi-Fi hotspot, notes, history, and more.
* Cached the `LibkiwixBook` instance to avoid recreating it multiple times when adding or removing bookmarks.
This commit is contained in:
MohitMaliFtechiz 2025-06-05 23:49:02 +05:30
parent 7a10528d19
commit ebbe1b9889
20 changed files with 129 additions and 69 deletions

View File

@ -44,7 +44,6 @@ import androidx.compose.ui.semantics.testTag
import com.tonyodev.fetch2.Status
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.downloader.model.toPainter
import org.kiwix.kiwixmobile.core.ui.components.ContentLoadingProgressBar
import org.kiwix.kiwixmobile.core.ui.components.ProgressBarStyle
import org.kiwix.kiwixmobile.core.ui.models.IconItem
@ -104,7 +103,7 @@ private fun DownloadBookContent(
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
BookIcon(item.favIcon.toPainter())
BookIcon(item.favIconUrl, isOnlineLibrary = true)
Column(
modifier = Modifier
.weight(1f)

View File

@ -48,8 +48,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.zIndex
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.core.downloader.model.Base64String
import org.kiwix.kiwixmobile.core.downloader.model.toPainter
import org.kiwix.kiwixmobile.core.extensions.toast
import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme
import org.kiwix.kiwixmobile.core.ui.theme.PureGrey
@ -58,7 +56,7 @@ import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FIVE_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.ONLINE_BOOK_DISABLED_COLOR_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWO_DP
import org.kiwix.kiwixmobile.core.zim_manager.KiloByte
import org.kiwix.kiwixmobile.core.zim_manager.Byte
import org.kiwix.kiwixmobile.ui.BookDate
import org.kiwix.kiwixmobile.ui.BookDescription
import org.kiwix.kiwixmobile.ui.BookIcon
@ -167,7 +165,7 @@ private fun OnlineBookContent(item: BookItem, bookUtils: BookUtils) {
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
BookIcon(Base64String(item.book.favicon).toPainter())
BookIcon(item.book.favicon, isOnlineLibrary = true)
Column(
modifier = Modifier
.weight(1f)
@ -207,7 +205,7 @@ private fun BookSizeAndDateRow(item: BookItem) {
verticalAlignment = Alignment.CenterVertically
) {
BookSize(
KiloByte(item.book.size).humanReadable,
Byte(item.book.size).humanReadable,
modifier = Modifier.weight(1f).testTag(ONLINE_BOOK_SIZE_TEXT_TESTING_TAG)
)
BookDate(item.book.date)

View File

@ -19,6 +19,7 @@
package org.kiwix.kiwixmobile.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -31,18 +32,17 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import coil3.compose.AsyncImage
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.model.Base64String
import org.kiwix.kiwixmobile.core.downloader.model.toPainter
@ -53,7 +53,7 @@ import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FIVE_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FOUR_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TWO_DP
import org.kiwix.kiwixmobile.core.zim_manager.KiloByte
import org.kiwix.kiwixmobile.core.zim_manager.Byte
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.ArticleCount
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode
@ -116,7 +116,7 @@ private fun BookContent(
if (selectionMode == SelectionMode.MULTI) {
BookCheckbox(bookOnDisk, selectionMode, onMultiSelect, onClick, index)
}
BookIcon(Base64String(bookOnDisk.book.favicon).toPainter())
BookIcon(bookOnDisk.book.favicon, isOnlineLibrary = false)
BookDetails(Modifier.weight(1f), bookOnDisk)
}
}
@ -142,14 +142,23 @@ private fun BookCheckbox(
}
@Composable
fun BookIcon(painter: Painter) {
Icon(
painter = painter,
contentDescription = stringResource(R.string.fav_icon),
modifier = Modifier
.size(BOOK_ICON_SIZE),
tint = Color.Unspecified
)
fun BookIcon(iconSource: String, isOnlineLibrary: Boolean) {
val modifier = Modifier.size(BOOK_ICON_SIZE)
if (isOnlineLibrary) {
AsyncImage(
model = iconSource,
contentDescription = stringResource(R.string.fav_icon),
modifier = modifier,
placeholder = painterResource(R.drawable.default_zim_file_icon),
error = painterResource(R.drawable.default_zim_file_icon),
)
} else {
Image(
painter = Base64String(iconSource).toPainter(),
contentDescription = stringResource(R.string.fav_icon),
modifier = modifier
)
}
}
@Composable
@ -164,7 +173,7 @@ private fun BookDetails(modifier: Modifier, bookOnDisk: BookOnDisk) {
) {
BookDate(bookOnDisk.book.date)
Spacer(modifier = Modifier.width(EIGHT_DP))
BookSize(KiloByte(bookOnDisk.book.size).humanReadable)
BookSize(Byte(bookOnDisk.book.size).humanReadable)
Spacer(modifier = Modifier.width(EIGHT_DP))
BookArticleCount(
ArticleCount(bookOnDisk.book.articleCount.orEmpty())

View File

@ -446,13 +446,19 @@ class ZimManageViewModel @Inject constructor(
): Flow<List<LibkiwixBook>> = flow {
downloadProgress.postValue(context.getString(R.string.starting_downloading_remote_library))
val response = kiwixService.getLibrary()
val resolvedUrl = response.raw().networkResponse?.request?.url
?: response.raw().request.url
val baseHostUrl = "${resolvedUrl.scheme}://${resolvedUrl.host}"
downloadProgress.postValue(context.getString(R.string.parsing_remote_library))
val isLibraryParsed = onlineLibraryManager.parseOPDSStream(response, KIWIX_OPDS_LIBRARY_URL)
if (isLibraryParsed) {
emit(onlineLibraryManager.getOnlineBooks())
} else {
emit(emptyList())
}
val libraryXml = response.body()
val isLibraryParsed = onlineLibraryManager.parseOPDSStream(libraryXml, baseHostUrl)
emit(
if (isLibraryParsed) {
onlineLibraryManager.getOnlineBooks()
} else {
emptyList()
}
)
}
.retry(5)
.catch { e ->

View File

@ -19,7 +19,6 @@
package org.kiwix.kiwixmobile.zimManager.libraryView
import eu.mhutti1.utils.storage.Bytes
import eu.mhutti1.utils.storage.KB
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao
@ -42,7 +41,7 @@ class AvailableSpaceCalculator @Inject constructor(
.map { downloads -> downloads.sumOf(DownloadModel::bytesRemaining) }
.map { bytesToBeDownloaded -> storageCalculator.availableBytes() - bytesToBeDownloaded }
.first()
if (bookItem.book.size.toLong() * KB < trueAvailableBytes) {
if (bookItem.book.size.toLong() < trueAvailableBytes) {
successAction.invoke(bookItem)
} else {
failureAction.invoke(Bytes(trueAvailableBytes).humanReadable)
@ -50,5 +49,5 @@ class AvailableSpaceCalculator @Inject constructor(
}
suspend fun hasAvailableSpaceForBook(book: LibkiwixBook) =
book.size.toLong() * KB < storageCalculator.availableBytes()
book.size.toLong() < storageCalculator.availableBytes()
}

View File

@ -20,7 +20,6 @@ package org.kiwix.kiwixmobile.zimManager.libraryView.adapter
import androidx.annotation.StringRes
import com.tonyodev.fetch2.Status
import org.kiwix.kiwixmobile.core.downloader.model.Base64String
import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.core.downloader.model.DownloadState
import org.kiwix.kiwixmobile.core.downloader.model.Seconds
@ -61,7 +60,7 @@ sealed class LibraryListItem {
data class LibraryDownloadItem(
val downloadId: Long,
val favIcon: Base64String,
val favIconUrl: String,
val title: String,
val description: String?,
val bytesDownloaded: Long,
@ -76,7 +75,7 @@ sealed class LibraryListItem {
constructor(downloadModel: DownloadModel) : this(
downloadModel.downloadId,
Base64String(downloadModel.book.favicon),
downloadModel.book.favicon,
downloadModel.book.title,
downloadModel.book.description,
downloadModel.bytesDownloaded,

View File

@ -369,4 +369,7 @@ object Libs {
const val COMPOSE_LIVE_DATA =
"androidx.compose.runtime:runtime-livedata:${Versions.COMPOSE_VERSION}"
const val COIL3_COMPOSE = "io.coil-kt.coil3:coil-compose:${Versions.COIL_COMPOSE}"
const val COIL3_OKHTTP_COMPOSE = "io.coil-kt.coil3:coil-network-okhttp:${Versions.COIL_COMPOSE}"
}

View File

@ -115,6 +115,8 @@ object Versions {
const val COMPOSE_MATERIAL3 = "1.3.1"
const val TURBINE_FLOW_TEST = "1.2.0"
const val COIL_COMPOSE = "3.2.0"
}
/**

View File

@ -243,6 +243,8 @@ class AllProjectConfigurer {
implementation(Libs.ANDROIDX_ACTIVITY_COMPOSE)
implementation(Libs.COMPOSE_TOOLING_PREVIEW)
implementation(Libs.COMPOSE_LIVE_DATA)
implementation(Libs.COIL3_COMPOSE)
implementation(Libs.COIL3_OKHTTP_COMPOSE)
// Compose UI test implementation
androidTestImplementation(Libs.COMPOSE_UI_TEST_JUNIT)

View File

@ -21,7 +21,7 @@
<ID>MagicNumber:DownloaderModule.kt$DownloaderModule$5</ID>
<ID>MagicNumber:FileUtils.kt$FileUtils$3</ID>
<ID>MagicNumber:JNIInitialiser.kt$JNIInitialiser$1024</ID>
<ID>MagicNumber:KiloByte.kt$KiloByte$1024.0</ID>
<ID>MagicNumber:Byte.kt$Byte$1024.0</ID>
<ID>MagicNumber:MainMenu.kt$MainMenu$99</ID>
<ID>MagicNumber:OnSwipeTouchListener.kt$OnSwipeTouchListener.GestureListener$100</ID>
<ID>MagicNumber:SearchResultGenerator.kt$ZimSearchResultGenerator$200</ID>
@ -42,7 +42,7 @@
<ID>NestedBlockDepth:StorageDeviceUtils.kt$StorageDeviceUtils$// Amazingly file.canWrite() does not always return the correct value private fun canWrite(file: File): Boolean</ID>
<ID>PackageNaming:ArticleCount.kt$package org.kiwix.kiwixmobile.core.zim_manager.fileselect_view</ID>
<ID>PackageNaming:BooksOnDiskListItem.kt$package org.kiwix.kiwixmobile.core.zim_manager.fileselect_view</ID>
<ID>PackageNaming:KiloByte.kt$package org.kiwix.kiwixmobile.core.zim_manager</ID>
<ID>PackageNaming:Byte.kt$package org.kiwix.kiwixmobile.core.zim_manager</ID>
<ID>PackageNaming:KiwixTag.kt$package org.kiwix.kiwixmobile.core.zim_manager</ID>
<ID>PackageNaming:Language.kt$package org.kiwix.kiwixmobile.core.zim_manager</ID>
<ID>PackageNaming:MountPointProducer.kt$package org.kiwix.kiwixmobile.core.zim_manager</ID>

View File

@ -18,6 +18,7 @@
package org.kiwix.kiwixmobile.core.dao
import android.util.Base64
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
@ -25,16 +26,20 @@ import androidx.room.Query
import androidx.room.Update
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.Status.COMPLETED
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.kiwix.kiwixmobile.core.dao.entities.DownloadRoomEntity
import org.kiwix.kiwixmobile.core.downloader.DownloadRequester
import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.core.downloader.model.DownloadRequest
import org.kiwix.kiwixmobile.core.entity.LibkiwixBook
import org.kiwix.kiwixmobile.core.reader.ILLUSTRATION_SIZE
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem
import org.kiwix.libzim.Archive
import javax.inject.Inject
@Dao
@ -53,15 +58,37 @@ abstract class DownloadRoomDao {
fun allDownloads() = getAllDownloads().map { it.map(::DownloadModel) }
private fun moveCompletedToBooksOnDiskDao(downloadEntities: List<DownloadRoomEntity>) {
@Suppress("InjectDispatcher")
private suspend fun moveCompletedToBooksOnDiskDao(downloadEntities: List<DownloadRoomEntity>) {
downloadEntities.filter { it.status == COMPLETED }
.takeIf(List<DownloadRoomEntity>::isNotEmpty)
?.let {
deleteDownloadsList(it)
newBookDao.insert(it.map(BooksOnDiskListItem::BookOnDisk))
?.let { completedDownloads ->
deleteDownloadsList(completedDownloads)
// We now use the OPDS stream instead of the custom library.xml handling.
// In the OPDS stream, the favicon is a URL instead of a Base64 string.
// So when a download is completed, we extract the illustration directly from the archive.
val booksOnDisk = completedDownloads.map { download ->
val archive = withContext(Dispatchers.IO) {
Archive(download.file)
}
val favicon = getOnlineBookFaviconForOfflineUsages(archive).orEmpty()
val updatedEntity = download.copy(favIcon = favicon)
BooksOnDiskListItem.BookOnDisk(updatedEntity)
}
newBookDao.insert(booksOnDisk)
}
}
private fun getOnlineBookFaviconForOfflineUsages(archive: Archive): String? =
if (archive.hasIllustration(ILLUSTRATION_SIZE)) {
Base64.encodeToString(
archive.getIllustrationItem(ILLUSTRATION_SIZE).data.data,
Base64.DEFAULT
)
} else {
null
}
fun update(download: Download) {
getEntityForDownloadId(download.id.toLong())?.let { downloadRoomEntity ->
downloadRoomEntity.updateWith(download)

View File

@ -285,6 +285,7 @@ class LibkiwixBookmarks @Inject constructor(
// Check if the book has an illustration of the specified size and encode it to Base64.
val favicon = book?.getFavicon()
Log.e(TAG, "getBookmarksList: $favicon")
val zimReaderSource = book?.path?.let { ZimReaderSource(File(it)) }
// Return the LibkiwixBookmarkItem, filtering out null results.

View File

@ -21,14 +21,16 @@ package org.kiwix.kiwixmobile.core.data.remote
import okhttp3.OkHttpClient
import org.kiwix.kiwixmobile.core.entity.MetaLinkNetworkEntity
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.converter.simplexml.SimpleXmlConverterFactory
import retrofit2.http.GET
import retrofit2.http.Url
interface KiwixService {
@GET(OPDS_LIBRARY_NETWORK_PATH)
suspend fun getLibrary(): String?
suspend fun getLibrary(): Response<String>
@GET
suspend fun getMetaLinks(
@ -43,7 +45,7 @@ interface KiwixService {
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(ScalarsConverterFactory.create())
// .addConverterFactory(SimpleXmlConverterFactory.create())
.addConverterFactory(SimpleXmlConverterFactory.create())
.build()
return retrofit.create(KiwixService::class.java)
}
@ -52,6 +54,6 @@ interface KiwixService {
companion object {
// To fetch the full OPDS catalog.
// TODO we will change this to pagination later once we migrate to OPDS properly.
const val OPDS_LIBRARY_NETWORK_PATH = "entries?count=-1"
const val OPDS_LIBRARY_NETWORK_PATH = "v2/entries?count=-1"
}
}

View File

@ -38,7 +38,7 @@ const val CONNECTION_TIMEOUT = 10L
const val READ_TIMEOUT = 300L
const val CALL_TIMEOUT = 300L
const val USER_AGENT = "kiwix-android-version:${BuildConfig.VERSION_CODE}"
const val KIWIX_OPDS_LIBRARY_URL = "https://opds.library.kiwix.org/v2/"
const val KIWIX_OPDS_LIBRARY_URL = "https://opds.library.kiwix.org/"
@Module
class NetworkModule {

View File

@ -38,7 +38,7 @@ value class Base64String(private val encodedString: String?) {
BitmapFactory.decodeByteArray(it, 0, it.size)
}
}
} catch (illegalArgumentException: IllegalArgumentException) {
} catch (_: IllegalArgumentException) {
null
}
}

View File

@ -34,7 +34,7 @@ import org.kiwix.kiwixmobile.core.R
data class DownloadItem(
val downloadId: Long,
val favIcon: Base64String,
val favIconUrl: String,
val title: String,
val description: String?,
val bytesDownloaded: Long,
@ -47,7 +47,7 @@ data class DownloadItem(
constructor(downloadModel: DownloadModel) : this(
downloadModel.downloadId,
Base64String(downloadModel.book.favicon),
downloadModel.book.favicon,
downloadModel.book.title,
downloadModel.book.description,
downloadModel.bytesDownloaded,

View File

@ -19,6 +19,7 @@
package org.kiwix.kiwixmobile.core.extensions
import android.util.Base64
import android.util.Log
import org.kiwix.kiwixmobile.core.CoreApp
import org.kiwix.kiwixmobile.core.entity.LibkiwixBook
import org.kiwix.kiwixmobile.core.reader.ILLUSTRATION_SIZE
@ -57,6 +58,20 @@ fun LibkiwixBook.buildSearchableText(bookUtils: BookUtils): String =
}
}.toString()
fun Book.getFavicon(): String? = getIllustration(ILLUSTRATION_SIZE)?.data?.let {
Base64.encodeToString(it, Base64.DEFAULT)
}
fun Book?.getFavicon(): String? =
runCatching {
val illustration = this?.getIllustration(ILLUSTRATION_SIZE)
illustration?.url()?.ifBlank {
illustration.data?.let {
Base64.encodeToString(it, Base64.DEFAULT)
}
}
}.getOrElse {
it.printStackTrace().also {
this?.illustrations?.forEach { illustration ->
Log.e("BOOK", "getFavicon: ${illustration.data} and ${illustration.url()}")
}
Log.e("BOOK", "getFavicon: ${this?.title}")
}
""
}

View File

@ -24,20 +24,6 @@ import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.widget.ImageViewCompat
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.model.Base64String
fun ImageView.setBitmap(base64String: Base64String) {
base64String.toBitmap()
?.let(::setImageBitmap)
?: kotlin.run { setImageDrawableCompat(R.drawable.default_zim_file_icon) }
}
// methods that accept inline classes as parameters are not allowed to be called from java
// hence this facade
fun ImageView.setBitmapFromString(string: String?) {
setBitmap(Base64String(string))
}
fun ImageView.setImageDrawableCompat(
@DrawableRes id: Int

View File

@ -340,6 +340,7 @@ abstract class CoreReaderFragment :
private val navigationHistoryList: MutableList<NavigationHistoryListItem> = ArrayList()
private var isReadSelection = false
private var isReadAloudServiceRunning = false
private var libkiwixBook: Book? = null
private var readerLifeCycleScope: CoroutineScope? = null
val coreReaderLifeCycleScope: CoroutineScope?
@ -2038,9 +2039,7 @@ abstract class CoreReaderFragment :
lifecycleScope.launch {
getCurrentWebView()?.url?.let { articleUrl ->
zimReaderContainer?.zimFileReader?.let { zimFileReader ->
val libKiwixBook = Book().apply {
update(zimFileReader.jniKiwixReader)
}
val libKiwixBook = getLibkiwixBook(zimFileReader)
if (isBookmarked) {
repositoryActions?.deleteBookmark(libKiwixBook.id, articleUrl)
snackBarRoot?.snack(R.string.bookmark_removed)
@ -2071,6 +2070,19 @@ abstract class CoreReaderFragment :
}
}
/**
* Returns the libkiwix book evertime when user saves or remove the bookmark.
* the object will be created once to avoid creating it multiple times.
*/
private fun getLibkiwixBook(zimFileReader: ZimFileReader): Book {
libkiwixBook?.let { return it }
val book = Book().apply {
update(zimFileReader.jniKiwixReader)
}
libkiwixBook = book
return book
}
override fun onResume() {
super.onResume()
updateBottomToolbarVisibility()

View File

@ -23,10 +23,10 @@ import kotlin.math.log10
import kotlin.math.pow
@JvmInline
value class KiloByte(private val kilobyteString: String?) {
value class Byte(private val byteString: String?) {
val humanReadable
get() = kilobyteString?.toLongOrNull()?.let {
val units = arrayOf("KB", "MB", "GB", "TB")
get() = byteString?.toLongOrNull()?.let {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
val conversion = (log10(it.toDouble()) / log10(1024.0)).toInt()
DecimalFormat("#,##0.#")
.format(it / 1024.0.pow(conversion.toDouble())) +