Skip to content

Commit 1133e76

Browse files
committed
Allow for Export and Import of Custom Covers
1 parent 7b70b40 commit 1133e76

File tree

6 files changed

+241
-2
lines changed

6 files changed

+241
-2
lines changed

app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ import eu.kanade.presentation.util.relativeTimeSpanString
5252
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
5353
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
5454
import eu.kanade.tachiyomi.data.cache.ChapterCache
55+
import eu.kanade.tachiyomi.data.cache.CoverCache
56+
import eu.kanade.tachiyomi.data.export.CustomCoverExporter
57+
import eu.kanade.tachiyomi.data.export.CustomCoverRestorer
5558
import eu.kanade.tachiyomi.data.export.LibraryExporter
5659
import eu.kanade.tachiyomi.data.export.LibraryExporter.ExportOptions
5760
import eu.kanade.tachiyomi.util.system.DeviceUtil
@@ -110,6 +113,7 @@ object SettingsDataScreen : SearchableSettings {
110113
getBackupAndRestoreGroup(backupPreferences = backupPreferences),
111114
getDataGroup(),
112115
getExportGroup(),
116+
getImportGroup(),
113117
)
114118
}
115119

@@ -349,7 +353,7 @@ object SettingsDataScreen : SearchableSettings {
349353
favorites = getFavorites.await()
350354
}
351355

352-
val saveFileLauncher = rememberLauncherForActivityResult(
356+
val saveCsvFileLauncher = rememberLauncherForActivityResult(
353357
contract = ActivityResultContracts.CreateDocument("text/csv"),
354358
) { uri ->
355359
uri?.let {
@@ -374,19 +378,75 @@ object SettingsDataScreen : SearchableSettings {
374378
options = exportOptions,
375379
onConfirm = { options ->
376380
exportOptions = options
377-
saveFileLauncher.launch("mihon_library.csv")
381+
saveCsvFileLauncher.launch("mihon_library.csv")
378382
},
379383
onDismissRequest = { showDialog = false },
380384
)
381385
}
382386

387+
val saveCoverFileLauncher = rememberLauncherForActivityResult(
388+
contract = ActivityResultContracts.CreateDocument("application/gzip"),
389+
) { uri ->
390+
uri?.let {
391+
scope.launch {
392+
CustomCoverExporter.exportToZip(
393+
context = context,
394+
uri = it,
395+
onExportComplete = {
396+
scope.launch(Dispatchers.Main) {
397+
context.toast("Covers Exported")
398+
}
399+
},
400+
)
401+
}
402+
}
403+
}
404+
383405
return Preference.PreferenceGroup(
384406
title = stringResource(MR.strings.export),
385407
preferenceItems = persistentListOf(
386408
Preference.PreferenceItem.TextPreference(
387409
title = stringResource(MR.strings.library_list),
388410
onClick = { showDialog = true },
389411
),
412+
Preference.PreferenceItem.TextPreference(
413+
title = stringResource(MR.strings.custom_cover),
414+
onClick = { saveCoverFileLauncher.launch("custom_covers.gz") },
415+
),
416+
),
417+
)
418+
}
419+
420+
@Composable
421+
private fun getImportGroup(): Preference.PreferenceGroup {
422+
val context = LocalContext.current
423+
val scope = rememberCoroutineScope()
424+
425+
val importCoverFileLauncher = rememberLauncherForActivityResult(
426+
contract = ActivityResultContracts.GetContent(),
427+
) { uri ->
428+
uri?.let {
429+
scope.launch {
430+
CustomCoverRestorer.restoreFromZip(
431+
context = context,
432+
uri = it,
433+
onRestoreComplete = {
434+
scope.launch(Dispatchers.Main) {
435+
context.toast("Covers Imported")
436+
}
437+
},
438+
)
439+
}
440+
}
441+
}
442+
443+
return Preference.PreferenceGroup(
444+
title = stringResource(MR.strings.import_title),
445+
preferenceItems = persistentListOf(
446+
Preference.PreferenceItem.TextPreference(
447+
title = stringResource(MR.strings.custom_cover),
448+
onClick = { importCoverFileLauncher.launch("application/gzip") },
449+
),
390450
),
391451
)
392452
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package eu.kanade.tachiyomi.data.backup.models
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.protobuf.ProtoNumber
5+
6+
@Serializable
7+
data class BackupCovers(
8+
@ProtoNumber(1) val mappings: List<BackupCover> = emptyList(),
9+
)
10+
11+
@Serializable
12+
data class BackupCover(
13+
@ProtoNumber(1) val mangaId: Long,
14+
@ProtoNumber(2) val url: String
15+
)

app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,13 @@ class CoverCache(private val context: Context) {
102102
return context.getExternalFilesDir(dir)
103103
?: File(context.filesDir, dir).also { it.mkdirs() }
104104
}
105+
106+
/**
107+
* Get all custom cover files.
108+
*
109+
* @return list of custom cover image files.
110+
*/
111+
fun getAllCustomCovers(): List<File> {
112+
return customCoverCacheDir.listFiles()?.toList() ?: emptyList()
113+
}
105114
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package eu.kanade.tachiyomi.data.export
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import eu.kanade.tachiyomi.data.cache.CoverCache
6+
import eu.kanade.tachiyomi.util.storage.DiskUtil
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.withContext
9+
import kotlinx.serialization.Serializable
10+
import kotlinx.serialization.encodeToByteArray
11+
import kotlinx.serialization.protobuf.ProtoNumber
12+
import tachiyomi.domain.manga.interactor.GetFavorites
13+
import uy.kohesive.injekt.Injekt
14+
import uy.kohesive.injekt.api.get
15+
import java.io.FileInputStream
16+
import java.io.FileOutputStream
17+
import java.util.zip.ZipEntry
18+
import java.util.zip.ZipOutputStream
19+
import kotlinx.serialization.protobuf.ProtoBuf
20+
import java.io.File
21+
import android.util.Log
22+
import eu.kanade.tachiyomi.data.backup.models.BackupCover
23+
import eu.kanade.tachiyomi.data.backup.models.BackupCovers
24+
25+
object CustomCoverExporter {
26+
27+
suspend fun exportToZip(
28+
context: Context,
29+
uri: Uri,
30+
onExportComplete: () -> Unit
31+
) {
32+
withContext(Dispatchers.IO) {
33+
try {
34+
val customCovers = Injekt.get<CoverCache>().getAllCustomCovers()
35+
val mangas = Injekt.get<GetFavorites>().await()
36+
37+
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
38+
ZipOutputStream(outputStream).use { zipOutputStream ->
39+
val mangaUrlMappings = mangas.mapNotNull { manga ->
40+
val expectedFileName = DiskUtil.hashKeyForDisk(manga.id.toString())
41+
val file = customCovers.find { it.name == expectedFileName }
42+
43+
if (file?.exists() == true) {
44+
BackupCover(manga.id, manga.url)
45+
} else {
46+
null
47+
}
48+
}
49+
50+
val protoByteArray = ProtoBuf.encodeToByteArray(BackupCovers(mangaUrlMappings))
51+
val protoFile = File(context.cacheDir, "manga_urls.proto")
52+
protoFile.writeBytes(protoByteArray)
53+
54+
val protoZipEntry = ZipEntry("manga_urls.proto")
55+
zipOutputStream.putNextEntry(protoZipEntry)
56+
protoFile.inputStream().use { protoInput ->
57+
protoInput.copyTo(zipOutputStream)
58+
}
59+
zipOutputStream.closeEntry()
60+
61+
mangas.forEach { manga ->
62+
val expectedFileName = DiskUtil.hashKeyForDisk(manga.id.toString())
63+
val file = customCovers.find { it.name == expectedFileName }
64+
65+
if (file?.exists() == true) {
66+
FileInputStream(file).use { input ->
67+
val zipEntry = ZipEntry("${manga.id}.jpg")
68+
zipOutputStream.putNextEntry(zipEntry)
69+
input.copyTo(zipOutputStream)
70+
zipOutputStream.closeEntry()
71+
}
72+
}
73+
}
74+
75+
protoFile.delete()
76+
}
77+
}
78+
79+
onExportComplete()
80+
} catch (e: Exception) {
81+
e.printStackTrace()
82+
}
83+
}
84+
}
85+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package eu.kanade.tachiyomi.data.export
2+
3+
import tachiyomi.domain.manga.interactor.GetFavorites
4+
import uy.kohesive.injekt.Injekt
5+
import uy.kohesive.injekt.api.get
6+
import android.content.Context
7+
import android.net.Uri
8+
import eu.kanade.tachiyomi.data.backup.models.BackupCovers
9+
import eu.kanade.tachiyomi.data.cache.CoverCache
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.withContext
12+
import kotlinx.serialization.protobuf.ProtoBuf
13+
import java.util.zip.ZipInputStream
14+
import kotlinx.serialization.decodeFromByteArray
15+
16+
object CustomCoverRestorer {
17+
18+
suspend fun restoreFromZip(
19+
context: Context,
20+
uri: Uri,
21+
onRestoreComplete: () -> Unit
22+
) {
23+
withContext(Dispatchers.IO) {
24+
try {
25+
context.contentResolver.openInputStream(uri)?.use { inputStream ->
26+
ZipInputStream(inputStream).use { zipInputStream ->
27+
var entry = zipInputStream.nextEntry
28+
var protoData: ByteArray? = null
29+
val imageMap = mutableMapOf<String, ByteArray>()
30+
31+
while (entry != null) {
32+
if (entry.name == "manga_urls.proto") {
33+
protoData = zipInputStream.readBytes()
34+
} else if (entry.name.endsWith(".jpg")) {
35+
val imageData = zipInputStream.readBytes()
36+
imageMap[entry.name] = imageData
37+
}
38+
zipInputStream.closeEntry()
39+
entry = zipInputStream.nextEntry
40+
}
41+
42+
if (protoData != null) {
43+
val backupCoverMappings = ProtoBuf.decodeFromByteArray<BackupCovers>(protoData)
44+
val mangas = Injekt.get<GetFavorites>().await()
45+
46+
backupCoverMappings.mappings.forEach { mapping ->
47+
val matchingManga = mangas.find { it.url == mapping.url }
48+
49+
if (matchingManga != null) {
50+
val imageName = "${mapping.mangaId}.jpg"
51+
val coverData = imageMap[imageName]
52+
53+
if (coverData != null) {
54+
val coverInputStream = coverData.inputStream()
55+
Injekt.get<CoverCache>().setCustomCoverToCache(matchingManga, coverInputStream)
56+
}
57+
}
58+
}
59+
}
60+
}
61+
}
62+
63+
onRestoreComplete()
64+
} catch (e: Exception) {
65+
e.printStackTrace()
66+
}
67+
}
68+
}
69+
}

i18n/src/commonMain/moko-resources/base/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@
573573
<string name="cache_delete_error">Error occurred while clearing</string>
574574
<string name="pref_auto_clear_chapter_cache">Clear chapter cache on app launch</string>
575575
<string name="export">Export</string>
576+
<string name="import_title">Import</string>
576577
<string name="library_list">Library List</string>
577578
<string name="library_exported">Library Exported</string>
578579

0 commit comments

Comments
 (0)