Batch pages saving
This commit is contained in:
@@ -88,6 +88,10 @@ fun Manga.findChapter(id: Long): MangaChapter? {
|
||||
return chapters?.findById(id)
|
||||
}
|
||||
|
||||
fun Manga.requireChapter(id: Long): MangaChapter = checkNotNull(findChapter(id)) {
|
||||
"Chapter $id not found"
|
||||
}
|
||||
|
||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val ch = chapters
|
||||
if (ch.isNullOrEmpty()) {
|
||||
|
||||
@@ -2,8 +2,13 @@ package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
@@ -20,10 +25,12 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
@@ -34,16 +41,18 @@ import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PagesFragment :
|
||||
BaseFragment<FragmentPagesBinding>(),
|
||||
OnListItemClickListener<PageThumbnail> {
|
||||
OnListItemClickListener<PageThumbnail>, ListSelectionController.Callback {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
@@ -51,17 +60,23 @@ class PagesFragment :
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@Inject
|
||||
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||
|
||||
private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
|
||||
private val viewModel by viewModels<PagesViewModel>()
|
||||
private lateinit var pageSaveHelper: PageSaveHelper
|
||||
|
||||
private var thumbnailsAdapter: PageThumbnailAdapter? = null
|
||||
private var spanResolver: GridSpanResolver? = null
|
||||
private var scrollListener: ScrollListener? = null
|
||||
private var selectionController: ListSelectionController? = null
|
||||
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
combine(
|
||||
parentViewModel.mangaDetails,
|
||||
parentViewModel.readingState,
|
||||
@@ -83,6 +98,12 @@ class PagesFragment :
|
||||
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
spanResolver = GridSpanResolver(binding.root.resources)
|
||||
selectionController = ListSelectionController(
|
||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||
decoration = PagesSelectionDecoration(binding.root.context),
|
||||
registryOwner = this,
|
||||
callback = this,
|
||||
)
|
||||
thumbnailsAdapter = PageThumbnailAdapter(
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
@@ -91,6 +112,7 @@ class PagesFragment :
|
||||
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
adapter = thumbnailsAdapter
|
||||
setHasFixedSize(true)
|
||||
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
|
||||
@@ -103,6 +125,7 @@ class PagesFragment :
|
||||
}
|
||||
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
||||
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
|
||||
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
|
||||
@@ -113,6 +136,7 @@ class PagesFragment :
|
||||
spanResolver = null
|
||||
scrollListener = null
|
||||
thumbnailsAdapter = null
|
||||
selectionController = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
}
|
||||
@@ -120,6 +144,9 @@ class PagesFragment :
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun onItemClick(item: PageThumbnail, view: View) {
|
||||
if (selectionController?.onItemClick(item.page.id) == true) {
|
||||
return
|
||||
}
|
||||
val listener = findParentCallback(ReaderNavigationCallback::class.java)
|
||||
if (listener != null && listener.onPageSelected(item.page)) {
|
||||
dismissParentDialog()
|
||||
@@ -133,6 +160,39 @@ class PagesFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: PageThumbnail, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(view, item.page.id) ?: false
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: PageThumbnail, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.page.id) ?: false
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||
viewBinding?.recyclerView?.invalidateItemDecorations()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu,
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_pages, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_save -> {
|
||||
viewModel.savePages(pageSaveHelper, collectSelectedPages())
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
|
||||
val adapter = thumbnailsAdapter ?: return
|
||||
if (adapter.itemCount == 0) {
|
||||
@@ -172,6 +232,18 @@ class PagesFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectSelectedPages(): Set<ReaderPage> {
|
||||
val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet()
|
||||
val items = thumbnailsAdapter?.items ?: return emptySet()
|
||||
val result = ArraySet<ReaderPage>(checkedIds.size)
|
||||
for (item in items) {
|
||||
if (item is PageThumbnail && item.page.id in checkedIds) {
|
||||
result.add(item.page)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private inner class ScrollListener : BoundsScrollListener(3, 3) {
|
||||
|
||||
override fun onScrolledToStart(recyclerView: RecyclerView) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
|
||||
class PagesSavedObserver(
|
||||
private val snackbarHost: View,
|
||||
) : FlowCollector<Collection<Uri>> {
|
||||
|
||||
override suspend fun emit(value: Collection<Uri>) {
|
||||
val msg = when (value.size) {
|
||||
0 -> R.string.nothing_found
|
||||
1 -> R.string.page_saved
|
||||
else -> R.string.pages_saved
|
||||
}
|
||||
val snackbar = Snackbar.make(snackbarHost, msg, Snackbar.LENGTH_LONG)
|
||||
value.singleOrNull()?.let { uri ->
|
||||
snackbar.setAction(R.string.share) {
|
||||
ShareHelper(snackbarHost.context).shareImage(uri)
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
||||
|
||||
class PagesSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
|
||||
|
||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
|
||||
val item = holder.getItem(PageThumbnail::class.java) ?: return RecyclerView.NO_ID
|
||||
return item.page.id
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -7,15 +8,21 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.model.requireChapter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.firstNotNull
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -32,6 +39,7 @@ class PagesViewModel @Inject constructor(
|
||||
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
|
||||
val isLoadingUp = MutableStateFlow(false)
|
||||
val isLoadingDown = MutableStateFlow(false)
|
||||
val onPageSaved = MutableEventFlow<Collection<Uri>>()
|
||||
|
||||
val gridScale = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
@@ -73,6 +81,25 @@ class PagesViewModel @Inject constructor(
|
||||
loadingNextJob = loadPrevNextChapter(isNext = true)
|
||||
}
|
||||
|
||||
fun savePages(
|
||||
pageSaveHelper: PageSaveHelper,
|
||||
pages: Set<ReaderPage>,
|
||||
) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val manga = state.requireValue().details.toManga()
|
||||
val tasks = pages.map {
|
||||
PageSaveHelper.Task(
|
||||
manga = manga,
|
||||
chapter = manga.requireChapter(it.chapterId),
|
||||
pageNumber = it.index + 1,
|
||||
page = it.toMangaPage(),
|
||||
)
|
||||
}
|
||||
val dest = pageSaveHelper.save(tasks)
|
||||
onPageSaved.call(dest)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doInit(state: State) {
|
||||
chaptersLoader.init(state.details)
|
||||
val initialChapterId = state.readerState?.chapterId?.takeIf {
|
||||
|
||||
@@ -115,9 +115,9 @@ abstract class MangaListFragment :
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = listAdapter
|
||||
checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView)
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
addOnScrollListener(paginationListener!!)
|
||||
addOnScrollListener(checkNotNull(paginationListener))
|
||||
fastScroller.setFastScrollListener(this@MangaListFragment)
|
||||
}
|
||||
with(binding.swipeRefreshLayout) {
|
||||
|
||||
@@ -67,17 +67,14 @@ class PageSaveHelper @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(tasks: Set<Task>): Uri? = when (tasks.size) {
|
||||
0 -> null
|
||||
1 -> saveImpl(tasks.first())
|
||||
else -> {
|
||||
saveImpl(tasks)
|
||||
null
|
||||
}
|
||||
suspend fun save(tasks: Collection<Task>): Collection<Uri> = when (tasks.size) {
|
||||
0 -> emptySet()
|
||||
1 -> setOf(saveImpl(tasks.first()))
|
||||
else -> saveImpl(tasks)
|
||||
}
|
||||
|
||||
private suspend fun saveImpl(task: Task): Uri {
|
||||
val pageLoader = pageLoaderProvider.get()
|
||||
val pageLoader = getPageLoader()
|
||||
val pageUrl = pageLoader.getPageUrl(task.page).toUri()
|
||||
val pageUri = pageLoader.loadPage(task.page, force = false)
|
||||
val proposedName = task.getFileBaseName() + "." + getPageExtension(pageUrl, pageUri)
|
||||
@@ -89,13 +86,14 @@ class PageSaveHelper @AssistedInject constructor(
|
||||
return destination
|
||||
}
|
||||
|
||||
private suspend fun saveImpl(tasks: Collection<Task>) {
|
||||
val pageLoader = pageLoaderProvider.get()
|
||||
private suspend fun saveImpl(tasks: Collection<Task>): Collection<Uri> {
|
||||
val pageLoader = getPageLoader()
|
||||
val destinationDir = getDefaultFileUri(null) ?: run {
|
||||
val defaultUri = settings.getPagesSaveDir(context)?.uri
|
||||
DocumentFile.fromTreeUri(context, pickDirectoryRequest.launchAndAwait(defaultUri))
|
||||
} ?: throw IOException("Cannot get destination directory")
|
||||
|
||||
val result = ArrayList<Uri>(tasks.size)
|
||||
for (task in tasks) {
|
||||
val pageUrl = pageLoader.getPageUrl(task.page).toUri()
|
||||
val pageUri = pageLoader.loadPage(task.page, force = false)
|
||||
@@ -106,7 +104,9 @@ class PageSaveHelper @AssistedInject constructor(
|
||||
}
|
||||
val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.'))
|
||||
copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file"))
|
||||
result.add(destination.uri)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun getPageExtension(url: Uri, fileUri: Uri): String {
|
||||
@@ -143,6 +143,10 @@ class PageSaveHelper @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getPageLoader() = withContext(Dispatchers.Main.immediate) {
|
||||
pageLoaderProvider.get()
|
||||
}
|
||||
|
||||
private fun getDefaultFileUri(proposedName: String?): DocumentFile? {
|
||||
if (settings.isPagesSavingAskEnabled) {
|
||||
return null
|
||||
|
||||
@@ -53,6 +53,7 @@ import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.ext.zipWithPrevious
|
||||
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.PagesSavedObserver
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
@@ -143,7 +144,7 @@ class ReaderActivity :
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
|
||||
viewModel.onPageSaved.observeEvent(this, this::onPageSaved)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
|
||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.content.observe(this) {
|
||||
@@ -289,17 +290,6 @@ class ReaderActivity :
|
||||
readerManager.setDoubleReaderMode(isEnabled)
|
||||
}
|
||||
|
||||
private fun onPageSaved(uri: Uri?) {
|
||||
val snackbar = Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
|
||||
if (uri != null) {
|
||||
snackbar.setAction(R.string.share) {
|
||||
ShareHelper(this).shareImage(uri)
|
||||
}
|
||||
}
|
||||
snackbar.setAnchorView(viewBinding.appbarBottom)
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.model.requireChapter
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
@@ -111,7 +112,7 @@ class ReaderViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
val readerMode = MutableStateFlow<ReaderMode?>(null)
|
||||
val onPageSaved = MutableEventFlow<Uri?>()
|
||||
val onPageSaved = MutableEventFlow<Collection<Uri>>()
|
||||
val onShowToast = MutableEventFlow<Int>()
|
||||
val uiState = MutableStateFlow<ReaderUiState?>(null)
|
||||
|
||||
@@ -261,8 +262,8 @@ class ReaderViewModel @Inject constructor(
|
||||
val currentManga = manga.requireValue()
|
||||
val task = PageSaveHelper.Task(
|
||||
manga = currentManga,
|
||||
chapter = checkNotNull(currentManga.findChapter(state.chapterId)),
|
||||
pageNumber = state.page,
|
||||
chapter = currentManga.requireChapter(state.chapterId),
|
||||
pageNumber = state.page + 1,
|
||||
page = checkNotNull(getCurrentPage()) { "Cannot find current page" },
|
||||
)
|
||||
val dest = pageSaveHelper.save(setOf(task))
|
||||
|
||||
12
app/src/main/res/menu/mode_pages.xml
Normal file
12
app/src/main/res/menu/mode_pages.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:icon="@drawable/ic_save"
|
||||
android:title="@string/save"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
</menu>
|
||||
@@ -57,6 +57,7 @@
|
||||
<string name="_s_deleted_from_local_storage">\"%s\" deleted from local storage</string>
|
||||
<string name="save_page">Save page</string>
|
||||
<string name="page_saved">Page saved</string>
|
||||
<string name="pages_saved">Pages saved</string>
|
||||
<string name="share_image">Share image</string>
|
||||
<string name="_import">Import</string>
|
||||
<string name="delete">Delete</string>
|
||||
|
||||
Reference in New Issue
Block a user