Downloads queue activity

pull/47/head
Koitharu 5 years ago
parent 77186d271d
commit e8e95a485b
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -68,6 +68,7 @@
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity" android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true"
android:label="@string/manga_shelf"> android:label="@string/manga_shelf">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
@ -83,9 +84,12 @@
<activity <activity
android:name=".settings.protect.ProtectSetupActivity" android:name=".settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" />
<service <service
android:name="org.koitharu.kotatsu.download.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"

@ -22,7 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState

@ -33,7 +33,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.download.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.download package org.koitharu.kotatsu.download.domain
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@ -6,6 +6,7 @@ import android.net.ConnectivityManager
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@ -60,6 +61,7 @@ class DownloadManager(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.size(coverWidth, coverHeight) .size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build() .build()
).drawable ).drawable
}.getOrNull() }.getOrNull()
@ -98,7 +100,8 @@ class DownloadManager(
} }
} while (false) } while (false)
emit(State.Progress(startId, manga, cover, emit(State.Progress(
startId, manga, cover,
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
@ -186,7 +189,14 @@ class DownloadManager(
val currentChapter: Int, val currentChapter: Int,
val totalPages: Int, val totalPages: Int,
val currentPage: Int, val currentPage: Int,
): State ): State {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = progress.toFloat() / max
}
data class WaitingForNetwork( data class WaitingForNetwork(
override val startId: Int, override val startId: Int,

@ -0,0 +1,101 @@
package org.koitharu.kotatsu.download.ui
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
fun downloadItemAD(
scope: CoroutineScope,
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
bind {
job?.cancel()
job = item.onEach { state ->
binding.textViewTitle.text = state.manga.title
binding.imageViewCover.setImageDrawable(
state.cover ?: getDrawable(R.drawable.ic_placeholder)
)
when (state) {
is DownloadManager.State.Cancelling -> {
binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
}
is DownloadManager.State.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = true
binding.progressBar.max = state.max
binding.progressBar.setProgressCompat(state.progress, true)
binding.textViewPercent.text = (state.percent * 100f).format(1) + "%"
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.WaitingForNetwork -> {
binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
}
}.launchIn(scope)
}
onViewRecycled {
job?.cancel()
job = null
}
}

@ -0,0 +1,58 @@
package org.koitharu.kotatsu.download.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(lifecycleScope)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
LifecycleAwareServiceConnection.bindService(
this,
this,
Intent(this, DownloadService::class.java),
0
).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach {
adapter.items = it?.toList().orEmpty()
binding.textViewHolder.isVisible = it.isNullOrEmpty()
}.launchIn(lifecycleScope)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
top = insets.top
)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

@ -0,0 +1,38 @@
package org.koitharu.kotatsu.download.ui
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
class DownloadsAdapter(
scope: CoroutineScope,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(scope))
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return items[position].value.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<JobStateFlow<DownloadManager.State>>() {
override fun areItemsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value.startId == newItem.value.startId
}
override fun areContentsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value == newItem.value
}
}
}

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.download package org.koitharu.kotatsu.download.ui.service
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@ -13,9 +13,11 @@ import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import kotlin.math.roundToInt
class DownloadNotification( class DownloadNotification(
private val context: Context, private val context: Context,
@ -26,13 +28,19 @@ class DownloadNotification(
private val cancelAction = NotificationCompat.Action( private val cancelAction = NotificationCompat.Action(
R.drawable.ic_cross, R.drawable.ic_cross,
context.getString(android.R.string.cancel), context.getString(android.R.string.cancel),
PendingIntent.getService( PendingIntent.getBroadcast(
context, context,
startId, startId,
DownloadService.getCancelIntent(context, startId), DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
) )
) )
private val listIntent = PendingIntent.getActivity(
context,
REQUEST_LIST,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE,
)
init { init {
builder.setOnlyAlertOnce(true) builder.setOnlyAlertOnce(true)
@ -45,7 +53,7 @@ class DownloadNotification(
builder.setContentText(context.getString(R.string.manga_downloading_)) builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download) builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(null) builder.setContentIntent(listIntent)
builder.setStyle(null) builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap()) builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions() builder.clearActions()
@ -72,7 +80,6 @@ class DownloadNotification(
builder.setSubText(context.getString(R.string.error)) builder.setSubText(context.getString(R.string.error))
builder.setContentText(message) builder.setContentText(message)
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setContentIntent(null)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
} }
@ -89,13 +96,8 @@ class DownloadNotification(
builder.addAction(cancelAction) builder.addAction(cancelAction)
} }
is DownloadManager.State.Progress -> { is DownloadManager.State.Progress -> {
val max = state.totalChapters * PROGRESS_STEP builder.setProgress(state.max, state.progress, false)
val progress = state.currentChapter * PROGRESS_STEP + builder.setContentText((state.percent * 100).format() + "%")
(state.currentPage / state.totalPages.toFloat() * PROGRESS_STEP)
.roundToInt()
val percent = (progress / max.toFloat() * 100).roundToInt()
builder.setProgress(max, progress, false)
builder.setContentText("%d%%".format(percent))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null) builder.setStyle(null)
builder.addAction(cancelAction) builder.addAction(cancelAction)
@ -120,7 +122,7 @@ class DownloadNotification(
companion object { companion object {
private const val CHANNEL_ID = "download" private const val CHANNEL_ID = "download"
private const val PROGRESS_STEP = 20 private const val REQUEST_LIST = 6
fun createChannel(context: Context) { fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

@ -1,7 +1,9 @@
package org.koitharu.kotatsu.download package org.koitharu.kotatsu.download.ui.service
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
@ -11,11 +13,16 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@ -24,9 +31,9 @@ import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.LiveStateFlow import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.toArraySet import org.koitharu.kotatsu.utils.ext.toArraySet
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.set import kotlin.collections.set
@ -35,10 +42,11 @@ class DownloadService : BaseService() {
private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationManager: NotificationManagerCompat
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var dispatcher: ExecutorCoroutineDispatcher
private val jobs = HashMap<Int, LiveStateFlow<DownloadManager.State>>() private val jobs = LinkedHashMap<Int, JobStateFlow<DownloadManager.State>>()
private val jobCount = MutableStateFlow(0)
private val mutex = Mutex() private val mutex = Mutex()
private val controlReceiver = ControlReceiver()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -46,49 +54,40 @@ class DownloadService : BaseService() {
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = DownloadManager(this, get(), get(), get(), get(), get()) downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
DownloadNotification.createChannel(this) DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
when (intent?.action) { val manga = intent?.getParcelableExtra<Manga>(EXTRA_MANGA)
ACTION_DOWNLOAD_START -> { val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
val manga = intent.getParcelableExtra<Manga>(EXTRA_MANGA) return if (manga != null) {
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters) jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
return START_REDELIVER_INTENT START_REDELIVER_INTENT
} else { } else {
stopSelf(startId) stopSelf(startId)
START_NOT_STICKY
} }
} }
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel()
stopSelf(startId)
}
else -> stopSelf(startId)
}
return START_NOT_STICKY
}
override fun onDestroy() {
super.onDestroy()
dispatcher.close()
}
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
super.onBind(intent) super.onBind(intent)
return DownloadBinder() return DownloadBinder()
} }
override fun onDestroy() {
unregisterReceiver(controlReceiver)
super.onDestroy()
}
private fun downloadManga( private fun downloadManga(
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: Set<Long>?,
): LiveStateFlow<DownloadManager.State> { ): JobStateFlow<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null) val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState) val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
val job = lifecycleScope.launch { val job = lifecycleScope.launch {
@ -97,13 +96,19 @@ class DownloadService : BaseService() {
val notification = DownloadNotification(this@DownloadService, startId) val notification = DownloadNotification(this@DownloadService, startId)
startForeground(startId, notification.create(initialState)) startForeground(startId, notification.create(initialState))
try { try {
withContext(dispatcher) { withContext(Dispatchers.Default) {
downloadManager.downloadManga(manga, chaptersIds, startId) downloadManager.downloadManga(manga, chaptersIds, startId)
.collect { state -> .collect { state ->
stateFlow.value = state stateFlow.value = state
notificationManager.notify(startId, notification.create(state)) notificationManager.notify(startId, notification.create(state))
} }
} }
if (stateFlow.value is DownloadManager.State.Done) {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, manga)
)
}
} finally { } finally {
ServiceCompat.stopForeground( ServiceCompat.stopForeground(
this@DownloadService, this@DownloadService,
@ -120,19 +125,33 @@ class DownloadService : BaseService() {
} }
} }
} }
return LiveStateFlow(stateFlow, job) return JobStateFlow(stateFlow, job)
}
inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) {
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel()
jobCount.value = jobs.size
}
}
}
} }
inner class DownloadBinder : Binder() { inner class DownloadBinder : Binder() {
val downloads: Collection<LiveStateFlow<DownloadManager.State>> val downloads: Flow<Collection<JobStateFlow<DownloadManager.State>>>
get() = jobs.values get() = jobCount.mapLatest { jobs.values }
} }
companion object { companion object {
private const val ACTION_DOWNLOAD_START = const val ACTION_DOWNLOAD_COMPLETE =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_START" "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL = private const val ACTION_DOWNLOAD_CANCEL =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
@ -143,7 +162,6 @@ class DownloadService : BaseService() {
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) { fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
confirmDataTransfer(context) { confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java) val intent = Intent(context, DownloadService::class.java)
intent.action = ACTION_DOWNLOAD_START
intent.putExtra(EXTRA_MANGA, manga) intent.putExtra(EXTRA_MANGA, manga)
if (chaptersIds != null) { if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
@ -152,9 +170,7 @@ class DownloadService : BaseService() {
} }
} }
fun getCancelIntent(context: Context, startId: Int) = fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
Intent(context, DownloadService::class.java)
.setAction(ACTION_DOWNLOAD_CANCEL)
.putExtra(ACTION_DOWNLOAD_CANCEL, startId) .putExtra(ACTION_DOWNLOAD_CANCEL, startId)
private fun confirmDataTransfer(context: Context, callback: () -> Unit) { private fun confirmDataTransfer(context: Context, callback: () -> Unit) {

@ -44,6 +44,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
adapter = CategoriesAdapter(this) adapter = CategoriesAdapter(this)
editDelegate = CategoriesEditDelegate(this, this) editDelegate = CategoriesEditDelegate(this, this)
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this) binding.fabAdd.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback()) reorderHelper = ItemTouchHelper(ReorderHelperCallback())

@ -15,5 +15,5 @@ val localModule
single { LocalMangaRepository(androidContext()) } single { LocalMangaRepository(androidContext()) }
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() } factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) } viewModel { LocalListViewModel(get(), get(), get(), get()) }
} }

@ -18,6 +18,7 @@ import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.sub import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.toCamelCase
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
@ -36,8 +37,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
require(offset == 0) { require(offset == 0) {
"LocalMangaRepository does not support pagination" "LocalMangaRepository does not support pagination"
} }
val files = getAvailableStorageDirs(context) val files = getAllFiles()
.flatMap { x -> x.listFiles(filenameFilter)?.toList().orEmpty() }
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() } return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
} }
@ -102,7 +102,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
) )
} }
// fallback // fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize() val title = file.nameWithoutExtension.replace("_", " ").toCamelCase()
val chapters = ArraySet<String>() val chapters = ArraySet<String>()
for (x in zip.entries()) { for (x in zip.entries()) {
if (!x.isDirectory) { if (!x.isDirectory) {
@ -120,7 +120,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s -> chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter( MangaChapter(
id = "$i$s".longHashCode(), id = "$i$s".longHashCode(),
name = if (s.isEmpty()) title else s, name = s.ifEmpty { title },
number = i + 1, number = i + 1,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
url = uriBuilder.fragment(s).build().toString() url = uriBuilder.fragment(s).build().toString()
@ -134,12 +134,35 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
Uri.parse(localManga.url).toFile() Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null }.getOrNull() ?: return null
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val zip = ZipFile(file) @Suppress("BlockingMethodInNonBlockingContext")
ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY) val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
index.getMangaInfo() index.getMangaInfo()
} }
} }
}
suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) {
val files = getAllFiles()
for (file in files) {
@Suppress("BlockingMethodInNonBlockingContext")
val index = ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
entry?.let(zip::readText)?.let(::MangaIndex)
} ?: continue
val info = index.getMangaInfo() ?: continue
if (info.id == remoteManga.id) {
val fileUri = file.toUri().toString()
return@withContext info.copy(
source = MangaSource.LOCAL,
url = fileUri,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
)
}
}
null
}
private fun zipUri(file: File, entryName: String) = private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString() Uri.fromParts("cbz", file.path, entryName).toString()
@ -165,12 +188,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
override suspend fun getTags() = emptySet<MangaTag>() override suspend fun getTags() = emptySet<MangaTag>()
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}
companion object { companion object {
private const val DIR_NAME = "manga" private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean { fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT) val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip" return ext == "cbz" || ext == "zip"
} }

@ -1,6 +1,6 @@
package org.koitharu.kotatsu.local.ui package org.koitharu.kotatsu.local.ui
import android.content.ActivityNotFoundException import android.content.*
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
@ -15,6 +15,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize import org.koitharu.kotatsu.utils.ext.ellipsize
@ -25,12 +26,32 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
ActivityResultContracts.OpenDocument(), ActivityResultContracts.OpenDocument(),
this this
) )
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
viewModel.onRefresh()
}
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
context.registerReceiver(
downloadReceiver,
IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved) viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
} }
override fun onDetach() {
requireContext().unregisterReceiver(downloadReceiver)
super.onDetach()
}
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -65,7 +86,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
override fun onActivityResult(result: Uri?) { override fun onActivityResult(result: Uri?) {
if (result != null) { if (result != null) {
viewModel.importFile(result) viewModel.importFile(context?.applicationContext ?: return, result)
} }
} }

@ -19,7 +19,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MediaStoreCompat import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.sub import java.io.File
import java.io.IOException import java.io.IOException
class LocalListViewModel( class LocalListViewModel(
@ -27,7 +27,6 @@ class LocalListViewModel(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository, private val shortcutsRepository: ShortcutsRepository,
private val context: Context
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
@ -71,7 +70,7 @@ class LocalListViewModel(
override fun onRetry() = onRefresh() override fun onRetry() = onRefresh()
fun importFile(uri: Uri) { fun importFile(context: Context, uri: Uri) {
launchLoadingJob { launchLoadingJob {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -80,8 +79,9 @@ class LocalListViewModel(
if (!LocalMangaRepository.isFileSupported(name)) { if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri") throw UnsupportedFileException("Unsupported file on $uri")
} }
val dest = settings.getStorageDir(context)?.sub(name) val dest = settings.getStorageDir(context)?.let { File(it, name) }
?: throw IOException("External files dir unavailable") ?: throw IOException("External files dir unavailable")
@Suppress("BlockingMethodInNonBlockingContext")
contentResolver.openInputStream(uri)?.use { source -> contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output -> dest.outputStream().use { output ->
source.copyTo(output) source.copyTo(output)

@ -0,0 +1,22 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
class DeferredStateFlow<R, S>(
private val stateFlow: StateFlow<S>,
private val deferred: Deferred<R>,
) : StateFlow<S> by stateFlow, Deferred<R> by deferred {
suspend fun collectAndAwait(): R {
return coroutineScope {
val collectJob = launchIn(this)
val result = await()
collectJob.cancelAndJoin()
result
}
}
}

@ -0,0 +1,21 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
class JobStateFlow<S>(
private val stateFlow: StateFlow<S>,
private val job: Job,
) : StateFlow<S> by stateFlow, Job by job {
suspend fun collectAndJoin(): Unit {
coroutineScope {
val collectJob = launchIn(this)
join()
collectJob.cancelAndJoin()
}
}
}

@ -0,0 +1,49 @@
package org.koitharu.kotatsu.utils
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class LifecycleAwareServiceConnection private constructor(
private val host: Activity,
) : ServiceConnection, DefaultLifecycleObserver {
private val serviceStateFlow = MutableStateFlow<IBinder?>(null)
val service: StateFlow<IBinder?>
get() = serviceStateFlow
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
serviceStateFlow.value = service
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceStateFlow.value = null
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
host.unbindService(this)
}
companion object {
fun bindService(
host: Activity,
lifecycleOwner: LifecycleOwner,
service: Intent,
flags: Int,
): LifecycleAwareServiceConnection {
val connection = LifecycleAwareServiceConnection(host)
host.bindService(service, connection, flags)
lifecycleOwner.lifecycle.addObserver(connection)
return connection
}
}
}

@ -1,12 +0,0 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
class LiveStateFlow<T>(
private val stateFlow: StateFlow<T>,
private val job: Job,
) : StateFlow<T> by stateFlow, Job by job {
}

@ -1,9 +1,12 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T { inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size) val b = Bundle(size)
@ -28,3 +31,9 @@ inline fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
inline fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) { inline fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) {
arguments?.getString(name) arguments?.getString(name)
} }
fun Fragment.bindService(
lifecycleOwner: LifecycleOwner,
service: Intent,
flags: Int,
) = LifecycleAwareServiceConnection.bindService(requireActivity(), lifecycleOwner, service, flags)

@ -16,6 +16,7 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
fun View.hideKeyboard() { fun View.hideKeyboard() {
@ -159,3 +160,15 @@ fun RecyclerView.findCenterViewPosition(): Int {
inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? { inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T) return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
} }
fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) {
if (isIndeterminate != indeterminate) {
if (indeterminate && visibility == View.VISIBLE) {
visibility = View.INVISIBLE
isIndeterminate = indeterminate
visibility = View.VISIBLE
} else {
isIndeterminate = indeterminate
}
}
}

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
style="@style/Widget.Kotatsu.AppBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="4dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.Kotatsu.Toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
<TextView
android:id="@+id/textView_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="20dp"
android:gravity="center"
android:text="@string/text_downloads_holder"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="?android:textColorSecondary"
android:visibility="gone"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingStart="16dp"
android:paddingTop="4dp"
android:paddingEnd="16dp"
android:paddingBottom="6dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_cover"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="4dp"
app:cardElevation="4dp"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/imageView_cover"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toBottomOf="@id/textView_title" />
<TextView
android:id="@+id/textView_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:text="@string/manga_downloading_" />
<TextView
android:id="@+id/textView_percent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
app:layout_constraintBaseline_toBaselineOf="@id/textView_status"
app:layout_constraintEnd_toEndOf="parent"
tools:text="25%" />
<TextView
android:id="@+id/textView_details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="4"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_cover"
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -217,4 +217,6 @@
<string name="backup_saved">Резервная копия успешно сохранена</string> <string name="backup_saved">Резервная копия успешно сохранена</string>
<string name="tracker_warning">Некоторые производители могут изменять поведение системы, нарушая работу фоновых задач.</string> <string name="tracker_warning">Некоторые производители могут изменять поведение системы, нарушая работу фоновых задач.</string>
<string name="read_more">Подробнее</string> <string name="read_more">Подробнее</string>
<string name="queued">В очереди</string>
<string name="text_downloads_holder">На данный момент нет активных загрузок</string>
</resources> </resources>

@ -220,4 +220,6 @@
<string name="backup_saved">Backup saved successfully</string> <string name="backup_saved">Backup saved successfully</string>
<string name="tracker_warning">Some manufacturers can change the system behavior, which may breaks background tasks.</string> <string name="tracker_warning">Some manufacturers can change the system behavior, which may breaks background tasks.</string>
<string name="read_more">Read more</string> <string name="read_more">Read more</string>
<string name="queued">Queued</string>
<string name="text_downloads_holder">There are currently no active downloads</string>
</resources> </resources>
Loading…
Cancel
Save