SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt

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
)
}
}
}
}