Downloading manga

This commit is contained in:
Koitharu
2020-02-08 15:55:09 +02:00
parent b69c624442
commit 5b858edc97
25 changed files with 551 additions and 34 deletions

View File

@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.koitharu.kotatsu">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -33,6 +34,8 @@
android:name="android.app.searchable"
android:resource="@xml/search" />
</activity>
<service android:name=".ui.download.DownloadService" />
</application>
</manifest>

View File

@@ -8,6 +8,7 @@ import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext
import java.util.concurrent.TimeUnit
@@ -40,6 +41,10 @@ class KotatsuApp : Application() {
factory {
AppSettings(applicationContext)
}
}, module {
single {
PagesCache(applicationContext)
}
}
))
}

View File

@@ -13,12 +13,11 @@ data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "localized_title") val localizedTitle: String? = null,
@ColumnInfo(name = "alt_title") val altTitle: String? = null,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "rating") val rating: Float = Manga.NO_RATING, //normalized value [0..1] or -1
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String? = null,
@ColumnInfo(name = "summary") val summary: String,
@ColumnInfo(name = "state") val state: String? = null,
@ColumnInfo(name = "source") val source: String
) {
@@ -26,8 +25,7 @@ data class MangaEntity(
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
id = this.id,
title = this.title,
localizedTitle = this.localizedTitle,
summary = this.summary,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
url = this.url,
@@ -45,10 +43,9 @@ data class MangaEntity(
source = manga.source.name,
largeCoverUrl = manga.largeCoverUrl,
coverUrl = manga.coverUrl,
localizedTitle = manga.localizedTitle,
altTitle = manga.altTitle,
rating = manga.rating,
state = manga.state?.name,
summary = manga.summary,
// tags = manga.tags.map(TagEntity.Companion::fromMangaTag),
title = manga.title
)

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.core.local
import android.content.Context
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
import java.io.OutputStream
class PagesCache(context: Context) {
private val cacheDir = File(context.externalCacheDir ?: context.cacheDir, "pages")
init {
if (!cacheDir.exists()) {
cacheDir.mkdir()
}
}
operator fun get(url: String) = cacheDir.sub(url.longHashCode().toString()).takeIfReadable()
fun put(url: String, writer: (OutputStream) -> Unit): File {
val file = cacheDir.sub(url.longHashCode().toString())
file.outputStream().use(writer)
return file
}
}

View File

@@ -7,12 +7,11 @@ import kotlinx.android.parcel.Parcelize
data class Manga(
val id: Long,
val title: String,
val localizedTitle: String? = null,
val altTitle: String? = null,
val url: String,
val rating: Float = NO_RATING, //normalized value [0..1] or -1
val coverUrl: String,
val largeCoverUrl: String? = null,
val summary: String,
val description: String? = null, //HTML
val tags: Set<MangaTag> = emptySet(),
val state: MangaState? = null,

View File

@@ -42,10 +42,9 @@ abstract class GroupleRepository(
Manga(
id = href.longHashCode(),
url = href,
localizedTitle = title,
title = descDiv.selectFirst("h4")?.text() ?: title,
title = title,
altTitle = descDiv.selectFirst("h4")?.text(),
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original").orEmpty(),
summary = "",
rating = safe {
node.selectFirst("div.rating")
?.attr("title")

View File

@@ -0,0 +1,115 @@
package org.koitharu.kotatsu.domain.local
import androidx.annotation.WorkerThread
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.toFileName
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@WorkerThread
class MangaZip(private val file: File) {
private val dir = file.parentFile?.sub(file.name + ".dir")?.takeIf { it.mkdir() }
?: throw RuntimeException("Cannot create temporary directory")
private lateinit var index: JSONObject
fun prepare(manga: Manga) {
extract()
index = dir.sub("index.json").takeIfReadable()?.readText()?.let { JSONObject(it) } ?: JSONObject()
index.put("id", manga.id)
index.put("title", manga.title)
index.put("title_alt", manga.altTitle)
index.put("url", manga.url)
index.put("cover", manga.coverUrl)
index.put("description", manga.description)
index.put("rating", manga.rating)
index.put("source", manga.source.name)
index.put("cover_large", manga.largeCoverUrl)
index.put("tags", JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
}
})
index.put("chapters", JSONObject())
index.put("app_id", BuildConfig.APPLICATION_ID)
index.put("app_version", BuildConfig.VERSION_CODE)
}
fun cleanup() {
dir.deleteRecursively()
}
fun compress() {
dir.sub("index.json").writeText(index.toString(4))
ZipOutputStream(file.outputStream()).use { out ->
for (file in dir.listFiles().orEmpty()) {
val entry = ZipEntry(file.name)
out.putNextEntry(entry)
file.inputStream().use { stream ->
stream.copyTo(out)
}
out.closeEntry()
}
}
}
private fun extract() {
if (!file.exists()) {
return
}
ZipInputStream(file.inputStream()).use { input ->
while(true) {
val entry = input.nextEntry ?: return
if (!entry.isDirectory) {
dir.sub(entry.name).outputStream().use { out->
input.copyTo(out)
}
}
input.closeEntry()
}
}
}
fun addCover(file: File) {
val name = FILENAME_PATTERN.format(0, 0)
file.copyTo(dir.sub(name), overwrite = true)
}
fun addPage(page: MangaPage, chapter: MangaChapter, file: File, pageNumber: Int) {
val name = FILENAME_PATTERN.format(chapter.number, pageNumber)
file.copyTo(dir.sub(name), overwrite = true)
val chapters = index.getJSONObject("chapters")
if (!chapters.has(chapter.number.toString())) {
val jo = JSONObject()
jo.put("id", chapter.id)
jo.put("url", chapter.url)
jo.put("name", chapter.name)
chapters.put(chapter.number.toString(), jo)
}
}
companion object {
private const val FILENAME_PATTERN = "%03d%03d"
fun findInDir(root: File, manga: Manga): MangaZip {
val name = manga.title.toFileName() + ".cbz"
val file = File(root, name)
return MangaZip(file)
}
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.ui.common
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import moxy.MvpPresenter
import moxy.MvpView
import org.koin.core.KoinComponent
@@ -14,7 +16,7 @@ abstract class BasePresenter<V : MvpView> : MvpPresenter<V>(), KoinComponent, Co
get() = Dispatchers.Main + job
override fun onDestroy() {
coroutineContext.cancel()
job.cancel()
super.onDestroy()
}
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.ui.common
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.annotation.CallSuper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.KoinComponent
import kotlin.coroutines.CoroutineContext
abstract class BaseService : Service(), KoinComponent, CoroutineScope {
private val job = SupervisorJob()
final override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
@CallSuper
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}

View File

@@ -13,7 +13,9 @@ import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.utils.ext.showPopupMenu
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView,
OnRecyclerItemClickListener<MangaChapter> {
@@ -63,4 +65,21 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
)
)
}
override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean {
view.showPopupMenu(R.menu.popup_chapter) {
val ctx = context ?: return@showPopupMenu false
val m = manga ?: return@showPopupMenu false
when (it.itemId) {
R.id.action_save_this -> DownloadService.start(ctx, m, setOf(item.id))
R.id.action_save_this_next -> DownloadService.start(ctx, m, m.chapters.orEmpty()
.filter { x -> x.number >= item.number }.map { x -> x.id })
R.id.action_save_this_prev -> DownloadService.start(ctx, m, m.chapters.orEmpty()
.filter { x -> x.number <= item.number }.map { x -> x.id })
else -> return@showPopupMenu false
}
true
}
return true
}
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -57,6 +58,12 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
}
true
}
R.id.action_save -> {
manga?.let {
DownloadService.start(this, it)
}
true
}
else -> super.onOptionsItemSelected(item)
}

View File

@@ -26,7 +26,7 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
this.manga = manga
imageView_cover.load(manga.largeCoverUrl ?: manga.coverUrl)
textView_title.text = manga.title
textView_subtitle.text = manga.localizedTitle
textView_subtitle.text = manga.altTitle
textView_description.text = manga.description?.parseAsHtml()
if (manga.rating == Manga.NO_RATING) {
ratingBar.isVisible = false

View File

@@ -0,0 +1,81 @@
package org.koitharu.kotatsu.ui.download
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.core.app.NotificationCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import kotlin.math.roundToInt
class DownloadNotification(private val context: Context) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val manager =
context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW
)
channel.enableVibration(false)
manager.createNotificationChannel(channel)
}
builder.setOnlyAlertOnce(true)
}
fun fillFrom(manga: Manga) {
builder.setContentTitle(manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setSubText(context.getText(R.string.preparing_))
builder.setLargeIcon(null)
}
fun setLargeIcon(icon: Drawable?) {
builder.setLargeIcon((icon as? BitmapDrawable)?.bitmap)
}
fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) {
val max = chaptersTotal * PROGRESS_STEP
val progress =
chapter * PROGRESS_STEP + (page / pagesTotal.toFloat() * PROGRESS_STEP).roundToInt()
val percent = (progress / max.toFloat() * 100).roundToInt()
builder.setProgress(max, progress, false)
builder.setSubText("$percent%")
}
fun setPostProcessing() {
builder.setProgress(1, 0, true)
builder.setSubText(context.getString(R.string.processing_))
}
fun setDone() {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setSubText(null)
}
fun update(id: Int = NOTIFICATION_ID) {
manager.notify(id, builder.build())
}
operator fun invoke(): Notification = builder.build()
companion object {
const val NOTIFICATION_ID = 201
const val CHANNEL_ID = "download"
private const val PROGRESS_STEP = 20
}
}

View File

@@ -0,0 +1,146 @@
package org.koitharu.kotatsu.ui.download
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import coil.Coil
import coil.api.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.inject
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.local.MangaZip
import org.koitharu.kotatsu.ui.common.BaseService
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.retryUntilSuccess
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import kotlin.math.absoluteValue
class DownloadService : BaseService() {
private lateinit var notification: DownloadNotification
private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>()
override fun onCreate() {
super.onCreate()
notification = DownloadNotification(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val manga = intent?.getParcelableExtra<Manga>(EXTRA_MANGA)
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet()
if (manga != null) {
downloadManga(manga, chapters)
} else {
stopSelf(startId)
}
return START_NOT_STICKY
}
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?) {
val destination = getExternalFilesDir("manga")!!
notification.fillFrom(manga)
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
launch(Dispatchers.IO) {
var output: MangaZip? = null
try {
val repo = MangaProviderFactory.create(manga.source)
val cover = safe {
Coil.loader().get(manga.coverUrl)
}
withContext(Dispatchers.Main) {
notification.setLargeIcon(cover)
notification.update()
}
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data)
output.prepare(data)
downloadPage(data.largeCoverUrl ?: data.coverUrl, destination).let { file ->
output.addCover(file)
}
val chapters = if (chaptersIds == null) {
data.chapters.orEmpty()
} else {
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersIds == null || chapter.id in chaptersIds) {
val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) {
val url = repo.getPageFullUrl(page)
val file = cache[url] ?: downloadPage(url, destination)
output.addPage(page, chapter, file, pageIndex)
withContext(Dispatchers.Main) {
notification.setProgress(
chapters.size,
pages.size,
chapterIndex,
pageIndex
)
notification.update()
}
}
}
}
withContext(Dispatchers.Main) {
notification.setPostProcessing()
notification.update()
}
output.compress()
withContext(Dispatchers.Main) {
notification.setDone()
notification.update(manga.id.toInt().absoluteValue)
}
} finally {
withContext(NonCancellable) {
output?.cleanup()
destination.sub("page.tmp").delete()
withContext(Dispatchers.Main) {
stopForeground(true)
}
}
}
}
}
private suspend fun downloadPage(url: String, destination: File): File {
val request = Request.Builder()
.url(url)
.get()
.build()
return retryUntilSuccess(3) {
okHttp.newCall(request).await().use { response ->
val file = destination.sub("page.tmp")
file.outputStream().use { out ->
response.body!!.byteStream().copyTo(out)
}
file
}
}
}
companion object {
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, manga)
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(context, intent)
}
}
}

View File

@@ -21,7 +21,7 @@ class MangaListDetailsHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHis
override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose()
textView_title.text = data.title
textView_subtitle.textAndVisible = data.localizedTitle
textView_subtitle.textAndVisible = data.altTitle
coverRequest = imageView_cover.load(data.coverUrl) {
crossfade(true)
}

View File

@@ -17,7 +17,7 @@ class MangaListHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHistory?>(
override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose()
textView_title.text = data.title
textView_subtitle.textAndVisible = data.localizedTitle
textView_subtitle.textAndVisible = data.altTitle
coverRequest = imageView_cover.load(data.coverUrl) {
crossfade(true)
}

View File

@@ -6,8 +6,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File
import kotlin.coroutines.CoroutineContext
@@ -16,13 +16,7 @@ class PageLoader(context: Context) : KoinComponent, CoroutineScope, DisposableHa
private val job = SupervisorJob()
private val tasks = HashMap<String, Job>()
private val okHttp by inject<OkHttpClient>()
private val cacheDir = File(context.externalCacheDir ?: context.cacheDir, "pages")
init {
if (!cacheDir.exists()) {
cacheDir.mkdir()
}
}
private val cache by inject<PagesCache>()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
@@ -35,19 +29,20 @@ class PageLoader(context: Context) : KoinComponent, CoroutineScope, DisposableHa
}
private suspend fun loadFile(url: String, force: Boolean): File {
val file = File(cacheDir, url.longHashCode().toString())
if (!force && file.exists()) {
return file
if (!force) {
cache[url]?.let {
return it
}
}
val request = Request.Builder()
.url(url)
.get()
.build()
okHttp.newCall(request).await().use { response ->
file.outputStream().use { out ->
return okHttp.newCall(request).await().use { response ->
cache.put(url) { out ->
response.body!!.byteStream().copyTo(out)
}
return file
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.utils
object FileSizeUtils {
@JvmStatic
fun mbToBytes(mb: Int) = 1024L * 1024L * mb
@JvmStatic
fun kbToBytes(kb: Int) = 1024L * kb
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.utils.ext
import android.content.res.Resources
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import java.io.IOException
@@ -14,7 +15,23 @@ inline fun <T, R> T.safe(action: T.() -> R?) = try {
null
}
fun Throwable.getDisplayMessage(resources: Resources) = when(this) {
suspend inline fun <T, R> T.retryUntilSuccess(maxAttempts: Int, action: T.() -> R): R {
var attempts = maxAttempts
while (true) {
try {
return this.action()
} catch (e: Exception) {
attempts--
if (attempts <= 0) {
throw e
} else {
delay(1000)
}
}
}
}
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is IOException -> resources.getString(R.string.network_error)
else -> if (BuildConfig.DEBUG) {
message ?: resources.getString(R.string.error_occurred)

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.utils.ext
import java.io.File
fun File.sub(name: String) = File(this, name)
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }

View File

@@ -32,4 +32,29 @@ fun String.removeSurrounding(vararg chars: Char): String {
}
}
return this
}
}
fun String.transliterate(skipMissing: Boolean): String {
val cyr = charArrayOf(
'a', 'б', 'в', 'г', 'д', 'ё', 'ж', 'з', 'и', 'к', 'л', 'м', 'н',
'п', 'р', 'с', 'т', 'у', 'ў', 'ф', 'х', 'ц', 'ш', 'щ', 'ы', 'э', 'ю', 'я'
)
val lat = arrayOf(
"a", "b", "v", "g", "d", "jo", "zh", "z", "i", "k", "l", "m", "n",
"p", "r", "s", "t", "u", "w", "f", "h", "ts", "sh", "sch", "", "e", "ju", "ja"
)
return buildString(length + 5) {
for (c in this@transliterate) {
val p = cyr.binarySearch(c)
if (p in lat.indices) {
append(lat[p])
} else if (!skipMissing) {
append(c)
}
}
}
}
fun String.toFileName() = this.transliterate(false)
.replace(Regex("[^a-z0-9_\\-]", setOf(RegexOption.IGNORE_CASE)), " ")
.replace(Regex("\\s+"), "_")

View File

@@ -3,12 +3,15 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isGone
import androidx.core.view.postDelayed
import androidx.recyclerview.widget.GridLayoutManager
@@ -96,4 +99,11 @@ fun View.disableFor(timeInMillis: Long) {
postDelayed(timeInMillis) {
isEnabled = true
}
}
fun View.showPopupMenu(@MenuRes menuRes: Int, onItemClick: (MenuItem) -> Boolean) {
val menu = PopupMenu(context, this)
menu.inflate(menuRes)
menu.setOnMenuItemClickListener(onItemClick)
menu.show()
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_save_this"
android:title="@string/save_this_chapter" />
<item
android:id="@+id/action_save_this_next"
android:title="@string/save_this_chapter_and_next" />
<item
android:id="@+id/action_save_this_prev"
android:title="@string/save_this_chapter_and_prev" />
</menu>

View File

@@ -37,4 +37,12 @@
<string name="search">Search</string>
<string name="search_manga">Search manga</string>
<string name="search_results">Search results</string>
<string name="manga_downloading_">Manga downloading…</string>
<string name="preparing_">Preparing…</string>
<string name="processing_">Processing…</string>
<string name="download_complete">Download complete</string>
<string name="downloads">Downloads</string>
<string name="save_this_chapter_and_prev">Save this chapter and prev.</string>
<string name="save_this_chapter_and_next">Save this chapter and next</string>
<string name="save_this_chapter">Save this chapter</string>
</resources>

View File

@@ -6,6 +6,7 @@
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
<item name="windowActionModeOverlay">true</item>
</style>
</resources>