992 lines
35 KiB
Kotlin
992 lines
35 KiB
Kotlin
package jp.juggler.subwaytooter
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.app.DownloadManager
|
|
import android.content.ClipData
|
|
import android.content.ClipDescription
|
|
import android.content.ClipboardManager
|
|
import android.content.Intent
|
|
import android.graphics.*
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.os.Environment
|
|
import android.os.SystemClock
|
|
import android.view.View
|
|
import android.view.Window
|
|
import androidx.annotation.OptIn
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.core.net.toUri
|
|
import androidx.media3.common.MediaItem
|
|
import androidx.media3.common.PlaybackException
|
|
import androidx.media3.common.Player
|
|
import androidx.media3.common.Timeline
|
|
import androidx.media3.common.util.RepeatModeUtil
|
|
import androidx.media3.common.util.UnstableApi
|
|
import androidx.media3.exoplayer.ExoPlayer
|
|
import androidx.media3.exoplayer.source.LoadEventInfo
|
|
import androidx.media3.exoplayer.source.MediaLoadData
|
|
import androidx.media3.exoplayer.source.MediaSource
|
|
import androidx.media3.exoplayer.source.MediaSourceEventListener
|
|
import jp.juggler.subwaytooter.api.*
|
|
import jp.juggler.subwaytooter.api.entity.*
|
|
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachmentJson
|
|
import jp.juggler.subwaytooter.databinding.ActMediaViewerBinding
|
|
import jp.juggler.subwaytooter.dialog.actionsDialog
|
|
import jp.juggler.subwaytooter.drawable.MediaBackgroundDrawable
|
|
import jp.juggler.subwaytooter.itemviewholder.reUrlGif
|
|
import jp.juggler.subwaytooter.pref.PrefI
|
|
import jp.juggler.subwaytooter.util.permissionSpecMediaDownload
|
|
import jp.juggler.subwaytooter.util.requester
|
|
import jp.juggler.subwaytooter.view.PinchBitmapView
|
|
import jp.juggler.util.*
|
|
import jp.juggler.util.coroutine.launchAndShowError
|
|
import jp.juggler.util.coroutine.launchMain
|
|
import jp.juggler.util.data.*
|
|
import jp.juggler.util.log.LogCategory
|
|
import jp.juggler.util.log.dialogOrToast
|
|
import jp.juggler.util.log.showToast
|
|
import jp.juggler.util.log.withCaption
|
|
import jp.juggler.util.media.imageOrientation
|
|
import jp.juggler.util.media.resolveOrientation
|
|
import jp.juggler.util.media.rotateSize
|
|
import jp.juggler.util.network.MySslSocketFactory
|
|
import jp.juggler.util.ui.*
|
|
import kotlinx.coroutines.CancellationException
|
|
import kotlinx.coroutines.yield
|
|
import okhttp3.Request
|
|
import java.io.ByteArrayInputStream
|
|
import java.io.IOException
|
|
import java.nio.charset.StandardCharsets
|
|
import java.util.*
|
|
import javax.net.ssl.HttpsURLConnection
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
|
|
class ActMediaViewer : AppCompatActivity(), View.OnClickListener {
|
|
|
|
companion object {
|
|
|
|
internal val log = LogCategory("ActMediaViewer")
|
|
|
|
internal val download_history_list = LinkedList<DownloadHistory>()
|
|
internal const val DOWNLOAD_REPEAT_EXPIRE = 3000L
|
|
internal const val short_limit = 5000L
|
|
|
|
internal const val EXTRA_IDX = "idx"
|
|
internal const val EXTRA_DATA = "data"
|
|
internal const val EXTRA_SERVICE_TYPE = "serviceType"
|
|
internal const val EXTRA_SHOW_DESCRIPTION = "showDescription"
|
|
|
|
internal const val STATE_PLAYER_POS = "playerPos"
|
|
internal const val STATE_PLAYER_PLAY_WHEN_READY = "playerPlayWhenReady"
|
|
internal const val STATE_LAST_VOLUME = "lastVolume"
|
|
|
|
internal fun <T : TootAttachmentLike> encodeMediaList(list: ArrayList<T>?) =
|
|
list?.encodeJson()?.toString() ?: "[]"
|
|
|
|
internal fun decodeMediaList(src: String?) =
|
|
ArrayList<TootAttachment>().apply {
|
|
src?.decodeJsonArray()?.objectList()
|
|
?.map { tootAttachmentJson(it) }
|
|
?.let { addAll(it) }
|
|
}
|
|
|
|
fun open(
|
|
activity: ActMain,
|
|
showDescription: Boolean,
|
|
serviceType: ServiceType,
|
|
list: ArrayList<TootAttachmentLike>,
|
|
idx: Int,
|
|
) {
|
|
val intent = Intent(activity, ActMediaViewer::class.java)
|
|
intent.putExtra(EXTRA_IDX, idx)
|
|
intent.putExtra(EXTRA_SERVICE_TYPE, serviceType.ordinal)
|
|
intent.putExtra(EXTRA_DATA, encodeMediaList(list))
|
|
intent.putExtra(EXTRA_SHOW_DESCRIPTION, showDescription)
|
|
activity.startActivity(intent)
|
|
|
|
activity.overrideActivityTransitionCompat(
|
|
TransitionOverrideType.Open,
|
|
R.anim.slide_from_bottom,
|
|
android.R.anim.fade_out,
|
|
)
|
|
}
|
|
|
|
private fun checkMaxBitmapSize(): Int {
|
|
var bitsMin = 10 // 1024 px
|
|
var bitsMax = 16 // 65536 px
|
|
while (bitsMax > bitsMin) {
|
|
val bitsMid = (bitsMin + bitsMax + 1).shr(1)
|
|
val px = 1.shl(bitsMid)
|
|
val canCreate = try {
|
|
val bitmap = Bitmap.createBitmap(px, px, Bitmap.Config.ARGB_8888)
|
|
bitmap.recycle()
|
|
log.i("checkMaxBitmapSize: range=$bitsMin..$bitsMid..$bitsMax, px=${px}, canCreate=true")
|
|
true
|
|
} catch (ex: Throwable) {
|
|
log.i(ex.withCaption("checkMaxBitmapSize: range=$bitsMin..$bitsMid..$bitsMax, px=${px}, canCreate=false"))
|
|
false
|
|
}
|
|
when {
|
|
canCreate ->
|
|
bitsMin = bitsMid
|
|
|
|
else ->
|
|
bitsMax = bitsMid - 1
|
|
}
|
|
}
|
|
val resolved = 1.shl(bitsMin)
|
|
log.w("checkMaxBitmapSize: resolved=$resolved")
|
|
return min(8192, resolved)
|
|
}
|
|
|
|
private val maxBitmapSize by lazy {
|
|
checkMaxBitmapSize()
|
|
}
|
|
}
|
|
|
|
class DownloadHistory(val time: Long, val url: String)
|
|
|
|
internal var idx: Int = 0
|
|
private lateinit var mediaList: ArrayList<TootAttachment>
|
|
private lateinit var serviceType: ServiceType
|
|
private var showDescription = true
|
|
|
|
private val views by lazy {
|
|
ActMediaViewerBinding.inflate(layoutInflater)
|
|
}
|
|
|
|
private lateinit var exoPlayer: ExoPlayer
|
|
|
|
private var lastVolume = Float.NaN
|
|
|
|
internal var bufferingLastShown: Long = 0
|
|
|
|
private var lastVideoUrl: String? = null
|
|
|
|
private val tileStep by lazy {
|
|
val density = resources.displayMetrics.density
|
|
(density * 12f + 0.5f).toInt()
|
|
}
|
|
|
|
private var originalWidth = 0
|
|
private var originalHeight = 0
|
|
|
|
private val playerListener = object : Player.Listener {
|
|
|
|
override fun onTimelineChanged(
|
|
timeline: Timeline,
|
|
@Player.TimelineChangeReason reason: Int,
|
|
) {
|
|
log.d("exoPlayer onTimelineChanged reason=$reason")
|
|
}
|
|
|
|
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
|
}
|
|
|
|
private fun showBufferingToast() {
|
|
val playWhenReady = exoPlayer.playWhenReady
|
|
val playbackState = exoPlayer.playbackState
|
|
if (playWhenReady && playbackState == Player.STATE_BUFFERING) {
|
|
val now = SystemClock.elapsedRealtime()
|
|
if (now - bufferingLastShown >= short_limit && exoPlayer.duration >= short_limit) {
|
|
bufferingLastShown = now
|
|
showToast(false, R.string.video_buffering)
|
|
}
|
|
/*
|
|
exoPlayer.getDuration() may returns negative value (TIME_UNSET ,same as Long.MIN_VALUE + 1).
|
|
*/
|
|
}
|
|
}
|
|
|
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
showBufferingToast()
|
|
}
|
|
|
|
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
|
log.d("onPlayWhenReadyChanged playWhenReady=$playWhenReady, reason=$reason")
|
|
showBufferingToast()
|
|
}
|
|
|
|
override fun onRepeatModeChanged(repeatMode: Int) {
|
|
log.d("exoPlayer onRepeatModeChanged $repeatMode")
|
|
}
|
|
|
|
override fun onPlayerError(error: PlaybackException) {
|
|
log.w(error, "exoPlayer onPlayerError")
|
|
if (recoverLocalVideo()) return
|
|
showToast(error, "exoPlayer onPlayerError")
|
|
}
|
|
|
|
override fun onPositionDiscontinuity(
|
|
oldPosition: Player.PositionInfo,
|
|
newPosition: Player.PositionInfo,
|
|
reason: Int,
|
|
) {
|
|
log.d("exoPlayer onPositionDiscontinuity reason=$reason, oldPosition=$oldPosition, newPosition=$newPosition")
|
|
}
|
|
}
|
|
|
|
@UnstableApi
|
|
private val mediaSourceEventListener = object : MediaSourceEventListener {
|
|
override fun onLoadStarted(
|
|
windowIndex: Int,
|
|
mediaPeriodId: MediaSource.MediaPeriodId?,
|
|
loadEventInfo: LoadEventInfo,
|
|
mediaLoadData: MediaLoadData,
|
|
) {
|
|
log.d("onLoadStarted")
|
|
}
|
|
|
|
override fun onDownstreamFormatChanged(
|
|
windowIndex: Int,
|
|
mediaPeriodId: MediaSource.MediaPeriodId?,
|
|
mediaLoadData: MediaLoadData,
|
|
) {
|
|
log.d("onDownstreamFormatChanged")
|
|
}
|
|
|
|
override fun onUpstreamDiscarded(
|
|
windowIndex: Int,
|
|
mediaPeriodId: MediaSource.MediaPeriodId,
|
|
mediaLoadData: MediaLoadData,
|
|
) {
|
|
log.d("onUpstreamDiscarded")
|
|
}
|
|
|
|
override fun onLoadCompleted(
|
|
windowIndex: Int,
|
|
mediaPeriodId: MediaSource.MediaPeriodId?,
|
|
loadEventInfo: LoadEventInfo,
|
|
mediaLoadData: MediaLoadData,
|
|
) {
|
|
log.d("onLoadCompleted")
|
|
}
|
|
|
|
override fun onLoadCanceled(
|
|
windowIndex: Int,
|
|
mediaPeriodId: MediaSource.MediaPeriodId?,
|
|
loadEventInfo: LoadEventInfo,
|
|
mediaLoadData: MediaLoadData,
|
|
) {
|
|
log.d("onLoadCanceled")
|
|
}
|
|
|
|
override fun onLoadError(
|
|
windowIndex: Int,
|
|
mediaPeriodId: MediaSource.MediaPeriodId?,
|
|
loadEventInfo: LoadEventInfo,
|
|
mediaLoadData: MediaLoadData,
|
|
error: IOException,
|
|
wasCanceled: Boolean,
|
|
) {
|
|
showError(error.withCaption("load error."))
|
|
}
|
|
}
|
|
|
|
private val prDownload = permissionSpecMediaDownload.requester { download(mediaList[idx]) }
|
|
|
|
override fun onSaveInstanceState(outState: Bundle) {
|
|
super.onSaveInstanceState(outState)
|
|
|
|
log.d("onSaveInstanceState")
|
|
|
|
outState.putInt(EXTRA_IDX, idx)
|
|
outState.putInt(EXTRA_SERVICE_TYPE, serviceType.ordinal)
|
|
outState.putString(EXTRA_DATA, encodeMediaList(mediaList))
|
|
|
|
outState.putLong(STATE_PLAYER_POS, exoPlayer.currentPosition)
|
|
outState.putBoolean(STATE_PLAYER_PLAY_WHEN_READY, exoPlayer.playWhenReady)
|
|
outState.putFloat(STATE_LAST_VOLUME, lastVolume)
|
|
}
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
|
|
super.onCreate(savedInstanceState)
|
|
prDownload.register(this)
|
|
App1.setActivityTheme(this, forceDark = true)
|
|
|
|
this.showDescription = intent.getBooleanExtra(EXTRA_SHOW_DESCRIPTION, showDescription)
|
|
|
|
this.serviceType = ServiceType.entries[
|
|
savedInstanceState?.int(EXTRA_SERVICE_TYPE)
|
|
?: intent.int(EXTRA_SERVICE_TYPE) ?: 0
|
|
]
|
|
|
|
this.mediaList = decodeMediaList(
|
|
savedInstanceState?.getString(EXTRA_DATA)
|
|
?: intent.string(EXTRA_DATA)
|
|
)
|
|
|
|
this.idx = (savedInstanceState?.int(EXTRA_IDX) ?: intent.int(EXTRA_IDX))
|
|
?.takeIf { it in mediaList.indices } ?: 0
|
|
|
|
initUI()
|
|
|
|
load(savedInstanceState)
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
views.pbvImage.setBitmap(null)
|
|
exoPlayer.release()
|
|
}
|
|
|
|
override fun onStart() {
|
|
super.onStart()
|
|
views.pvVideo.onResume()
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
views.pvVideo.onPause()
|
|
}
|
|
|
|
override fun finish() {
|
|
super.finish()
|
|
overrideActivityTransitionCompat(
|
|
TransitionOverrideType.Close,
|
|
R.anim.fade_in,
|
|
R.anim.slide_to_bottom,
|
|
)
|
|
}
|
|
|
|
@OptIn(UnstableApi::class)
|
|
private fun initUI() {
|
|
setContentView(views.root)
|
|
|
|
views.pbvImage.background = MediaBackgroundDrawable(
|
|
context = views.root.context,
|
|
tileStep = tileStep,
|
|
kind = MediaBackgroundDrawable.Kind.fromIndex(PrefI.ipMediaBackground.value)
|
|
)
|
|
|
|
val enablePaging = mediaList.size > 1
|
|
views.btnPrevious.isEnabledAlpha = enablePaging
|
|
views.btnNext.isEnabledAlpha = enablePaging
|
|
|
|
views.btnPrevious.setOnClickListener(this)
|
|
views.btnNext.setOnClickListener(this)
|
|
findViewById<View>(R.id.btnDownload).setOnClickListener(this)
|
|
findViewById<View>(R.id.btnMore).setOnClickListener(this)
|
|
|
|
views.cbMute.setOnCheckedChangeListener { _, isChecked ->
|
|
if (isChecked) {
|
|
// mute
|
|
lastVolume = exoPlayer.volume
|
|
exoPlayer.volume = 0f
|
|
} else {
|
|
// unmute
|
|
exoPlayer.volume = when {
|
|
lastVolume.isNaN() -> 1f
|
|
lastVolume <= 0f -> 1f
|
|
else -> lastVolume
|
|
}
|
|
lastVolume = Float.NaN
|
|
}
|
|
}
|
|
|
|
views.pbvImage.setCallback(object : PinchBitmapView.Callback {
|
|
override fun onSwipe(deltaX: Int, deltaY: Int) {
|
|
if (isDestroyed) return
|
|
if (deltaX != 0) {
|
|
loadDelta(deltaX)
|
|
} else {
|
|
log.d("finish by vertical swipe")
|
|
finish()
|
|
}
|
|
}
|
|
|
|
override fun onMove(
|
|
bitmapW: Float,
|
|
bitmapH: Float,
|
|
tx: Float,
|
|
ty: Float,
|
|
scale: Float,
|
|
) {
|
|
App1.getAppState(this@ActMediaViewer).handler.post {
|
|
showZoom(bitmapW.toInt(), bitmapH.toInt(), scale)
|
|
}
|
|
}
|
|
})
|
|
|
|
exoPlayer = ExoPlayer.Builder(this).build()
|
|
exoPlayer.addListener(playerListener)
|
|
|
|
views.pvVideo.run {
|
|
player = exoPlayer
|
|
controllerAutoShow = false
|
|
setShowRewindButton(false)
|
|
setShowFastForwardButton(false)
|
|
setShowPreviousButton(false)
|
|
setShowNextButton(false)
|
|
setRepeatToggleModes(RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE)
|
|
}
|
|
|
|
views.wvOther.apply {
|
|
scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY
|
|
settings.apply {
|
|
@SuppressLint("SetJavaScriptEnabled")
|
|
javaScriptEnabled = true
|
|
loadWithOverviewMode = true
|
|
useWideViewPort = true
|
|
setSupportZoom(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
internal fun loadDelta(delta: Int) {
|
|
if (mediaList.size < 2) return
|
|
val size = mediaList.size
|
|
idx = (idx + size + delta) % size
|
|
load()
|
|
}
|
|
|
|
internal fun load(state: Bundle? = null) {
|
|
|
|
exoPlayer.stop()
|
|
|
|
// いったんすべて隠す
|
|
views.run {
|
|
wvOther.gone()
|
|
pbvImage.gone()
|
|
pvVideo.gone()
|
|
tvError.gone()
|
|
svDescription.gone()
|
|
tvStatus.gone()
|
|
}
|
|
|
|
if (idx < 0 || idx >= mediaList.size) {
|
|
showError(getString(R.string.media_attachment_empty))
|
|
return
|
|
}
|
|
val ta = mediaList[idx]
|
|
val description = ta.description
|
|
if (showDescription && description?.isNotEmpty() == true) {
|
|
views.svDescription.visible()
|
|
views.tvDescription.text = description
|
|
}
|
|
|
|
when (ta.type) {
|
|
TootAttachmentType.Unknown,
|
|
-> loadOther(ta) // showError(getString(R.string.media_attachment_type_error, ta.type.id))
|
|
|
|
TootAttachmentType.Image -> when {
|
|
reUrlGif.containsMatchIn(ta.remote_url ?: "") ->
|
|
loadOther(ta)
|
|
|
|
else ->
|
|
loadBitmap(ta)
|
|
}
|
|
|
|
TootAttachmentType.Video,
|
|
TootAttachmentType.GIFV,
|
|
TootAttachmentType.Audio,
|
|
-> loadVideo(ta, state)
|
|
}
|
|
}
|
|
|
|
private fun showError(message: String) {
|
|
views.run {
|
|
pvVideo.gone()
|
|
pbvImage.gone()
|
|
tvError.visible().text = message
|
|
}
|
|
}
|
|
|
|
private fun loadVideo(
|
|
ta: TootAttachment,
|
|
state: Bundle? = null,
|
|
forceLocalUrl: Boolean = false,
|
|
) {
|
|
|
|
views.cbMute.visible().run {
|
|
if (isChecked && lastVolume.isFinite()) {
|
|
exoPlayer.volume = 0f
|
|
}
|
|
}
|
|
|
|
val url = when {
|
|
forceLocalUrl -> ta.url
|
|
else -> ta.getLargeUrl()
|
|
}
|
|
if (url == null) {
|
|
showError("missing media attachment url.")
|
|
return
|
|
}
|
|
val uri = url.mayUri()
|
|
if (uri == null) {
|
|
showError("can't parse URI: $url")
|
|
return
|
|
}
|
|
lastVideoUrl = url
|
|
|
|
// https://github.com/google/ExoPlayer/issues/1819
|
|
HttpsURLConnection.setDefaultSSLSocketFactory(MySslSocketFactory)
|
|
views.pvVideo.visibility = View.VISIBLE
|
|
exoPlayer.setMediaItem(MediaItem.fromUri(uri))
|
|
exoPlayer.prepare()
|
|
exoPlayer.repeatMode = when (ta.type) {
|
|
TootAttachmentType.Video -> Player.REPEAT_MODE_OFF
|
|
// GIFV or AUDIO
|
|
else -> Player.REPEAT_MODE_ALL
|
|
}
|
|
if (state == null) {
|
|
exoPlayer.playWhenReady = true
|
|
} else {
|
|
exoPlayer.playWhenReady = state.getBoolean(STATE_PLAYER_PLAY_WHEN_READY, true)
|
|
exoPlayer.seekTo(max(0L, state.getLong(STATE_PLAYER_POS, 0L)))
|
|
lastVolume = state.getFloat(STATE_LAST_VOLUME, 1f)
|
|
}
|
|
}
|
|
|
|
private fun decodeBitmap(
|
|
options: BitmapFactory.Options,
|
|
data: ByteArray,
|
|
@Suppress("SameParameterValue") pixelMax: Int = maxBitmapSize,
|
|
): Pair<Bitmap?, String?> {
|
|
|
|
val orientation: Int? = ByteArrayInputStream(data).imageOrientation()
|
|
|
|
// detects image size
|
|
options.inJustDecodeBounds = true
|
|
options.inScaled = false
|
|
options.outWidth = 0
|
|
options.outHeight = 0
|
|
BitmapFactory.decodeByteArray(data, 0, data.size, options)
|
|
var w = options.outWidth
|
|
var h = options.outHeight
|
|
if (w <= 0 || h <= 0) {
|
|
return Pair(null, "can't decode image bounds.")
|
|
}
|
|
originalWidth = w
|
|
originalHeight = h
|
|
|
|
// calc bits to reduce size
|
|
var bits = 0
|
|
while (w > pixelMax || h > pixelMax) {
|
|
++bits
|
|
w = w shr 1
|
|
h = h shr 1
|
|
}
|
|
options.inJustDecodeBounds = false
|
|
options.inSampleSize = 1 shl bits
|
|
|
|
// decode image
|
|
val bitmap1 = BitmapFactory.decodeByteArray(data, 0, data.size, options)
|
|
?: return Pair(null, "BitmapFactory.decodeByteArray returns null.")
|
|
|
|
val srcWidth = bitmap1.width.toFloat()
|
|
val srcHeight = bitmap1.height.toFloat()
|
|
if (srcWidth <= 0f || srcHeight <= 0f) {
|
|
bitmap1.recycle()
|
|
return Pair(null, "image size <= 0")
|
|
}
|
|
|
|
val dstSize = rotateSize(orientation, srcWidth, srcHeight)
|
|
val dstSizeInt = Point(
|
|
max(1, (dstSize.x + 0.5f).toInt()),
|
|
max(1, (dstSize.y + 0.5f).toInt())
|
|
)
|
|
|
|
// 回転行列を作る
|
|
val matrix = Matrix()
|
|
matrix.reset()
|
|
|
|
// 画像の中心が原点に来るようにして
|
|
matrix.postTranslate(srcWidth * -0.5f, srcHeight * -0.5f)
|
|
|
|
// orientationに合わせた回転指定
|
|
matrix.resolveOrientation(orientation)
|
|
|
|
// 表示領域に埋まるように平行移動
|
|
matrix.postTranslate(dstSize.x * 0.5f, dstSize.y * 0.5f)
|
|
|
|
// 回転後の画像
|
|
val bitmap2 = try {
|
|
Bitmap.createBitmap(dstSizeInt.x, dstSizeInt.y, Bitmap.Config.ARGB_8888)
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "createBitmap failed.")
|
|
return Pair(bitmap1, ex.withCaption("createBitmap failed."))
|
|
}
|
|
|
|
try {
|
|
Canvas(bitmap2).drawBitmap(
|
|
bitmap1,
|
|
matrix,
|
|
Paint().apply { isFilterBitmap = true }
|
|
)
|
|
} catch (ex: Throwable) {
|
|
bitmap2.recycle()
|
|
log.e(ex, "drawBitmap failed.")
|
|
return Pair(bitmap1, ex.withCaption("drawBitmap failed."))
|
|
}
|
|
|
|
try {
|
|
bitmap1.recycle()
|
|
} catch (ignored: Throwable) {
|
|
}
|
|
return Pair(bitmap2, null)
|
|
}
|
|
|
|
private fun loadOther(ta: TootAttachment) {
|
|
|
|
val urlList = ta.getLargeUrlList()
|
|
if (urlList.isEmpty()) {
|
|
showError("missing media attachment url.")
|
|
return
|
|
}
|
|
val url = urlList.first()
|
|
views.run {
|
|
cbMute.gone()
|
|
tvStatus.visible().text = "${ta.type.id} ${url.ellipsizeDot3(100)}"
|
|
wvOther.visible().loadUrl(url)
|
|
}
|
|
}
|
|
|
|
private fun loadBitmap(ta: TootAttachment) {
|
|
|
|
views.run {
|
|
cbMute.gone()
|
|
tvStatus.visible().text = null
|
|
pbvImage.visible().setBitmap(null)
|
|
}
|
|
|
|
val urlList = ta.getLargeUrlList()
|
|
if (urlList.isEmpty()) {
|
|
showError("missing media attachment url.")
|
|
return
|
|
}
|
|
|
|
launchMain {
|
|
try {
|
|
val errors = ArrayList<String>()
|
|
val bitmap = runApiTask2(progressStyle = ApiTask.PROGRESS_HORIZONTAL) { client ->
|
|
if (urlList.isEmpty()) {
|
|
errors.add("missing url(s)")
|
|
}
|
|
val options = BitmapFactory.Options()
|
|
for (url in urlList) {
|
|
try {
|
|
val ba = Request.Builder()
|
|
.url(url)
|
|
.cacheControl(App1.CACHE_CONTROL)
|
|
.addHeader("Accept", "image/webp,image/*,*/*;q=0.8")
|
|
.build()
|
|
.send(
|
|
client,
|
|
errorSuffix = url,
|
|
overrideClient = App1.ok_http_client_media_viewer
|
|
)
|
|
.readBytes { bytesRead, bytesTotal ->
|
|
// 50MB以上のデータはキャンセルする
|
|
if (max(bytesRead, bytesTotal) >= 50000000) {
|
|
error("media attachment is larger than 50000000")
|
|
}
|
|
client.publishApiProgressRatio(
|
|
bytesRead.toInt(),
|
|
bytesTotal.toInt()
|
|
)
|
|
}
|
|
client.publishApiProgress("decoding image…")
|
|
val (b, error) = decodeBitmap(options, ba)
|
|
if (b != null) return@runApiTask2 b
|
|
if (error != null) errors.add(error)
|
|
} catch (ex: Throwable) {
|
|
if (ex is CancellationException) break
|
|
errors.add("load error. ${ex.withCaption()} url=$url")
|
|
}
|
|
}
|
|
return@runApiTask2 null
|
|
}
|
|
when {
|
|
bitmap != null -> views.pbvImage.setBitmap(bitmap)
|
|
else -> errors.notEmpty()?.let { dialogOrToast(it.joinToString("\n")) }
|
|
}
|
|
} catch (ex: Throwable) {
|
|
showApiError(ex)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onClick(v: View) {
|
|
try {
|
|
when (v) {
|
|
views.btnPrevious -> loadDelta(-1)
|
|
views.btnNext -> loadDelta(+1)
|
|
views.btnDownload -> download(mediaList[idx])
|
|
views.btnMore -> more(mediaList[idx])
|
|
}
|
|
} catch (ex: Throwable) {
|
|
showToast(ex, "action failed.")
|
|
}
|
|
}
|
|
|
|
private fun download(ta: TootAttachmentLike) {
|
|
if (!prDownload.checkOrLaunch()) return
|
|
|
|
val downLoadManager: DownloadManager = systemService(this)
|
|
?: error("missing DownloadManager system service")
|
|
|
|
val url = if (ta is TootAttachment) {
|
|
ta.getLargeUrl()
|
|
} else {
|
|
null
|
|
} ?: return
|
|
|
|
// ボタン連打対策
|
|
run {
|
|
val now = SystemClock.elapsedRealtime()
|
|
|
|
// 期限切れの履歴を削除
|
|
val it = download_history_list.iterator()
|
|
while (it.hasNext()) {
|
|
val dh = it.next()
|
|
if (now - dh.time >= DOWNLOAD_REPEAT_EXPIRE) {
|
|
// この履歴は十分に古いので捨てる
|
|
it.remove()
|
|
} else if (url == dh.url) {
|
|
// 履歴に同じURLがあればエラーとする
|
|
showToast(false, R.string.dont_repeat_download_to_same_url)
|
|
return
|
|
}
|
|
}
|
|
// 履歴の末尾に追加(履歴は古い順に並ぶ)
|
|
download_history_list.addLast(DownloadHistory(now, url))
|
|
}
|
|
|
|
/**
|
|
* Linuxはフォルダ中のファイルの名前の上限が255バイトと決まっている。
|
|
* その上限に収まるように文字を切りたいが、それはUTF-8の区切りを考慮する必要がある。
|
|
*/
|
|
fun shortenName(src: String, limitBytes: Int): String {
|
|
val bytes = src.encodeUTF8()
|
|
if (bytes.size <= limitBytes) return src
|
|
// 制限バイト数の終端の一つ先
|
|
var pos = limitBytes
|
|
while (pos >= 0) {
|
|
if (bytes[pos].toInt().and(0x80) == 0) {
|
|
// 現在位置がUTF-8の後続ではないなら、その手前までを返す
|
|
return String(bytes, 0, pos, StandardCharsets.UTF_8)
|
|
}
|
|
// 現在位置はUTF-8の文字の2バイト目以降なので、この手前で切ると文字が壊れる
|
|
--pos
|
|
}
|
|
// UTF-8表現がおかしい
|
|
return "media"
|
|
}
|
|
|
|
var fileName = url.mayUri()?.pathSegments?.findLast { !it.isNullOrBlank() }
|
|
?: url.replaceFirst("https?://".asciiRegex(), "")
|
|
|
|
// Windowsでファイル名に使えない文字を避ける
|
|
fileName = """[\\/|"<>?*:-]+""".toRegex().replace(fileName, "-")
|
|
|
|
// 末尾から20文字以内にある最初のドット
|
|
val extDotPos = fileName.indexOf('.', startIndex = max(0, fileName.length - 20))
|
|
val fileNameMaxBytes = 255
|
|
fileName = if (extDotPos == -1) {
|
|
// 拡張子なし
|
|
shortenName(fileName, fileNameMaxBytes)
|
|
} else {
|
|
// 拡張子の手前だけを短縮する
|
|
val extPart = fileName.substring(extDotPos)
|
|
val extPartBytes = extPart.encodeUTF8().size
|
|
val namePart = shortenName(
|
|
fileName.substring(0, extDotPos),
|
|
fileNameMaxBytes - extPartBytes
|
|
)
|
|
"$namePart$extPart"
|
|
}
|
|
|
|
val request = DownloadManager.Request(url.toUri())
|
|
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
|
|
request.setTitle(fileName)
|
|
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE or DownloadManager.Request.NETWORK_WIFI)
|
|
|
|
// Android 10 以降では allowScanningByMediaScanner は無視される
|
|
if (Build.VERSION.SDK_INT < 29) {
|
|
//メディアスキャンを許可する
|
|
@Suppress("DEPRECATION")
|
|
request.allowScanningByMediaScanner()
|
|
}
|
|
|
|
//ダウンロード中・ダウンロード完了時にも通知を表示する
|
|
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
|
|
downLoadManager.enqueue(request)
|
|
showToast(false, R.string.downloading)
|
|
}
|
|
|
|
private fun share(action: String, url: String) {
|
|
|
|
try {
|
|
val intent = Intent(action)
|
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
if (action == Intent.ACTION_SEND) {
|
|
intent.type = "text/plain"
|
|
intent.putExtra(Intent.EXTRA_TEXT, url)
|
|
} else {
|
|
intent.data = url.toUri()
|
|
}
|
|
|
|
startActivity(intent)
|
|
} catch (ex: Throwable) {
|
|
showToast(ex, "can't open app.")
|
|
}
|
|
}
|
|
|
|
private fun copy(url: String) {
|
|
val cm = getSystemService(CLIPBOARD_SERVICE) as? ClipboardManager
|
|
?: throw NotImplementedError("missing ClipboardManager system service")
|
|
|
|
try {
|
|
//クリップボードに格納するItemを作成
|
|
val item = ClipData.Item(url)
|
|
|
|
val mimeType = arrayOfNulls<String>(1)
|
|
mimeType[0] = ClipDescription.MIMETYPE_TEXT_PLAIN
|
|
|
|
//クリップボードに格納するClipDataオブジェクトの作成
|
|
val cd = ClipData(ClipDescription("media URL", mimeType), item)
|
|
|
|
//クリップボードにデータを格納
|
|
cm.setPrimaryClip(cd)
|
|
|
|
showToast(false, R.string.url_is_copied)
|
|
} catch (ex: Throwable) {
|
|
showToast(ex, "clipboard access failed.")
|
|
}
|
|
}
|
|
|
|
private fun more(ta: TootAttachmentLike) {
|
|
launchAndShowError {
|
|
actionsDialog {
|
|
fun addMoreMenu(
|
|
captionPrefix: String,
|
|
url: String?,
|
|
@Suppress("SameParameterValue") action: String,
|
|
) {
|
|
val uri = url.mayUri() ?: return
|
|
val caption = getString(R.string.open_browser_of, captionPrefix)
|
|
action(caption) {
|
|
try {
|
|
val intent = Intent(action, uri)
|
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
startActivity(intent)
|
|
} catch (ex: Throwable) {
|
|
showToast(ex, "can't open app.")
|
|
}
|
|
}
|
|
}
|
|
if (ta is TootAttachment) {
|
|
val url = ta.getLargeUrl()
|
|
if (url != null) {
|
|
action(getString(R.string.open_in_browser)) {
|
|
share(Intent.ACTION_VIEW, url)
|
|
}
|
|
action(getString(R.string.share_url)) {
|
|
share(Intent.ACTION_SEND, url)
|
|
}
|
|
action(getString(R.string.copy_url)) {
|
|
copy(url)
|
|
}
|
|
}
|
|
addMoreMenu("url", ta.url, Intent.ACTION_VIEW)
|
|
addMoreMenu("remote_url", ta.remote_url, Intent.ACTION_VIEW)
|
|
addMoreMenu("preview_url", ta.preview_url, Intent.ACTION_VIEW)
|
|
addMoreMenu("preview_remote_url", ta.preview_remote_url, Intent.ACTION_VIEW)
|
|
addMoreMenu("text_url", ta.text_url, Intent.ACTION_VIEW)
|
|
} else if (ta is TootAttachmentMSP) {
|
|
val url = ta.preview_url
|
|
action(getString(R.string.open_in_browser)) {
|
|
share(Intent.ACTION_VIEW, url)
|
|
}
|
|
action(getString(R.string.share_url)) {
|
|
share(Intent.ACTION_SEND, url)
|
|
}
|
|
action(getString(R.string.copy_url)) {
|
|
copy(url)
|
|
}
|
|
}
|
|
|
|
if (TootAttachmentType.Image == mediaList.elementAtOrNull(idx)?.type) {
|
|
action(getString(R.string.background_pattern)) { mediaBackgroundDialog() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun mediaBackgroundDialog() {
|
|
launchAndShowError {
|
|
actionsDialog(getString(R.string.background_pattern)) {
|
|
for (k in MediaBackgroundDrawable.Kind.entries) {
|
|
if (!k.isMediaBackground) continue
|
|
action(k.name) {
|
|
val idx = k.toIndex()
|
|
PrefI.ipMediaBackground.value = idx
|
|
views.pbvImage.background = MediaBackgroundDrawable(
|
|
context = views.root.context,
|
|
tileStep = tileStep,
|
|
kind = k
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* remote_urlを再生できなかった場合、自サーバで再生し直す
|
|
*/
|
|
private fun recoverLocalVideo(): Boolean {
|
|
val ta = mediaList.elementAtOrNull(idx)
|
|
if (ta != null &&
|
|
lastVideoUrl == ta.remote_url &&
|
|
!ta.url.isNullOrEmpty() &&
|
|
ta.url != ta.remote_url
|
|
) {
|
|
launchMain {
|
|
yield()
|
|
loadVideo(ta, forceLocalUrl = true)
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* 画面下部の情報テキストの表示を更新する
|
|
*/
|
|
private fun showZoom(
|
|
w: Int,
|
|
h: Int,
|
|
scale: Float,
|
|
) {
|
|
if (isDestroyed) return
|
|
if (views.tvStatus.visibility == View.VISIBLE) {
|
|
views.tvStatus.text = if (w != originalWidth || h != originalHeight) {
|
|
getString(
|
|
R.string.zooming_of_resized,
|
|
w,
|
|
h,
|
|
scale,
|
|
idx + 1,
|
|
mediaList.size,
|
|
originalWidth,
|
|
originalHeight,
|
|
)
|
|
} else {
|
|
getString(
|
|
R.string.zooming_of,
|
|
w,
|
|
h,
|
|
scale,
|
|
idx + 1,
|
|
mediaList.size
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|