From 21016dda53a1df1f2f17f03ac00a66f91533f77e Mon Sep 17 00:00:00 2001 From: Vladimir Dostoyevskiy <99822064+VDostoyevskiy@users.noreply.github.com> Date: Tue, 6 Feb 2024 14:44:11 +0800 Subject: [PATCH] Initial draft support export as cbz (#685) * Initial draft support export as cbz * Hint user progress * Hint user result * Emit comicInfo as well * Write ComicInfo if not exist * Use perFilename * Print stacktrace * Fix * Refine * Revert "Refine" This reverts commit 2b368180fde00c7a8cfd4e895f1f2d5c29c3cf9f. * Refine * Refine * Fix --------- Co-authored-by: GG Bond --- app/src/main/cpp/archive.c | 49 ++++++++++++--- .../kotlin/com/hippo/ehviewer/jni/Archive.kt | 1 + .../com/hippo/ehviewer/spider/SpiderDen.kt | 62 ++++++++++++++++--- .../com/hippo/ehviewer/spider/SpiderQueen.kt | 20 +----- .../ehviewer/ui/screen/GalleryDetailScreen.kt | 42 +++++++++++++ .../ehviewer/ui/settings/PreferenceWidgets.kt | 32 +--------- .../com/hippo/ehviewer/util/Progress.kt | 53 ++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 9 files changed, 201 insertions(+), 66 deletions(-) create mode 100644 app/src/main/kotlin/com/hippo/ehviewer/util/Progress.kt diff --git a/app/src/main/cpp/archive.c b/app/src/main/cpp/archive.c index 7508f030b..901139c9d 100644 --- a/app/src/main/cpp/archive.c +++ b/app/src/main/cpp/archive.c @@ -296,7 +296,7 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) { JNIEXPORT jint JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, - jlong size) { + jlong size) { EH_UNUSED(env); EH_UNUSED(thiz); archive_ctx *ctx = NULL; @@ -376,7 +376,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint JNIEXPORT jobject JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz, - jint index) { + jint index) { EH_UNUSED(env); EH_UNUSED(thiz); void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index); @@ -432,7 +432,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_needPassword(JNIEnv *env, jclass thiz) { JNIEXPORT jboolean JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz, - jstring str) { + jstring str) { EH_UNUSED(thiz); struct archive_entry *entry; archive_ctx *ctx; @@ -443,7 +443,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz, LOGE("Allocate passwd buffer failed"); return false; } - strcpy(passwd, (*env)->GetStringUTFChars(env, str, NULL)); + (*env)->GetStringUTFRegion(env, str, 0, len, passwd); archive_alloc_ctx(&ctx); void *tmpBuf = alloca(4096); while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) { @@ -463,7 +463,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz, JNIEXPORT jstring JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_getExtension(JNIEnv *env, jclass thiz, - jint index) { + jint index) { EH_UNUSED(env); EH_UNUSED(thiz); const char *ext = strrchr(entries[index].filename, '.') + 1; @@ -472,7 +472,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_getExtension(JNIEnv *env, jclass thiz, JNIEXPORT jboolean JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz, - jint index, jint fd) { + jint index, jint fd) { EH_UNUSED(env); EH_UNUSED(thiz); index = entries[index].index; @@ -488,9 +488,44 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz, JNIEXPORT void JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz, - jobject buffer) { + jobject buffer) { EH_UNUSED(thiz); void *addr = (*env)->GetDirectBufferAddress(env, buffer); size_t size = (*env)->GetDirectBufferCapacity(env, buffer); mempool_release_pages(addr, size); } + +JNIEXPORT jboolean JNICALL +Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz, jintArray fd_batch, + jobjectArray names, jint arc_fd, jint size) { + struct archive *arc = archive_write_new(); + struct stat st; + char buff[8192]; + jint fdBatch[size]; + (*env)->GetIntArrayRegion(env, fd_batch, 0, size, fdBatch); + archive_write_set_format_zip(arc); + archive_write_zip_set_compression_store(arc); + archive_write_open_fd(arc, arc_fd); + struct archive_entry *entry = archive_entry_new(); + for (int i = 0; i < size; i++) { + int fd = fdBatch[i]; + jobject name = (*env)->GetObjectArrayElement(env, names, i); + const char *cname = (*env)->GetStringUTFChars(env, name, false); + archive_entry_set_pathname(entry, cname); + (*env)->ReleaseStringUTFChars(env, name, cname); + fstat(fd, &st); + archive_entry_copy_stat(entry, &st); + archive_entry_set_perm(entry, 0644); + archive_write_header(arc, entry); + int len = read(fd, buff, sizeof(buff)); + while (len > 0) { + archive_write_data(arc, buff, len); + len = read(fd, buff, sizeof(buff)); + } + archive_write_finish_entry(arc); + archive_entry_clear(entry); + } + archive_write_close(arc); + archive_write_free(arc); + return true; +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/jni/Archive.kt b/app/src/main/kotlin/com/hippo/ehviewer/jni/Archive.kt index 3abe38354..38e2ff27f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/jni/Archive.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/jni/Archive.kt @@ -10,3 +10,4 @@ external fun getExtension(index: Int): String external fun needPassword(): Boolean external fun providePassword(str: String): Boolean external fun closeArchive() +external fun archiveFdBatch(fdBatch: IntArray, names: Array, arcFd: Int, size: Int): Boolean diff --git a/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderDen.kt b/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderDen.kt index 44f0c0310..af0138970 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderDen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderDen.kt @@ -15,8 +15,13 @@ */ package com.hippo.ehviewer.spider +import arrow.fx.coroutines.autoCloseable +import arrow.fx.coroutines.closeable +import arrow.fx.coroutines.parMap +import arrow.fx.coroutines.resourceScope import com.hippo.ehviewer.EhApplication.Companion.imageCache as sCache import com.hippo.ehviewer.EhDB +import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhUtils.getSuitableTitle import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.ehRequest @@ -25,11 +30,13 @@ import com.hippo.ehviewer.coil.read import com.hippo.ehviewer.coil.suspendEdit import com.hippo.ehviewer.download.downloadLocation import com.hippo.ehviewer.image.UniFileSource +import com.hippo.ehviewer.jni.archiveFdBatch import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.sendTo import com.hippo.unifile.UniFile import com.hippo.unifile.asUniFile import com.hippo.unifile.openOutputStream +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext import io.ktor.client.plugins.onDownload import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel @@ -39,8 +46,8 @@ import io.ktor.utils.io.jvm.nio.copyTo import java.util.Locale import kotlin.io.path.readText -class SpiderDen(mGalleryInfo: GalleryInfo) { - private val mGid = mGalleryInfo.gid +class SpiderDen(val info: GalleryInfo) { + private val gid = info.gid var downloadDir: UniFile? = null @Volatile @@ -51,12 +58,12 @@ class SpiderDen(mGalleryInfo: GalleryInfo) { suspend fun setMode(value: Int) { mode = value if (mode == SpiderQueen.MODE_DOWNLOAD && downloadDir == null) { - downloadDir = getGalleryDownloadDir(mGid)?.takeIf { it.ensureDir() } + downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.ensureDir() } } } private fun containInCache(index: Int): Boolean { - val key = getImageKey(mGid, index) + val key = getImageKey(gid, index) return sCache.read(key) { true } ?: false } @@ -67,7 +74,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) { private fun copyFromCacheToDownloadDir(index: Int): Boolean { val dir = downloadDir ?: return false - val key = getImageKey(mGid, index) + val key = getImageKey(gid, index) return runCatching { sCache.read(key) { val extension = "." + metadata.toFile().readText() @@ -97,7 +104,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) { } private fun removeFromCache(index: Int): Boolean { - val key = getImageKey(mGid, index) + val key = getImageKey(gid, index) return sCache.remove(key) } @@ -146,7 +153,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) { // Read Mode, allow save to cache if (mode == SpiderQueen.MODE_READ) { - val key = getImageKey(mGid, index) + val key = getImageKey(gid, index) return sCache.suspendEdit(key) { metadata.toFile().writeText(ext) fops(data.asUniFile()) @@ -167,7 +174,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) { } fun saveToUniFile(index: Int, file: UniFile): Boolean { - val key = getImageKey(mGid, index) + val key = getImageKey(gid, index) // Read from diskCache first sCache.read(key) { @@ -195,14 +202,14 @@ class SpiderDen(mGalleryInfo: GalleryInfo) { } fun getExtension(index: Int): String? { - val key = getImageKey(mGid, index) + val key = getImageKey(gid, index) return sCache.read(key) { metadata.toNioPath().readText() } ?: downloadDir?.let { findImageFile(it, index) }?.name.let { FileUtils.getExtensionFromFilename(it) } } fun getImageSource(index: Int): UniFileSource? { if (mode == SpiderQueen.MODE_READ) { - val key = getImageKey(mGid, index) + val key = getImageKey(gid, index) val snapshot = sCache.openSnapshot(key) if (snapshot != null) { return object : UniFileSource, AutoCloseable by snapshot { @@ -218,6 +225,41 @@ class SpiderDen(mGalleryInfo: GalleryInfo) { override fun close() {} } } + + suspend fun exportAsCbz(file: UniFile) = resourceScope { + val pages = info.pages + val comicInfo = closeable { + val f = downloadDir!! / COMIC_INFO_FILE + if (!f.exists()) writeComicInfo() + f.openFileDescriptor("r") + } + val (fdBatch, names) = (0 until pages).parMap { idx -> + val ext = requireNotNull(getExtension(idx)) + val f = autoCloseable { requireNotNull(getImageSource(idx)) } + closeable { f.source.openFileDescriptor("r") }.fd to perFilename(idx, ".$ext") + }.run { plus(comicInfo.fd to COMIC_INFO_FILE) }.unzip() + val arcFd = closeable { file.openFileDescriptor("rw") } + archiveFdBatch(fdBatch.toIntArray(), names.toTypedArray(), arcFd.fd, pages + 1) + } + + suspend fun initDownloadDirIfExist() { + downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.isDirectory } + } + + suspend fun writeComicInfo() { + downloadDir?.run { + createFile(COMIC_INFO_FILE)?.also { + runCatching { + withNonCancellableContext { + EhEngine.fillGalleryListByApi(listOf(info)) + } + info.getComicInfo().write(it) + }.onFailure { + it.printStackTrace() + } + } + } + } } /** diff --git a/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderQueen.kt b/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderQueen.kt index 27a340ec4..f66c226c3 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderQueen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/spider/SpiderQueen.kt @@ -41,7 +41,6 @@ import com.hippo.ehviewer.image.Image import com.hippo.ehviewer.util.ExceptionUtils import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withNonCancellableContext import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -227,7 +226,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS private val prepareJob = launchIO { doPrepare() } private suspend fun doPrepare() { - mSpiderDen.downloadDir = getGalleryDownloadDir(galleryInfo.gid)?.takeIf { it.isDirectory } + mSpiderDen.initDownloadDirIfExist() mSpiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet() ?: return mPageStateArray = IntArray(mSpiderInfo.pages) notifyGetPages(mSpiderInfo.pages) @@ -426,21 +425,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS mSpiderInfo.saveToCache() } - private suspend fun writeComicInfo() { - mSpiderDen.downloadDir?.run { - createFile(COMIC_INFO_FILE)?.also { - runCatching { - withNonCancellableContext { - EhEngine.fillGalleryListByApi(listOf(galleryInfo)) - } - galleryInfo.getComicInfo().write(it) - }.onFailure { - it.printStackTrace() - } - } - } - } - private fun isStateDone(state: Int): Boolean { return state == STATE_FINISHED || state == STATE_FAILED } @@ -507,7 +491,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS // Will create download dir if not exists updateMode() if (mode == MODE_DOWNLOAD) { - writeComicInfo() + mSpiderDen.writeComicInfo() } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt index 386d6f7b9..4be97022b 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt @@ -10,6 +10,7 @@ import android.os.Parcelable import android.text.TextUtils.TruncateAt.END import android.view.View import android.widget.ArrayAdapter +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -118,6 +119,7 @@ import com.hippo.ehviewer.databinding.DialogArchiveListBinding import com.hippo.ehviewer.databinding.DialogTorrentListBinding import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager import com.hippo.ehviewer.ktbuilder.imageRequest +import com.hippo.ehviewer.spider.SpiderDen import com.hippo.ehviewer.spider.SpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.Companion.MODE_READ import com.hippo.ehviewer.ui.GalleryInfoBottomSheet @@ -154,9 +156,12 @@ import com.hippo.ehviewer.util.ExceptionUtils import com.hippo.ehviewer.util.FavouriteStatusRouter import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.addTextToClipboard +import com.hippo.ehviewer.util.awaitActivityResult +import com.hippo.ehviewer.util.bgWork import com.hippo.ehviewer.util.findActivity import com.hippo.ehviewer.util.isAtLeastQ import com.hippo.ehviewer.util.requestPermission +import com.hippo.unifile.asUniFile import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import eu.kanade.tachiyomi.util.lang.launchIO @@ -1214,6 +1219,43 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa context.openBrowser(galleryDetailUrl) }, ) + val exportSuccess = stringResource(id = R.string.export_as_archive_success) + val exportFailed = stringResource(id = R.string.export_as_archive_failed) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.export_as_archive)) }, + onClick = { + dropdown = false + coroutineScope.launchIO { + val canExport = EhDownloadManager.getDownloadState(gid) == DownloadInfo.STATE_FINISH + if (!canExport) { + dialogState.awaitPermissionOrCancel( + showCancelButton = false, + text = { Text(text = stringResource(id = R.string.download_gallery_first)) }, + ) + } else { + val info = galleryInfo!! + val uri = with(context) { + awaitActivityResult( + CreateDocument("application/x-cbz"), + EhUtils.getSuitableTitle(info), + ) + } + if (uri != null) { + val success = dialogState.bgWork { + withIOContext { + SpiderDen(info).run { + initDownloadDirIfExist() + exportAsCbz(uri.asUniFile()) + } + } + } + val msg = if (success) exportSuccess else exportFailed + snackbarState.showSnackbar(message = msg) + } + } + } + }, + ) } }, ) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PreferenceWidgets.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PreferenceWidgets.kt index ad42261c6..f165aa8d0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PreferenceWidgets.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/PreferenceWidgets.kt @@ -11,13 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -38,12 +33,11 @@ import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import arrow.atomic.Atomic -import com.hippo.ehviewer.R import com.hippo.ehviewer.ui.destinations.DirectionDestination import com.hippo.ehviewer.ui.openBrowser import com.hippo.ehviewer.ui.settings.PreferenceTokens.PreferenceTextPadding +import com.hippo.ehviewer.util.ProgressDialog import com.jamal.composeprefs3.ui.prefs.DropDownPref import com.jamal.composeprefs3.ui.prefs.DropDownPrefInt import com.jamal.composeprefs3.ui.prefs.SliderPref @@ -157,30 +151,6 @@ fun SimpleMenuPreference(title: String, @ArrayRes entry: Int, @ArrayRes entryVal DropDownPref(title = title, defaultValue = v, onValueChange = ::set, useSelectedAsSummary = true, entries = map) } -@Composable -private fun ProgressDialog() { - BasicAlertDialog( - onDismissRequest = { }, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), - ) { - Surface( - modifier = Modifier.width(280.dp), - shape = AlertDialogDefaults.shape, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(18.dp), - ) { - CircularProgressIndicator() - Spacer(modifier = Modifier.size(18.dp)) - Text(text = stringResource(id = R.string.please_wait)) - } - } - } -} - @Composable fun WorkPreference(title: String, summary: String? = null, work: suspend CoroutineScope.() -> Unit) { val coroutineScope = rememberCoroutineScope { Dispatchers.IO } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/util/Progress.kt b/app/src/main/kotlin/com/hippo/ehviewer/util/Progress.kt new file mode 100644 index 000000000..d46cc5996 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/util/Progress.kt @@ -0,0 +1,53 @@ +package com.hippo.ehviewer.util + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.hippo.ehviewer.R.string +import com.hippo.ehviewer.ui.tools.DialogState + +@Composable +fun ProgressDialog() { + BasicAlertDialog( + onDismissRequest = { }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + ) { + Surface( + modifier = Modifier.width(280.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(18.dp), + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.size(18.dp)) + Text(text = stringResource(id = string.please_wait)) + } + } + } +} + +suspend fun DialogState.bgWork(work: suspend () -> R) = dialog { cont -> + ProgressDialog() + LaunchedEffect(work) { + val result = runCatching { work() }.onFailure { it.printStackTrace() } + cont.resumeWith(result) + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b49783230..eac84dca1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -509,6 +509,10 @@ 有新版本! 已是最新版本 权限被拒 + 请先下载画廊! + 导出为归档文件 + 导出成功 + 导出失败 设置 重试 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 186db128a..bde86cb78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -573,6 +573,10 @@ New version available! Already the latest version Permission denied + Please download the gallery first! + Export as archive + Export succeed + Export failed Crop borders Settings