Option to import manga from directories #31
parent
59a50e163f
commit
0077dc2f1c
@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.os.FileObserver
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.channels.ProducerScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class FlowFileObserver(
|
||||
private val producerScope: ProducerScope<File>,
|
||||
private val file: File,
|
||||
) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) {
|
||||
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
producerScope.trySendBlocking(
|
||||
if (path == null) file else file.resolve(path),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun File.observe() = callbackFlow {
|
||||
val observer = FlowFileObserver(this, this@observe)
|
||||
observer.startWatching()
|
||||
awaitClose { observer.stopWatching() }
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
package org.koitharu.kotatsu.local.domain.importer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.longOf
|
||||
|
||||
// TODO: Add support for chapters in cbz
|
||||
// https://github.com/KotatsuApp/Kotatsu/issues/31
|
||||
class DirMangaImporter(
|
||||
private val context: Context,
|
||||
storageManager: LocalStorageManager,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
) : MangaImporter(storageManager) {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
override suspend fun import(uri: Uri): Manga {
|
||||
val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) {
|
||||
"Provided uri $uri is not a tree"
|
||||
}
|
||||
val manga = Manga(root)
|
||||
val output = CbzMangaOutput.get(getOutputDir(), manga)
|
||||
try {
|
||||
val dest = output.use {
|
||||
addPages(
|
||||
output = it,
|
||||
root = root,
|
||||
path = "",
|
||||
state = State(uri.hashCode(), 0, false),
|
||||
)
|
||||
it.sortChaptersByName()
|
||||
it.mergeWithExisting()
|
||||
it.finalize()
|
||||
it.file
|
||||
}
|
||||
return localMangaRepository.getFromFile(dest)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output.cleanup()
|
||||
File(getOutputDir(), "page.tmp").deleteAwait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) {
|
||||
var number = 0
|
||||
for (file in root.listFiles()) {
|
||||
when {
|
||||
file.isDirectory -> {
|
||||
addPages(output, file, path + "/" + file.name, state)
|
||||
}
|
||||
file.isFile -> {
|
||||
val tempFile = file.asTempFile()
|
||||
if (!state.hasCover) {
|
||||
output.addCover(tempFile, file.extension)
|
||||
state.hasCover = true
|
||||
}
|
||||
output.addPage(
|
||||
chapter = state.getChapter(path),
|
||||
file = tempFile,
|
||||
pageNumber = number,
|
||||
ext = file.extension,
|
||||
)
|
||||
number++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun DocumentFile.asTempFile(): File {
|
||||
val file = File(getOutputDir(), "page.tmp")
|
||||
checkNotNull(contentResolver.openInputStream(uri)) {
|
||||
"Cannot open input stream for $uri"
|
||||
}.use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun Manga(file: DocumentFile) = Manga(
|
||||
id = longOf(file.uri.hashCode(), 0),
|
||||
title = checkNotNull(file.name),
|
||||
altTitle = null,
|
||||
url = file.uri.path.orEmpty(),
|
||||
publicUrl = file.uri.toString(),
|
||||
rating = RATING_UNKNOWN,
|
||||
isNsfw = false,
|
||||
coverUrl = "",
|
||||
tags = emptySet(),
|
||||
state = null,
|
||||
author = null,
|
||||
source = MangaSource.LOCAL,
|
||||
)
|
||||
|
||||
private val DocumentFile.extension: String
|
||||
get() = type?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
|
||||
?: name?.substringAfterLast('.')?.takeIf { it.length in 2..4 }
|
||||
?: error("Cannot obtain extension of $uri")
|
||||
|
||||
private class State(
|
||||
private val rootId: Int,
|
||||
private var counter: Int,
|
||||
var hasCover: Boolean,
|
||||
) {
|
||||
|
||||
private val chapters = HashMap<String, MangaChapter>()
|
||||
|
||||
@Synchronized
|
||||
fun getChapter(path: String): MangaChapter {
|
||||
return chapters.getOrPut(path) {
|
||||
counter++
|
||||
MangaChapter(
|
||||
id = longOf(rootId, counter),
|
||||
name = path.replace('/', ' ').trim(),
|
||||
number = counter,
|
||||
url = path.ifEmpty { "Default chapter" },
|
||||
scanlator = null,
|
||||
uploadDate = 0L,
|
||||
branch = null,
|
||||
source = MangaSource.LOCAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.local.domain.importer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
abstract class MangaImporter(
|
||||
protected val storageManager: LocalStorageManager,
|
||||
) {
|
||||
|
||||
abstract suspend fun import(uri: Uri): Manga
|
||||
|
||||
suspend fun getOutputDir(): File {
|
||||
return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
|
||||
}
|
||||
|
||||
class Factory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
) {
|
||||
|
||||
fun create(uri: Uri): MangaImporter {
|
||||
return when {
|
||||
isDir(uri) -> DirMangaImporter(context, storageManager, localMangaRepository)
|
||||
else -> ZipMangaImporter(storageManager, localMangaRepository)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDir(uri: Uri): Boolean {
|
||||
return runCatching {
|
||||
DocumentFile.fromTreeUri(context, uri)
|
||||
}.isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package org.koitharu.kotatsu.local.domain.importer
|
||||
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||
|
||||
class ZipMangaImporter(
|
||||
storageManager: LocalStorageManager,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
) : MangaImporter(storageManager) {
|
||||
|
||||
override suspend fun import(uri: Uri): Manga {
|
||||
val contentResolver = storageManager.contentResolver
|
||||
return withContext(Dispatchers.IO) {
|
||||
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!CbzFilter.isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = File(getOutputDir(), name)
|
||||
runInterruptible {
|
||||
contentResolver.openInputStream(uri)?.use { source ->
|
||||
dest.outputStream().use { output ->
|
||||
source.copyTo(output)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
localMangaRepository.getFromFile(dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.databinding.DialogImportBinding
|
||||
|
||||
class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener {
|
||||
|
||||
private val viewModel by activityViewModels<LocalListViewModel>()
|
||||
private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
|
||||
startImport(it)
|
||||
}
|
||||
private val importDirCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||
startImport(listOfNotNull(it))
|
||||
}
|
||||
|
||||
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogImportBinding {
|
||||
return DialogImportBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
|
||||
builder.setTitle(R.string._import)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setCancelable(true)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonDir.setOnClickListener(this)
|
||||
binding.buttonFile.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_file -> importFileCall.launch(arrayOf("*/*"))
|
||||
R.id.button_dir -> importDirCall.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startImport(uris: Collection<Uri>) {
|
||||
ImportService.start(requireContext(), uris)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "ImportDialogFragment"
|
||||
|
||||
fun show(fm: FragmentManager) = ImportDialogFragment().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.local.domain.importer.MangaImporter
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ImportService : CoroutineIntentService() {
|
||||
|
||||
@Inject
|
||||
lateinit var importerFactory: MangaImporter.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override suspend fun processIntent(intent: Intent?) {
|
||||
val uris = intent?.getParcelableArrayListExtra<Uri>(EXTRA_URIS)
|
||||
if (uris.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
startForeground()
|
||||
for (uri in uris) {
|
||||
try {
|
||||
val manga = importImpl(uri)
|
||||
showNotification(uri, manga, null)
|
||||
sendBroadcast(manga)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
showNotification(uri, null, e)
|
||||
}
|
||||
}
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
private suspend fun importImpl(uri: Uri): Manga {
|
||||
val importer = importerFactory.create(uri)
|
||||
return importer.import(uri)
|
||||
}
|
||||
|
||||
private fun sendBroadcast(manga: Manga) {
|
||||
sendBroadcast(
|
||||
Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE)
|
||||
.putExtra(DownloadService.EXTRA_MANGA, ParcelableManga(manga, withChapters = false)),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun showNotification(uri: Uri, manga: Manga?, error: Throwable?) {
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
|
||||
.setSilent(true)
|
||||
if (manga != null) {
|
||||
notification.setLargeIcon(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext).data(manga.coverUrl).referer(manga.publicUrl).build(),
|
||||
).toBitmapOrNull(),
|
||||
)
|
||||
notification.setSubText(manga.title)
|
||||
val intent = DetailsActivity.newIntent(applicationContext, manga)
|
||||
notification.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
manga.id.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
),
|
||||
).setAutoCancel(true)
|
||||
.setVisibility(
|
||||
if (manga.isNsfw) {
|
||||
NotificationCompat.VISIBILITY_SECRET
|
||||
} else NotificationCompat.VISIBILITY_PUBLIC,
|
||||
)
|
||||
}
|
||||
if (error != null) {
|
||||
notification.setContentTitle(getString(R.string.error_occurred))
|
||||
.setContentText(error.getDisplayMessage(resources))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
} else {
|
||||
notification.setContentTitle(getString(R.string.import_completed))
|
||||
.setContentText(getString(R.string.import_completed_hint))
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
NotificationCompat.BigTextStyle(notification)
|
||||
.bigText(getString(R.string.import_completed_hint))
|
||||
}
|
||||
|
||||
notificationManager.notify(uri.hashCode(), notification.build())
|
||||
}
|
||||
|
||||
private fun startForeground() {
|
||||
val title = getString(R.string.importing_manga)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
|
||||
channel.setShowBadge(false)
|
||||
channel.enableVibration(false)
|
||||
channel.setSound(null, null)
|
||||
channel.enableLights(false)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setDefaults(0)
|
||||
.setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
private const val CHANNEL_ID = "importing"
|
||||
private const val NOTIFICATION_ID = 22
|
||||
|
||||
private const val EXTRA_URIS = "uris"
|
||||
|
||||
fun start(context: Context, uris: Collection<Uri>) {
|
||||
if (uris.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val intent = Intent(context, ImportService::class.java)
|
||||
intent.putParcelableArrayListExtra(EXTRA_URIS, uris.asArrayList())
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Toast.makeText(context, R.string.import_will_start_soon, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
|
||||
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
|
||||
|
||||
fun longOf(a: Int, b: Int): Long {
|
||||
return a.toLong() shl 32 or (b.toLong() and 0xffffffffL)
|
||||
}
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="0.92"
|
||||
android:scaleY="0.92"
|
||||
android:translateX="0.96"
|
||||
android:translateY="0.96">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M21,5L9,17L3.5,11.5L4.91,10.09L9,14.17L19.59,3.59L21,5M3,21V19H21V21H3Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 240 B |
Binary file not shown.
|
After Width: | Height: | Size: 208 B |
Binary file not shown.
|
After Width: | Height: | Size: 285 B |
Binary file not shown.
|
After Width: | Height: | Size: 487 B |
@ -0,0 +1,14 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M14,2H6A2,2 0,0 0,4 4v16a2,2 0,0 0,2 2h12a2,2 0,0 0,2 -2V8L14,2m4,18H6V4h7v5h5z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m16,16v-2h-2v2h2m-4,-2h2V12h-2v2m4,6v-2h-2v2h2m-4,-2h2V16h-2v2" />
|
||||
</vector>
|
||||
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M4 18H11V20H4C2.9 20 2 19.11 2 18V6C2 4.89 2.89 4 4 4H10L12 6H20C21.1 6 22 6.89 22 8V10.17L20.41 8.59L20 8.17V8H4V18M23 14V21C23 22.11 22.11 23 21 23H15C13.9 23 13 22.11 13 21V12C13 10.9 13.9 10 15 10H19L23 14M21 15H18V12H15V21H21V15Z" />
|
||||
</vector>
|
||||
@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="@dimen/grid_spacing_outer"
|
||||
android:paddingTop="@dimen/margin_normal">
|
||||
|
||||
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
|
||||
android:id="@+id/button_file"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:drawableStart="@drawable/ic_file_zip"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:text="@string/comics_archive"
|
||||
android:textAppearance="?attr/textAppearanceButton" />
|
||||
|
||||
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
|
||||
android:id="@+id/button_dir"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:drawableStart="@drawable/ic_folder_file"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:text="@string/folder_with_images"
|
||||
android:textAppearance="?attr/textAppearanceButton" />
|
||||
|
||||
</LinearLayout>
|
||||
Loading…
Reference in New Issue