mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-10 12:47:26 +01:00
Local manga in zip/cbz/folder format (#648)
* add local source * small fixes * change Chapter to SChapter and and Manga to SManga in ChapterRecognition. Use ChapterRecognition.parseChapterNumber() to recognize chapter numbers. * use thread poll * update isImage() * add isImage() function to DiskUtil * improve cover handling * Support external SD cards * use R.string.app_name as root folder name
This commit is contained in:
parent
e25ce768bb
commit
2b73a9d2a4
@ -179,6 +179,9 @@ dependencies {
|
||||
// Crash reports
|
||||
compile 'ch.acra:acra:4.9.2'
|
||||
|
||||
// Sort
|
||||
compile 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
||||
|
||||
// UI
|
||||
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||
|
@ -76,6 +76,11 @@
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
|
||||
android:authorities="${applicationId}.zip-provider"
|
||||
android:exported="false"></provider>
|
||||
|
||||
<receiver
|
||||
android:name=".data.notification.NotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
@ -1,8 +1,10 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.LruCache
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.*
|
||||
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
||||
@ -43,6 +45,12 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
||||
InputStream::class.java, context)
|
||||
|
||||
/**
|
||||
* Base file loader.
|
||||
*/
|
||||
private val baseFileLoader = Glide.buildModelLoader(Uri::class.java,
|
||||
InputStream::class.java, context)
|
||||
|
||||
/**
|
||||
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
|
||||
* and the file where it should be stored in case the manga is a favorite.
|
||||
@ -82,6 +90,18 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url!!.startsWith("file://")) {
|
||||
val cover = File(url.substring(7))
|
||||
val id = url + File.separator + cover.lastModified()
|
||||
val rf = baseFileLoader.getResourceFetcher(Uri.fromFile(cover), width, height)
|
||||
return object : DataFetcher<InputStream> {
|
||||
override fun cleanup() = rf.cleanup()
|
||||
override fun loadData(priority: Priority?): InputStream = rf.loadData(priority)
|
||||
override fun cancel() = rf.cancel()
|
||||
override fun getId() = id
|
||||
}
|
||||
}
|
||||
|
||||
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
||||
// and add them to the cache.
|
||||
val (glideUrl, file) = lruCache.get(url) ?:
|
||||
|
178
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt
Normal file
178
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt
Normal file
@ -0,0 +1,178 @@
|
||||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||
import eu.kanade.tachiyomi.util.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.ZipContentProvider
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class LocalSource(private val context: Context) : CatalogueSource {
|
||||
companion object {
|
||||
private val FILE_PROTOCOL = "file://"
|
||||
private val COVER_NAME = "cover.jpg"
|
||||
private val POPULAR_FILTERS = FilterList(OrderBy())
|
||||
private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
|
||||
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
|
||||
val ID = 0L
|
||||
|
||||
fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
|
||||
val dir = getBaseDirectories(context).firstOrNull()
|
||||
if (dir == null) {
|
||||
input.close()
|
||||
return null
|
||||
}
|
||||
val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
|
||||
|
||||
// It might not exist if using the external SD card
|
||||
cover.parentFile.mkdirs()
|
||||
input.use {
|
||||
cover.outputStream().use {
|
||||
input.copyTo(it)
|
||||
}
|
||||
}
|
||||
return cover
|
||||
}
|
||||
|
||||
private fun getBaseDirectories(context: Context): List<File> {
|
||||
val c = File.separator + context.getString(R.string.app_name) + File.separator + "local"
|
||||
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath + c) }
|
||||
}
|
||||
}
|
||||
|
||||
override val id = ID
|
||||
override val name = "LocalSource"
|
||||
override val lang = "en"
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun toString() = context.getString(R.string.local_source)
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga) = Observable.just(manga)
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
val chapters = getBaseDirectories(context)
|
||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory || isSupportedFormat(it.extension) }
|
||||
.map { chapterFile ->
|
||||
SChapter.create().apply {
|
||||
url = chapterFile.absolutePath
|
||||
val chapName = if (chapterFile.isDirectory) {
|
||||
chapterFile.name
|
||||
} else {
|
||||
chapterFile.nameWithoutExtension
|
||||
}
|
||||
val chapNameCut = chapName.replace(manga.title, "", true)
|
||||
name = if (chapNameCut.isEmpty()) chapName else chapNameCut
|
||||
date_upload = chapterFile.lastModified()
|
||||
ChapterRecognition.parseChapterNumber(this, manga)
|
||||
}
|
||||
}
|
||||
|
||||
return Observable.just(chapters.sortedByDescending { it.chapter_number })
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
val chapFile = File(chapter.url)
|
||||
if (chapFile.isDirectory) {
|
||||
return Observable.just(chapFile.listFiles()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||
.sortedWith(Comparator<File> { t1, t2 -> CaseInsensitiveSimpleNaturalComparator.getInstance<String>().compare(t1.name, t2.name) })
|
||||
.mapIndexed { i, v -> Page(i, FILE_PROTOCOL + v.absolutePath, FILE_PROTOCOL + v.absolutePath, Uri.fromFile(v)).apply { status = Page.READY } })
|
||||
} else {
|
||||
val zip = ZipFile(chapFile)
|
||||
return Observable.just(ZipFile(chapFile).entries().toList()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||
.sortedWith(Comparator<ZipEntry> { t1, t2 -> CaseInsensitiveSimpleNaturalComparator.getInstance<String>().compare(t1.name, t2.name) })
|
||||
.mapIndexed { i, v ->
|
||||
val path = "content://${ZipContentProvider.PROVIDER}${chapFile.absolutePath}!/${v.name}"
|
||||
Page(i, path, path, Uri.parse(path)).apply { status = Page.READY }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
|
||||
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
|
||||
var mangaDirs = baseDirs.mapNotNull { it.listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory && if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
|
||||
.distinctBy { it.name }
|
||||
|
||||
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
|
||||
when (state?.index) {
|
||||
0 -> {
|
||||
if (state!!.ascending)
|
||||
mangaDirs = mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
|
||||
else
|
||||
mangaDirs = mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
|
||||
}
|
||||
1 -> {
|
||||
if (state!!.ascending)
|
||||
mangaDirs = mangaDirs.sortedBy(File::lastModified)
|
||||
else
|
||||
mangaDirs = mangaDirs.sortedByDescending(File::lastModified)
|
||||
}
|
||||
}
|
||||
|
||||
val mangas = mangaDirs.map { mangaDir ->
|
||||
SManga.create().apply {
|
||||
title = mangaDir.name
|
||||
url = mangaDir.name
|
||||
|
||||
// Try to find the cover
|
||||
for (dir in baseDirs) {
|
||||
val cover = File("${dir.absolutePath}/$url", COVER_NAME)
|
||||
if (cover.exists()) {
|
||||
thumbnail_url = FILE_PROTOCOL + cover.absolutePath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the cover from the first chapter found.
|
||||
if (thumbnail_url == null) {
|
||||
val chapters = fetchChapterList(this).toBlocking().first()
|
||||
if (chapters.isNotEmpty()) {
|
||||
val url = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.url
|
||||
if (url != null) {
|
||||
val input = context.contentResolver.openInputStream(Uri.parse(url))
|
||||
try {
|
||||
val dest = updateCover(context, this, input)
|
||||
thumbnail_url = dest?.let { FILE_PROTOCOL + it.absolutePath }
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
return Observable.just(MangasPage(mangas, false))
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
|
||||
|
||||
private fun isSupportedFormat(extension: String): Boolean {
|
||||
return extension.equals("zip", true) || extension.equals("cbz", true)
|
||||
}
|
||||
|
||||
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
||||
|
||||
override fun getFilterList() = FilterList(OrderBy())
|
||||
}
|
@ -48,6 +48,7 @@ open class SourceManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun createInternalSources(): List<Source> = listOf(
|
||||
LocalSource(context),
|
||||
Batoto(),
|
||||
Mangahere(),
|
||||
Mangafox(),
|
||||
|
@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.combineLatest
|
||||
@ -345,6 +346,11 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
|
||||
if (manga.source == LocalSource.ID) {
|
||||
LocalSource.updateCover(context, manga, inputStream)
|
||||
return true
|
||||
}
|
||||
|
||||
if (manga.thumbnail_url != null && manga.favorite) {
|
||||
coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
|
||||
return true
|
||||
|
@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackUpdateService
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
@ -539,6 +540,13 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
||||
*/
|
||||
internal fun setImageAsCover(page: Page) {
|
||||
try {
|
||||
if (manga.source == LocalSource.ID) {
|
||||
val input = context.contentResolver.openInputStream(page.uri)
|
||||
LocalSource.updateCover(context, manga, input)
|
||||
context.toast(R.string.cover_updated)
|
||||
return
|
||||
}
|
||||
|
||||
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
|
||||
if (manga.favorite) {
|
||||
val input = context.contentResolver.openInputStream(page.uri)
|
||||
|
@ -50,7 +50,7 @@ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter)
|
||||
// Set source + chapter title
|
||||
val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble())
|
||||
itemView.manga_source.text = itemView.context.getString(R.string.recent_manga_source)
|
||||
.format(adapter.sourceManager.get(manga.source)?.name, formattedNumber)
|
||||
.format(adapter.sourceManager.get(manga.source)?.toString(), formattedNumber)
|
||||
|
||||
// Set last read timestamp title
|
||||
itemView.last_read.text = df.format(Date(history.last_read))
|
||||
|
@ -1,7 +1,7 @@
|
||||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
|
||||
/**
|
||||
* -R> = regex conversion.
|
||||
@ -37,7 +37,7 @@ object ChapterRecognition {
|
||||
*/
|
||||
private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""")
|
||||
|
||||
fun parseChapterNumber(chapter: Chapter, manga: Manga) {
|
||||
fun parseChapterNumber(chapter: SChapter, manga: SManga) {
|
||||
// If chapter number is known return.
|
||||
if (chapter.chapter_number == -2f || chapter.chapter_number > -1f)
|
||||
return
|
||||
@ -91,7 +91,7 @@ object ChapterRecognition {
|
||||
* @param chapter chapter object
|
||||
* @return true if volume is found
|
||||
*/
|
||||
fun updateChapter(match: MatchResult?, chapter: Chapter): Boolean {
|
||||
fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean {
|
||||
match?.let {
|
||||
val initial = it.groups[1]?.value?.toFloat()!!
|
||||
val subChapterDecimal = it.groups[2]?.value
|
||||
|
@ -1,11 +1,53 @@
|
||||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.os.EnvironmentCompat
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.URLConnection
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
|
||||
object DiskUtil {
|
||||
|
||||
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
||||
val contentType = URLConnection.guessContentTypeFromName(name)
|
||||
if (contentType != null)
|
||||
return contentType.startsWith("image/")
|
||||
|
||||
if (openStream != null) try {
|
||||
openStream.invoke().buffered().use {
|
||||
var bytes = ByteArray(11)
|
||||
it.mark(bytes.size)
|
||||
var length = it.read(bytes, 0, bytes.size)
|
||||
it.reset()
|
||||
if (length == -1)
|
||||
return false
|
||||
if (bytes[0] == 'G'.toByte() && bytes[1] == 'I'.toByte() && bytes[2] == 'F'.toByte() && bytes[3] == '8'.toByte()) {
|
||||
return true // image/gif
|
||||
} else if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte()
|
||||
&& bytes[3] == 0x47.toByte() && bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte()
|
||||
&& bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte()) {
|
||||
return true // image/png
|
||||
} else if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) {
|
||||
if (bytes[3] == 0xE0.toByte() || bytes[3] == 0xE1.toByte() && bytes[6] == 'E'.toByte()
|
||||
&& bytes[7] == 'x'.toByte() && bytes[8] == 'i'.toByte()
|
||||
&& bytes[9] == 'f'.toByte() && bytes[10] == 0.toByte()) {
|
||||
return true // image/jpeg
|
||||
} else if (bytes[3] == 0xEE.toByte()) {
|
||||
return true // image/jpg
|
||||
}
|
||||
} else if (bytes[0] == 'W'.toByte() && bytes[1] == 'E'.toByte() && bytes[2] == 'B'.toByte() && bytes[3] == 'P'.toByte()) {
|
||||
return true // image/webp
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun hashKeyForDisk(key: String): String {
|
||||
return try {
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
@ -31,9 +73,26 @@ object DiskUtil {
|
||||
return size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the root folders of all the available external storages.
|
||||
*/
|
||||
fun getExternalStorages(context: Context): List<File> {
|
||||
return ContextCompat.getExternalFilesDirs(context, null)
|
||||
.filterNotNull()
|
||||
.mapNotNull {
|
||||
val file = File(it.absolutePath.substringBefore("/Android/"))
|
||||
val state = EnvironmentCompat.getStorageState(file)
|
||||
if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) {
|
||||
file
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the given filename to make it valid for a FAT filesystem,
|
||||
* replacing any invalid characters with "_". This method doesn't allow private files (starting
|
||||
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting
|
||||
* with a dot), but you can manually add it later.
|
||||
*/
|
||||
fun buildValidFilename(origName: String): String {
|
||||
|
@ -0,0 +1,71 @@
|
||||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
import java.net.URLConnection
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class ZipContentProvider : ContentProvider() {
|
||||
|
||||
private val pool by lazy { Executors.newCachedThreadPool() }
|
||||
|
||||
companion object {
|
||||
const val PROVIDER = "${BuildConfig.APPLICATION_ID}.zip-provider"
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? {
|
||||
return URLConnection.guessContentTypeFromName(uri.toString())
|
||||
}
|
||||
|
||||
override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
|
||||
try {
|
||||
val url = "jar:file://" + uri.toString().substringAfter("content://$PROVIDER")
|
||||
val input = URL(url).openStream()
|
||||
val pipe = ParcelFileDescriptor.createPipe()
|
||||
pool.execute {
|
||||
try {
|
||||
val output = ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])
|
||||
input.use {
|
||||
output.use {
|
||||
input.copyTo(output)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
return AssetFileDescriptor(pipe[0], 0, -1)
|
||||
} catch (e: IOException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?): Cursor? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun insert(p0: Uri?, p1: ContentValues?): Uri {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?): Int {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?): Int {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
@ -1,6 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Tachiyomi</string>
|
||||
|
||||
<string name="name">Име</string>
|
||||
|
||||
<!-- Activities and fragments labels (toolbar title) -->
|
||||
|
@ -1,6 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Tachiyomi</string>
|
||||
|
||||
<string name="name">Nombre</string>
|
||||
|
||||
<!-- Activities and fragments labels (toolbar title) -->
|
||||
|
@ -1,6 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Tachiyomi</string>
|
||||
|
||||
<string name="name">Nom</string>
|
||||
|
||||
<!-- Activities and fragments labels (toolbar title) -->
|
||||
|
@ -1,6 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Tachiyomi</string>
|
||||
|
||||
<string name="name">Nome</string>
|
||||
|
||||
<!-- Activities and fragments labels (toolbar title) -->
|
||||
|
@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Tachiyomi</string>
|
||||
|
||||
<string name="name">Nome</string>
|
||||
|
||||
<!-- Activities and fragments labels (toolbar title) -->
|
||||
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Tachiyomi</string>
|
||||
<string name="action_add">Добавить</string>
|
||||
<string name="action_add_category">Добавить категорию</string>
|
||||
<string name="action_add_to_home_screen">Добавить на домашний экран</string>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Tachiyomi</string>
|
||||
<string name="app_name" translatable="false">Tachiyomi</string>
|
||||
|
||||
<string name="name">Name</string>
|
||||
|
||||
@ -224,6 +224,7 @@
|
||||
<string name="select_source">Select a source</string>
|
||||
<string name="no_valid_sources">Please enable at least one valid source</string>
|
||||
<string name="no_more_results">No more results</string>
|
||||
<string name="local_source">Local manga</string>
|
||||
|
||||
<!-- Manga activity -->
|
||||
<string name="manga_not_in_db">This manga was removed from the database!</string>
|
||||
|
Loading…
Reference in New Issue
Block a user