Move edit cover to manga info

This commit is contained in:
arkon 2020-07-11 22:40:05 -04:00
parent 1f67695713
commit 9f7fda0bc5
7 changed files with 132 additions and 142 deletions

View File

@ -1,7 +1,5 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -37,7 +35,6 @@ import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.hasCustomCover
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.gone
@ -52,7 +49,6 @@ import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.appcompat.queryTextChanges import reactivecircus.flowbinding.appcompat.queryTextChanges
import reactivecircus.flowbinding.viewpager.pageSelections import reactivecircus.flowbinding.viewpager.pageSelections
import rx.Subscription import rx.Subscription
import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -64,7 +60,6 @@ class LibraryController(
RootController, RootController,
TabbedController, TabbedController,
ActionMode.Callback, ActionMode.Callback,
ChangeMangaCoverDialog.Listener,
ChangeMangaCategoriesDialog.Listener, ChangeMangaCategoriesDialog.Listener,
DeleteLibraryMangasDialog.Listener { DeleteLibraryMangasDialog.Listener {
@ -88,8 +83,6 @@ class LibraryController(
*/ */
val selectedMangas = mutableSetOf<Manga>() val selectedMangas = mutableSetOf<Manga>()
private var selectedCoverManga: Manga? = null
/** /**
* Relay to notify the UI of selection updates. * Relay to notify the UI of selection updates.
*/ */
@ -468,7 +461,6 @@ class LibraryController(
} else { } else {
mode.title = count.toString() mode.title = count.toString()
binding.actionToolbar.findItem(R.id.action_edit_cover)?.isVisible = count == 1
binding.actionToolbar.findItem(R.id.action_download_unread)?.isVisible = selectedMangas.any { it.source != LocalSource.ID } binding.actionToolbar.findItem(R.id.action_download_unread)?.isVisible = selectedMangas.any { it.source != LocalSource.ID }
} }
return false return false
@ -480,7 +472,6 @@ class LibraryController(
private fun onActionItemClicked(item: MenuItem): Boolean { private fun onActionItemClicked(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_edit_cover -> handleChangeCover()
R.id.action_move_to_category -> showChangeMangaCategoriesDialog() R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
R.id.action_download_unread -> downloadUnreadChapters() R.id.action_download_unread -> downloadUnreadChapters()
R.id.action_delete -> showDeleteMangaDialog() R.id.action_delete -> showDeleteMangaDialog()
@ -540,23 +531,6 @@ class LibraryController(
} }
} }
private fun handleChangeCover() {
val manga = selectedMangas.firstOrNull() ?: return
if (manga.hasCustomCover(coverCache)) {
showEditCoverDialog(manga)
} else {
openMangaCoverPicker(manga)
}
}
/**
* Edit custom cover for selected manga.
*/
private fun showEditCoverDialog(manga: Manga) {
ChangeMangaCoverDialog(this, manga).showDialog(router)
}
/** /**
* Move the selected manga to a list of categories. * Move the selected manga to a list of categories.
*/ */
@ -586,31 +560,6 @@ class LibraryController(
DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router) DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router)
} }
override fun openMangaCoverPicker(manga: Manga) {
selectedCoverManga = manga
if (manga.favorite) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "image/*"
startActivityForResult(
Intent.createChooser(
intent,
resources?.getString(R.string.file_select_cover)
),
REQUEST_IMAGE_OPEN
)
} else {
activity?.toast(R.string.notification_first_add_to_library)
}
destroyActionModeIfNeeded()
}
override fun deleteMangaCover(manga: Manga) {
presenter.deleteCustomCover(manga)
destroyActionModeIfNeeded()
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) { override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
presenter.moveMangasToCategories(categories, mangas) presenter.moveMangasToCategories(categories, mangas)
destroyActionModeIfNeeded() destroyActionModeIfNeeded()
@ -632,32 +581,4 @@ class LibraryController(
selectInverseRelay.call(it) selectInverseRelay.call(it)
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_IMAGE_OPEN) {
val dataUri = data?.data
if (dataUri == null || resultCode != Activity.RESULT_OK) return
val activity = activity ?: return
val manga = selectedCoverManga ?: return
selectedCoverManga = null
presenter.editCover(manga, activity, dataUri)
}
}
fun onSetCoverSuccess() {
activity?.toast(R.string.cover_updated)
}
fun onSetCoverError(error: Throwable) {
activity?.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
private companion object {
/**
* Key to change the cover of a manga in [onActivityResult].
*/
const val REQUEST_IMAGE_OPEN = 101
}
} }

View File

@ -1,7 +1,5 @@
package eu.kanade.tachiyomi.ui.library package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
@ -11,7 +9,6 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
@ -21,7 +18,6 @@ import eu.kanade.tachiyomi.util.lang.combineLatest
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.updateCoverLastModified
import java.util.Collections import java.util.Collections
import java.util.Comparator import java.util.Comparator
import rx.Observable import rx.Observable
@ -374,46 +370,4 @@ class LibraryPresenter(
db.setMangaCategories(mc, mangas) db.setMangaCategories(mc, mangas)
} }
/**
* Update cover with local file.
*
* @param manga the manga edited.
* @param context Context.
* @param data uri of the cover resource.
*/
fun editCover(manga: Manga, context: Context, data: Uri) {
Observable
.fromCallable {
context.contentResolver.openInputStream(data)?.use {
if (manga.isLocal()) {
LocalSource.updateCover(context, manga, it)
manga.updateCoverLastModified(db)
} else if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, it)
manga.updateCoverLastModified(db)
}
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
fun deleteCustomCover(manga: Manga) {
Observable
.fromCallable {
coverCache.deleteCustomCover(manga)
manga.updateCoverLastModified(db)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
} }

View File

@ -26,6 +26,7 @@ import com.google.android.material.snackbar.Snackbar
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
@ -44,6 +45,7 @@ import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.ChangeMangaCoverDialog
import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight
@ -59,6 +61,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.recent.history.HistoryController import eu.kanade.tachiyomi.ui.recent.history.HistoryController
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.hasCustomCover
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.getCoordinates import eu.kanade.tachiyomi.util.view.getCoordinates
@ -81,6 +84,7 @@ class MangaController :
ActionMode.Callback, ActionMode.Callback,
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener, FlexibleAdapter.OnItemLongClickListener,
ChangeMangaCoverDialog.Listener,
ChangeMangaCategoriesDialog.Listener, ChangeMangaCategoriesDialog.Listener,
DownloadCustomChaptersDialog.Listener, DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener { DeleteChaptersDialog.Listener {
@ -113,6 +117,7 @@ class MangaController :
private val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false) private val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false)
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
private val coverCache: CoverCache by injectLazy()
private var mangaInfoAdapter: MangaInfoHeaderAdapter? = null private var mangaInfoAdapter: MangaInfoHeaderAdapter? = null
private var chaptersHeaderAdapter: MangaChaptersHeaderAdapter? = null private var chaptersHeaderAdapter: MangaChaptersHeaderAdapter? = null
@ -310,7 +315,8 @@ class MangaController :
// Hide download options for local manga // Hide download options for local manga
menu.findItem(R.id.download_group).isVisible = !isLocalSource menu.findItem(R.id.download_group).isVisible = !isLocalSource
// Hide migrate option for non-library manga // Hide edit cover and migrate options for non-library manga
menu.findItem(R.id.action_edit_cover).isVisible = presenter.manga.favorite
menu.findItem(R.id.action_migrate).isVisible = presenter.manga.favorite menu.findItem(R.id.action_migrate).isVisible = presenter.manga.favorite
} }
@ -371,6 +377,7 @@ class MangaController :
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
R.id.action_edit_cover -> handleChangeCover()
R.id.action_migrate -> migrateManga() R.id.action_migrate -> migrateManga()
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
@ -582,22 +589,78 @@ class MangaController :
} }
} }
// Manga info - end private fun handleChangeCover() {
val manga = manga ?: return
if (manga.hasCustomCover(coverCache)) {
showEditCoverDialog(manga)
} else {
openMangaCoverPicker(manga)
}
}
// Chapters list - start /**
* Edit custom cover for selected manga.
*/
private fun showEditCoverDialog(manga: Manga) {
ChangeMangaCoverDialog(this, manga).showDialog(router)
}
override fun openMangaCoverPicker(manga: Manga) {
if (manga.favorite) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "image/*"
startActivityForResult(
Intent.createChooser(
intent,
resources?.getString(R.string.file_select_cover)
),
REQUEST_IMAGE_OPEN
)
} else {
activity?.toast(R.string.notification_first_add_to_library)
}
destroyActionModeIfNeeded()
}
override fun deleteMangaCover(manga: Manga) {
presenter.deleteCustomCover(manga)
mangaInfoAdapter?.notifyDataSetChanged()
destroyActionModeIfNeeded()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_IMAGE_OPEN) {
val dataUri = data?.data
if (dataUri == null || resultCode != Activity.RESULT_OK) return
val activity = activity ?: return
presenter.editCover(manga!!, activity, dataUri)
}
}
fun onSetCoverSuccess() {
mangaInfoAdapter?.notifyDataSetChanged()
activity?.toast(R.string.cover_updated)
}
fun onSetCoverError(error: Throwable) {
activity?.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
/** /**
* Initiates source migration for the specific manga. * Initiates source migration for the specific manga.
*/ */
private fun migrateManga() { private fun migrateManga() {
val controller = val controller = SearchController(presenter.manga)
SearchController(
presenter.manga
)
controller.targetController = this controller.targetController = this
router.pushController(controller.withFadeTransaction()) router.pushController(controller.withFadeTransaction())
} }
// Manga info - end
// Chapters list - start
fun onNextChapters(chapters: List<ChapterItem>) { fun onNextChapters(chapters: List<ChapterItem>) {
// If the list is empty and it hasn't requested previously, fetch chapters from source // If the list is empty and it hasn't requested previously, fetch chapters from source
// We use presenter chapters instead because they are always unfiltered // We use presenter chapters instead because they are always unfiltered
@ -943,5 +1006,10 @@ class MangaController :
companion object { companion object {
const val FROM_SOURCE_EXTRA = "from_source" const val FROM_SOURCE_EXTRA = "from_source"
const val MANGA_EXTRA = "manga" const val MANGA_EXTRA = "manga"
/**
* Key to change the cover of a manga in [onActivityResult].
*/
const val REQUEST_IMAGE_OPEN = 101
} }
} }

View File

@ -1,5 +1,7 @@
package eu.kanade.tachiyomi.ui.manga package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.PublishRelay import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
@ -11,6 +13,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
@ -21,6 +24,7 @@ import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.prepUpdateCover import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.updateCoverLastModified
import java.util.Date import java.util.Date
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
@ -217,6 +221,48 @@ class MangaPresenter(
moveMangaToCategories(manga, listOfNotNull(category)) moveMangaToCategories(manga, listOfNotNull(category))
} }
/**
* Update cover with local file.
*
* @param manga the manga edited.
* @param context Context.
* @param data uri of the cover resource.
*/
fun editCover(manga: Manga, context: Context, data: Uri) {
Observable
.fromCallable {
context.contentResolver.openInputStream(data)?.use {
if (manga.isLocal()) {
LocalSource.updateCover(context, manga, it)
manga.updateCoverLastModified(db)
} else if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, it)
manga.updateCoverLastModified(db)
}
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
fun deleteCustomCover(manga: Manga) {
Observable
.fromCallable {
coverCache.deleteCustomCover(manga)
manga.updateCoverLastModified(db)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
// Manga info - end // Manga info - end
// Chapters list - start // Chapters list - start

View File

@ -12,6 +12,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.MangaThumbnail
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding
@ -49,6 +50,7 @@ class MangaInfoHeaderAdapter(
private lateinit var binding: MangaInfoHeaderBinding private lateinit var binding: MangaInfoHeaderBinding
private var initialLoad: Boolean = true private var initialLoad: Boolean = true
private var currentMangaThumbnail: MangaThumbnail? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -237,9 +239,10 @@ class MangaInfoHeaderAdapter(
// Set the favorite drawable to the correct one. // Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite) setFavoriteButtonState(manga.favorite)
// Set cover if it wasn't already. // Set cover if changed.
if (binding.mangaCover.drawable == null) {
val mangaThumbnail = manga.toMangaThumbnail() val mangaThumbnail = manga.toMangaThumbnail()
if (mangaThumbnail != currentMangaThumbnail) {
currentMangaThumbnail = mangaThumbnail
listOf(binding.mangaCover, binding.backdrop) listOf(binding.mangaCover, binding.backdrop)
.forEach { .forEach {
GlideApp.with(view.context) GlideApp.with(view.context)

View File

@ -97,6 +97,11 @@
</menu> </menu>
</item> </item>
<item
android:id="@+id/action_edit_cover"
android:title="@string/action_edit_cover"
app:showAsAction="never" />
<item <item
android:id="@+id/action_migrate" android:id="@+id/action_migrate"
android:title="@string/action_migrate" android:title="@string/action_migrate"

View File

@ -2,13 +2,6 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" <menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_edit_cover"
android:icon="@drawable/ic_edit_24dp"
android:title="@string/action_edit_cover"
app:iconTint="?attr/colorOnPrimary"
app:showAsAction="always" />
<item <item
android:id="@+id/action_move_to_category" android:id="@+id/action_move_to_category"
android:icon="@drawable/ic_label_24dp" android:icon="@drawable/ic_label_24dp"