629 lines
23 KiB
Kotlin
629 lines
23 KiB
Kotlin
package jp.juggler.subwaytooter.view
|
|
|
|
import android.content.Context
|
|
import android.graphics.Bitmap
|
|
import android.graphics.Canvas
|
|
import android.graphics.Matrix
|
|
import android.graphics.drawable.Animatable
|
|
import android.graphics.drawable.BitmapDrawable
|
|
import android.graphics.drawable.Drawable
|
|
import android.util.AttributeSet
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import androidx.appcompat.widget.AppCompatImageView
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
|
|
import com.bumptech.glide.Glide
|
|
import com.bumptech.glide.RequestManager
|
|
import com.bumptech.glide.load.DataSource
|
|
import com.bumptech.glide.load.Options
|
|
import com.bumptech.glide.load.engine.GlideException
|
|
import com.bumptech.glide.load.model.GlideUrl
|
|
import com.bumptech.glide.load.model.LazyHeaders
|
|
import com.bumptech.glide.load.model.ModelLoader
|
|
import com.bumptech.glide.load.model.ModelLoader.LoadData
|
|
import com.bumptech.glide.load.resource.gif.GifDrawable
|
|
import com.bumptech.glide.load.resource.gif.MyGifDrawable
|
|
import com.bumptech.glide.request.RequestListener
|
|
import com.bumptech.glide.request.target.ImageViewTarget
|
|
import com.bumptech.glide.request.target.Target
|
|
import com.bumptech.glide.request.transition.Transition
|
|
import jp.juggler.subwaytooter.pref.PrefB
|
|
import jp.juggler.util.data.clip
|
|
import jp.juggler.util.data.notEmpty
|
|
import jp.juggler.util.log.LogCategory
|
|
import java.nio.ByteBuffer
|
|
|
|
class MyNetworkImageView : AppCompatImageView {
|
|
|
|
// ロード中などに表示するDrawableのリソースID
|
|
private var mDefaultImage: Drawable? = null
|
|
|
|
// エラー時に表示するDrawableのリソースID
|
|
private var mErrorImage: Drawable? = null
|
|
|
|
// 角丸の半径。元画像の短辺に対する割合を指定するらしい
|
|
internal var mCornerRadius = 0f
|
|
|
|
// 表示したい画像のURL
|
|
private var mUrl: String? = null
|
|
private var mMayAnime: Boolean = false
|
|
|
|
// 非同期処理のキャンセル
|
|
private var mTarget: Target<*>? = null
|
|
|
|
private val procLoadImage: Runnable = Runnable { loadImageIfNecessary() }
|
|
private val procFocusPoint: Runnable = Runnable { updateFocusPoint() }
|
|
|
|
private var mediaTypeDrawable1: Drawable? = null
|
|
private var mediaTypeBottom = 0
|
|
private var mediaTypeLeft = 0
|
|
|
|
constructor(context: Context) : super(context)
|
|
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
|
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
|
|
context,
|
|
attrs,
|
|
defStyleAttr
|
|
)
|
|
|
|
fun setDefaultImage(defaultImage: Drawable?) {
|
|
mDefaultImage = defaultImage
|
|
loadImageIfNecessary()
|
|
}
|
|
|
|
fun setErrorImage(errorImage: Drawable?) {
|
|
mErrorImage = errorImage
|
|
loadImageIfNecessary()
|
|
}
|
|
|
|
fun setImageUrl(
|
|
r: Float,
|
|
url: String?,
|
|
animeUrl: String? = null,
|
|
) {
|
|
mCornerRadius = r
|
|
if (PrefB.bpEnableGifAnimation.value) {
|
|
animeUrl?.notEmpty()?.let {
|
|
mUrl = it
|
|
mMayAnime = true
|
|
loadImageIfNecessary()
|
|
return
|
|
}
|
|
}
|
|
mUrl = url
|
|
mMayAnime = false
|
|
loadImageIfNecessary()
|
|
}
|
|
|
|
private fun getGlide(): RequestManager? {
|
|
try {
|
|
return Glide.with(context)
|
|
} catch (ex: IllegalArgumentException) {
|
|
if (ex.message?.contains("destroyed activity") == true) {
|
|
// ignore it
|
|
} else {
|
|
log.e(ex, "Glide.with() failed.")
|
|
}
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "Glide.with() failed.")
|
|
}
|
|
return null
|
|
}
|
|
|
|
fun cancelLoading(defaultDrawable: Drawable? = null) {
|
|
|
|
val d = drawable
|
|
if (d is Animatable) {
|
|
if (d.isRunning) {
|
|
//warning.d("cancelLoading: Animatable.stop()")
|
|
d.stop()
|
|
}
|
|
}
|
|
|
|
setImageDrawable(defaultDrawable)
|
|
|
|
val target = mTarget
|
|
if (target != null) {
|
|
try {
|
|
getGlide()?.clear(target)
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "Glide.clear() failed.")
|
|
}
|
|
|
|
mTarget = null
|
|
}
|
|
}
|
|
|
|
// 必要なら非同期処理を開始する
|
|
private fun loadImageIfNecessary() {
|
|
try {
|
|
val url = mUrl
|
|
if (url?.isEmpty() != false) {
|
|
// if the URL to be loaded in this view is empty,
|
|
// cancel any old requests and clear the currently loaded image.
|
|
cancelLoading(mDefaultImage)
|
|
return
|
|
}
|
|
|
|
// すでにリクエストが発行済みで、リクエストされたURLが同じなら何もしない
|
|
if ((mTarget as? UrlTarget)?.urlLoading == url) return
|
|
|
|
// if there is a pre-existing request, cancel it if it's fetching a different URL.
|
|
cancelLoading(mDefaultImage)
|
|
|
|
// 非表示状態ならロードを延期する
|
|
if (!isShown) return
|
|
|
|
var wrapWidth = false
|
|
var wrapHeight = false
|
|
val lp = layoutParams
|
|
if (lp != null) {
|
|
wrapWidth = lp.width == ViewGroup.LayoutParams.WRAP_CONTENT
|
|
wrapHeight = lp.height == ViewGroup.LayoutParams.WRAP_CONTENT
|
|
}
|
|
|
|
// Calculate the max image width / height to use while ignoring WRAP_CONTENT dimens.
|
|
val desiredWidth = if (wrapWidth) Target.SIZE_ORIGINAL else width
|
|
val desiredHeight = if (wrapHeight) Target.SIZE_ORIGINAL else height
|
|
|
|
if (desiredWidth != Target.SIZE_ORIGINAL && desiredWidth <= 0 ||
|
|
desiredHeight != Target.SIZE_ORIGINAL && desiredHeight <= 0
|
|
) {
|
|
// desiredWidth,desiredHeight の指定がおかしいと非同期処理中にSimpleTargetが落ちる
|
|
// おそらくレイアウト後に再度呼び出される
|
|
return
|
|
}
|
|
|
|
val glideHeaders = LazyHeaders.Builder()
|
|
.addHeader("Accept", "image/webp,image/*,*/*;q=0.8")
|
|
.build()
|
|
|
|
val glideUrl = GlideUrl(url, glideHeaders)
|
|
|
|
mTarget = if (mMayAnime) {
|
|
getGlide()
|
|
?.load(glideUrl)
|
|
?.listener(listener)
|
|
?.into(MyTargetGif(url))
|
|
} else {
|
|
getGlide()
|
|
?.load(glideUrl)
|
|
?.listener(listener)
|
|
?.into(MyTarget(url))
|
|
}
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "loadImageIfNecessary failed.")
|
|
}
|
|
}
|
|
|
|
private fun replaceGifDrawable(resource: GifDrawable): Drawable {
|
|
// ディスクキャッシュから読んだ画像は角丸が正しく扱われない
|
|
// MyGifDrawable に差し替えて描画させる
|
|
try {
|
|
return MyGifDrawable(resource, mCornerRadius)
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "replaceGifDrawable failed.")
|
|
}
|
|
return resource
|
|
}
|
|
|
|
private fun replaceBitmapDrawable(resource: BitmapDrawable): Drawable {
|
|
try {
|
|
resource.bitmap?.let { return replaceBitmapDrawable(it) }
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "replaceBitmapDrawable failed.")
|
|
}
|
|
return resource
|
|
}
|
|
|
|
private fun replaceBitmapDrawable(bitmap: Bitmap): Drawable {
|
|
val d = RoundedBitmapDrawableFactory.create(resources, bitmap)
|
|
d.cornerRadius = mCornerRadius
|
|
return d
|
|
}
|
|
|
|
private fun onLoadFailed(urlLoading: String) {
|
|
try {
|
|
// 別の画像を表示するよう指定が変化していたなら何もしない
|
|
if (urlLoading != mUrl) return
|
|
|
|
// エラー表示用の画像リソースが指定されていたら使う
|
|
when (val drawable = mErrorImage) {
|
|
// このタイミングでImageViewのDrawableを変更するとチラつきの元になるので何もしない
|
|
null -> Unit
|
|
|
|
else -> setImageDrawable(drawable)
|
|
}
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "onLoadFailed/setImageDrawable failed.")
|
|
}
|
|
}
|
|
|
|
private interface UrlTarget {
|
|
|
|
val urlLoading: String
|
|
}
|
|
|
|
// 静止画用のターゲット
|
|
private inner class MyTarget(
|
|
override val urlLoading: String,
|
|
) : ImageViewTarget<Drawable>(this@MyNetworkImageView), UrlTarget {
|
|
|
|
// errorDrawable The error drawable to optionally show, or null.
|
|
override fun onLoadFailed(errorDrawable: Drawable?) {
|
|
onLoadFailed(urlLoading)
|
|
}
|
|
|
|
override fun setResource(resource: Drawable?) {
|
|
try {
|
|
// 別の画像を表示するよう指定が変化していたなら何もしない
|
|
if (urlLoading != mUrl) return
|
|
|
|
if (mCornerRadius > 0f) {
|
|
if (resource is BitmapDrawable) {
|
|
// BitmapDrawableは角丸処理が可能。
|
|
setImageDrawable(replaceBitmapDrawable(resource.bitmap))
|
|
return
|
|
}
|
|
// その他のDrawable
|
|
// たとえばInstanceTickerのアイコンにSVGが使われていたらPictureDrawableになる
|
|
// log.w("cornerRadius=$mCornerRadius,drawable=$resource,url=$urlLoading")
|
|
}
|
|
|
|
setImageDrawable(resource)
|
|
return
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "setResource failed.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private inner class MyTargetGif(
|
|
override val urlLoading: String,
|
|
) : ImageViewTarget<Drawable>(this@MyNetworkImageView), UrlTarget {
|
|
|
|
private var glideDrawable: Drawable? = null
|
|
|
|
override fun onLoadFailed(errorDrawable: Drawable?) = onLoadFailed(urlLoading)
|
|
|
|
override fun onResourceReady(
|
|
drawable: Drawable,
|
|
transition: Transition<in Drawable>?,
|
|
) {
|
|
try {
|
|
// 別の画像を表示するよう指定が変化していたなら何もしない
|
|
if (urlLoading != mUrl) return
|
|
|
|
afterResourceReady(
|
|
transition,
|
|
when {
|
|
mCornerRadius <= 0f -> {
|
|
// 角丸でないならそのまま使う
|
|
drawable
|
|
}
|
|
|
|
// GidDrawableを置き換える
|
|
drawable is GifDrawable -> replaceGifDrawable(drawable)
|
|
|
|
// Glide 4.xから、静止画はBitmapDrawableになった
|
|
drawable is BitmapDrawable -> replaceBitmapDrawable(drawable)
|
|
|
|
else -> {
|
|
log.d("onResourceReady: drawable class=${drawable.javaClass.simpleName}")
|
|
drawable
|
|
}
|
|
}
|
|
)
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "onResourceReady failed.")
|
|
}
|
|
}
|
|
|
|
private fun afterResourceReady(transition: Transition<in Drawable>?, drawable: Drawable) {
|
|
super.onResourceReady(drawable, transition)
|
|
|
|
//if( ! drawable.isAnimated() ){
|
|
// //XXX: Try to generalize this to other sizes/shapes.
|
|
// // This is a dirty hack that tries to make loading square thumbnails and then square full images less costly
|
|
// // by forcing both the smaller thumb and the larger version to have exactly the same intrinsic dimensions.
|
|
// // If a drawable is replaced in an ImageView by another drawable with different intrinsic dimensions,
|
|
// // the ImageView requests a layout. Scrolling rapidly while replacing thumbs with larger images triggers
|
|
// // lots of these calls and causes significant amounts of junk.
|
|
// float viewRatio = view.getWidth() / (float) view.getHeight();
|
|
// float drawableRatio = drawable.getIntrinsicWidth() / (float) drawable.getIntrinsicHeight();
|
|
// if( Math.abs( viewRatio - 1f ) <= SQUARE_RATIO_MARGIN
|
|
// && Math.abs( drawableRatio - 1f ) <= SQUARE_RATIO_MARGIN ){
|
|
// drawable = new SquaringDrawable( drawable, view.getWidth() );
|
|
// }
|
|
//}
|
|
|
|
this.glideDrawable = drawable
|
|
if (drawable is GifDrawable) {
|
|
drawable.setLoopCount(GifDrawable.LOOP_FOREVER)
|
|
drawable.start()
|
|
} else if (drawable is MyGifDrawable) {
|
|
drawable.setLoopCount(GifDrawable.LOOP_FOREVER)
|
|
drawable.start()
|
|
}
|
|
}
|
|
|
|
// super.onResourceReady から呼ばれる
|
|
override fun setResource(drawable: Drawable?) {
|
|
setImageDrawable(drawable)
|
|
}
|
|
|
|
override fun onStart() {
|
|
val drawable = glideDrawable
|
|
if (drawable is Animatable && !drawable.isRunning) {
|
|
log.d("MyTargetGif onStart glide_drawable=$drawable")
|
|
drawable.start()
|
|
}
|
|
}
|
|
|
|
override fun onStop() {
|
|
val drawable = glideDrawable
|
|
if (drawable is Animatable && drawable.isRunning) {
|
|
log.d("MyTargetGif onStop glide_drawable=$drawable")
|
|
drawable.stop()
|
|
}
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
val drawable = glideDrawable
|
|
log.d("MyTargetGif onDestroy glide_drawable=$drawable")
|
|
super.onDestroy()
|
|
}
|
|
}
|
|
|
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
post(procLoadImage)
|
|
post(procFocusPoint)
|
|
}
|
|
|
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
super.onLayout(changed, left, top, right, bottom)
|
|
post(procLoadImage)
|
|
}
|
|
|
|
override fun onDetachedFromWindow() {
|
|
cancelLoading(null)
|
|
super.onDetachedFromWindow()
|
|
}
|
|
|
|
override fun onAttachedToWindow() {
|
|
super.onAttachedToWindow()
|
|
loadImageIfNecessary()
|
|
}
|
|
|
|
override fun drawableStateChanged() {
|
|
super.drawableStateChanged()
|
|
invalidate()
|
|
}
|
|
|
|
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
|
super.onVisibilityChanged(changedView, visibility)
|
|
loadImageIfNecessary()
|
|
}
|
|
|
|
fun setMediaType(drawableId: Int) {
|
|
if (drawableId == 0) {
|
|
mediaTypeDrawable1 = null
|
|
} else {
|
|
mediaTypeDrawable1 = ContextCompat.getDrawable(context, drawableId)?.mutate()
|
|
// DisplayMetrics dm = getResources().getDisplayMetrics();
|
|
mediaTypeBottom = 0
|
|
mediaTypeLeft = 0
|
|
}
|
|
invalidate()
|
|
}
|
|
|
|
override fun onDraw(canvas: Canvas) {
|
|
|
|
// bitmapがrecycledされた場合に例外をキャッチする
|
|
try {
|
|
super.onDraw(canvas)
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "onDraw failed.")
|
|
}
|
|
|
|
// media type の描画
|
|
val mediaTypeDrawable = this.mediaTypeDrawable1
|
|
if (mediaTypeDrawable != null) {
|
|
val drawableW = mediaTypeDrawable.intrinsicWidth
|
|
val drawableH = mediaTypeDrawable.intrinsicHeight
|
|
// int view_w = getWidth();
|
|
val viewH = height
|
|
mediaTypeDrawable.setBounds(
|
|
0,
|
|
viewH - drawableH,
|
|
drawableW,
|
|
viewH
|
|
)
|
|
mediaTypeDrawable.draw(canvas)
|
|
}
|
|
}
|
|
|
|
/////////////////////////////////////////////////////////////////////
|
|
|
|
// プロフ表示の背景画像のレイアウト崩れの対策
|
|
var measureProfileBg = false
|
|
|
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
if (measureProfileBg) {
|
|
// このモードではコンテンツを一切見ずにサイズを決める
|
|
val wSize = MeasureSpec.getSize(widthMeasureSpec)
|
|
val wMeasured = when (MeasureSpec.getMode(widthMeasureSpec)) {
|
|
MeasureSpec.EXACTLY -> wSize
|
|
MeasureSpec.AT_MOST -> wSize
|
|
MeasureSpec.UNSPECIFIED -> 0
|
|
else -> 0
|
|
}
|
|
val hSize = MeasureSpec.getSize(heightMeasureSpec)
|
|
val hMeasured = when (MeasureSpec.getMode(heightMeasureSpec)) {
|
|
MeasureSpec.EXACTLY -> hSize
|
|
MeasureSpec.AT_MOST -> hSize
|
|
MeasureSpec.UNSPECIFIED -> 0
|
|
else -> 0
|
|
}
|
|
setMeasuredDimension(wMeasured, hMeasured)
|
|
} else {
|
|
// 通常のImageViewは内容を見てサイズを決める
|
|
// たとえLayoutParamがw,hともmatchParentでも内容を見てしまう
|
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
|
}
|
|
}
|
|
|
|
/////////////////////////////////////////////////////////////////////
|
|
|
|
private var focusX: Float = 0f
|
|
private var focusY: Float = 0f
|
|
|
|
fun setFocusPoint(focusX: Float, focusY: Float) {
|
|
// フォーカスポイントは上がプラスで下がマイナス
|
|
// https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point
|
|
// このタイミングで正規化してしまう
|
|
|
|
this.focusX = focusX.clip(-1f, 1f)
|
|
this.focusY = focusY.clip(-1f, 1f).times(-1)
|
|
}
|
|
|
|
override fun setImageBitmap(bm: Bitmap?) {
|
|
super.setImageBitmap(bm)
|
|
updateFocusPoint()
|
|
}
|
|
|
|
override fun setImageDrawable(drawable: Drawable?) {
|
|
super.setImageDrawable(drawable)
|
|
updateFocusPoint()
|
|
}
|
|
|
|
private fun updateFocusPoint() {
|
|
|
|
// ビューのサイズが0より大きい
|
|
val viewW = width.toFloat()
|
|
val viewH = height.toFloat()
|
|
if (viewW <= 0f || viewH <= 0f) return
|
|
|
|
// 画像のサイズが0より大きい
|
|
val drawable = this.drawable ?: return
|
|
val drawableW = drawable.intrinsicWidth.toFloat()
|
|
val drawableH = drawable.intrinsicHeight.toFloat()
|
|
if (drawableW <= 0f || drawableH <= 0f) return
|
|
|
|
when (scaleType) {
|
|
ScaleType.CENTER_CROP, ScaleType.MATRIX -> {
|
|
val viewAspect = viewW / viewH
|
|
val drawableAspect = drawableW / drawableH
|
|
|
|
if (drawableAspect >= viewAspect) {
|
|
// ビューより画像の方が横長
|
|
val focusX1 = this.focusX
|
|
if (focusX1 == 0f) {
|
|
scaleType = ScaleType.CENTER_CROP
|
|
} else {
|
|
val matrix = Matrix()
|
|
val scale = viewH / drawableH
|
|
val delta = focusX1 * ((drawableW * scale) - viewW)
|
|
log.d("updateFocusPoint x delta=$delta")
|
|
matrix.postTranslate(drawableW / -2f, drawableH / -2f)
|
|
matrix.postScale(scale, scale)
|
|
matrix.postTranslate((viewW - delta) / 2f, viewH / 2f)
|
|
scaleType = ScaleType.MATRIX
|
|
imageMatrix = matrix
|
|
}
|
|
} else {
|
|
// ビューより画像の方が縦長
|
|
val focusY1 = this.focusY
|
|
if (focusY1 == 0f) {
|
|
scaleType = ScaleType.CENTER_CROP
|
|
} else {
|
|
val matrix = Matrix()
|
|
val scale = viewW / drawableW
|
|
val delta = focusY1 * ((drawableH * scale) - viewH)
|
|
matrix.postTranslate(drawableW / -2f, drawableH / -2f)
|
|
matrix.postScale(scale, scale)
|
|
matrix.postTranslate(viewW / 2f, (viewH - delta) / 2f)
|
|
scaleType = ScaleType.MATRIX
|
|
imageMatrix = matrix
|
|
}
|
|
}
|
|
}
|
|
|
|
else -> {
|
|
// not supported.
|
|
}
|
|
}
|
|
}
|
|
|
|
fun setScaleTypeForMedia() {
|
|
when (scaleType) {
|
|
ScaleType.CENTER_CROP, ScaleType.MATRIX -> {
|
|
// nothing to do
|
|
}
|
|
|
|
else -> {
|
|
scaleType = ScaleType.CENTER_CROP
|
|
}
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private val log = LogCategory("MyNetworkImageView")
|
|
private val listener = MyRequestListener()
|
|
private val misskey13ModelLoader = Misskey13ModelLoader()
|
|
}
|
|
|
|
class MyRequestListener : RequestListener<Drawable> {
|
|
override fun onResourceReady(
|
|
resource: Drawable,
|
|
model: Any?,
|
|
target: Target<Drawable>?,
|
|
dataSource: DataSource?,
|
|
isFirstResource: Boolean,
|
|
): Boolean {
|
|
return false // Allow calling onResourceReady on the Target.
|
|
}
|
|
|
|
override fun onLoadFailed(
|
|
e: GlideException?,
|
|
model: Any?,
|
|
target: Target<Drawable>?,
|
|
isFirstResource: Boolean,
|
|
): Boolean {
|
|
e?.let {
|
|
log.e(it, "onLoadFailed")
|
|
it.rootCauses?.forEach { cause ->
|
|
val message = cause?.message
|
|
when {
|
|
cause == null -> Unit
|
|
message?.contains("setDataSource failed: status") == true ||
|
|
message?.contains("etDataSourceCallback failed: status") == true
|
|
-> log.w(message)
|
|
else -> log.e(cause, "caused by")
|
|
}
|
|
}
|
|
}
|
|
return false // Allow calling onLoadFailed on the Target.
|
|
}
|
|
}
|
|
|
|
class Misskey13ModelLoader : ModelLoader<String?, ByteBuffer> {
|
|
override fun handles(model: String): Boolean {
|
|
return model.startsWith("http") &&
|
|
model.contains(".webp?")
|
|
}
|
|
|
|
override fun buildLoadData(
|
|
model: String,
|
|
width: Int,
|
|
height: Int,
|
|
options: Options,
|
|
): LoadData<ByteBuffer>? {
|
|
TODO("Not yet implemented")
|
|
}
|
|
}
|
|
}
|
|
|