画像と動画の選択をJetpack ActivityのPickVisualMediaに移行する

This commit is contained in:
tateisu 2024-01-06 07:26:14 +09:00
parent 5f7f4c34ec
commit ceca568a1a
14 changed files with 408 additions and 236 deletions

View File

@ -6,7 +6,7 @@ import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") kotlin("plugin.serialization")
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("io.gitlab.arturbosch.detekt") id("io.gitlab.arturbosch.detekt")
} }

View File

@ -63,10 +63,14 @@
android:maxSdkVersion="32" android:maxSdkVersion="32"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<!-- Devices running Android 13 (API level 33) or higher -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- To handle the reselection within the app on Android 14 (API level 34) -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

View File

@ -14,43 +14,75 @@ import android.text.Editable
import android.text.SpannableString import android.text.SpannableString
import android.text.TextWatcher import android.text.TextWatcher
import android.view.View import android.view.View
import android.widget.* import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CompoundButton
import android.widget.EditText
import android.widget.ImageButton
import android.widget.Spinner
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.jrummyapps.android.colorpicker.ColorPickerDialog import com.jrummyapps.android.colorpicker.ColorPickerDialog
import com.jrummyapps.android.colorpicker.ColorPickerDialogListener import com.jrummyapps.android.colorpicker.ColorPickerDialogListener
import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.auth.authRepo import jp.juggler.subwaytooter.api.auth.authRepo
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.ServiceType
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootAttachment
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.api.entity.parseItem
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.api.showApiError
import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.notification.* import jp.juggler.subwaytooter.notification.checkNotificationImmediate
import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll
import jp.juggler.subwaytooter.notification.resetNotificationTracking
import jp.juggler.subwaytooter.push.PushBase import jp.juggler.subwaytooter.push.PushBase
import jp.juggler.subwaytooter.push.pushRepo import jp.juggler.subwaytooter.push.pushRepo
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.util.*
import jp.juggler.util.* import jp.juggler.util.backPressed
import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.coroutine.launchProgress import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.* import jp.juggler.util.data.UriSerializer
import jp.juggler.util.data.getDocumentName
import jp.juggler.util.data.getStreamSize
import jp.juggler.util.data.notZero
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
import jp.juggler.util.log.withCaption import jp.juggler.util.log.withCaption
import jp.juggler.util.long
import jp.juggler.util.media.ResizeConfig import jp.juggler.util.media.ResizeConfig
import jp.juggler.util.media.ResizeType import jp.juggler.util.media.ResizeType
import jp.juggler.util.media.createResizedBitmap import jp.juggler.util.media.createResizedBitmap
import jp.juggler.util.network.toPatch import jp.juggler.util.network.toPatch
import jp.juggler.util.network.toPost import jp.juggler.util.network.toPost
import jp.juggler.util.network.toPostRequestBuilder import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.ui.* import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.attrColor
import jp.juggler.util.ui.isEnabledAlpha
import jp.juggler.util.ui.isOk
import jp.juggler.util.ui.scan
import jp.juggler.util.ui.vg
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import okhttp3.MediaType import okhttp3.MediaType
@ -164,61 +196,39 @@ class ActAccountSetting : AppCompatActivity(),
loadLanguageList() loadLanguageList()
} }
/////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////
private var permissionCamera = permissionSpecCamera.requester {
openCamera()
}
private var pickImageLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null
private val pickImageCallback = ActivityResultCallback<Uri?> {
handlePickImageResult(it)
}
private val arCameraImage = ActivityResultHandler(log) {
handleCameraResult(it)
}
private val arShowAcctColor = ActivityResultHandler(log) { r -> private val arShowAcctColor = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler if (r.isOk) showAcctColor()
showAcctColor()
} }
private val arAddAttachment = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data
?.handleGetContentResult(contentResolver)
?.firstOrNull()
?.let {
uploadImage(
state.propName,
it.uri,
it.uri.resolveMimeType(it.mimeType, this),
)
}
}
private val arCameraImage = ActivityResultHandler(log) { r ->
if (r.isNotOk) {
// 失敗したら DBからデータを削除
state.uriCameraImage?.let {
contentResolver.delete(it, null, null)
}
state.uriCameraImage = null
} else {
// 画像のURL
val uri = r.data?.data ?: state.uriCameraImage
if (uri != null) {
uploadImage(
state.propName,
uri,
uri.resolveMimeType(null, this),
)
}
}
}
private val prPickAvater = permissionSpecImagePicker.requester { openPicker(it) }
private val prPickHeader = permissionSpecImagePicker.requester { openPicker(it) }
/////////////////////////////////////////////////// ///////////////////////////////////////////////////
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
backPressed { handleBackPressed() } backPressed { handleBackPressed() }
prPickAvater.register(this) pickImageLauncher = registerForActivityResult(
prPickHeader.register(this) ActivityResultContracts.PickVisualMedia(),
pickImageCallback,
)
permissionCamera.register(this)
arShowAcctColor.register(this) arShowAcctColor.register(this)
arAddAttachment.register(this)
arCameraImage.register(this) arCameraImage.register(this)
if (savedInstanceState != null) { if (savedInstanceState != null) {
@ -1335,64 +1345,89 @@ class ActAccountSetting : AppCompatActivity(),
} }
private fun pickAvatarImage() { private fun pickAvatarImage() {
openPicker(prPickAvater) openImagePickerOrCamera("avatar")
} }
private fun pickHeaderImage() { private fun pickHeaderImage() {
openPicker(prPickHeader) openImagePickerOrCamera("header")
} }
private fun openPicker(permissionRequester: PermissionRequester) { private fun openImagePickerOrCamera(propName: String) {
state.propName = propName
launchAndShowError { launchAndShowError {
if (!permissionRequester.checkOrLaunch()) return@launchAndShowError
val propName = when (permissionRequester) {
prPickHeader -> "header"
else -> "avatar"
}
actionsDialog { actionsDialog {
action(getString(R.string.pick_image)) { action(getString(R.string.pick_image)) {
performAttachment(propName) openPickImage()
} }
action(getString(R.string.image_capture)) { action(getString(R.string.image_capture)) {
performCamera(propName) openCamera()
} }
} }
} }
} }
private fun performAttachment(propName: String) { private fun openPickImage() {
try { (pickImageLauncher ?: error("pickImageLauncher not registered")).launch(
state.propName = propName PickVisualMediaRequest(
val intent = intentGetContent(false, getString(R.string.pick_image), arrayOf("image/*")) ActivityResultContracts.PickVisualMedia.ImageOnly,
arAddAttachment.launch(intent) )
} catch (ex: Throwable) { )
log.e(ex, "performAttachment failed.")
showToast(ex, "performAttachment failed.")
}
} }
private fun performCamera(propName: String) { private fun handlePickImageResult(uri: Uri?) {
uri ?: return
uploadImage(
state.propName,
uri,
uri.resolveMimeType(null, this),
)
}
try { private fun openCamera() {
if (!permissionCamera.checkOrLaunch()) return
launchAndShowError(errorCaption = "openCamera failed.") {
// カメラで撮影 // カメラで撮影
val filename = System.currentTimeMillis().toString() + ".jpg" val filename = System.currentTimeMillis().toString() + ".jpg"
val values = ContentValues() val values = ContentValues().apply {
values.put(MediaStore.Images.Media.TITLE, filename) put(MediaStore.Images.Media.TITLE, filename)
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
state.uriCameraImage = uri .also { state.uriCameraImage = it }
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri) putExtra(MediaStore.EXTRA_OUTPUT, uri)
}
state.propName = propName
arCameraImage.launch(intent) arCameraImage.launch(intent)
} catch (ex: Throwable) {
log.e(ex, "opening camera app failed.")
showToast(ex, "opening camera app failed.")
} }
} }
private fun handleCameraResult(r: ActivityResult) {
when {
r.isOk -> {
// 画像のURL
val uri = r.data?.data ?: state.uriCameraImage
if (uri != null) {
uploadImage(
state.propName,
uri,
uri.resolveMimeType(null, this),
)
}
}
else -> {
// 失敗したら DBからデータを削除
state.uriCameraImage?.let {
contentResolver.delete(it, null, null)
}
state.uriCameraImage = null
}
}
}
///////////////////////////////////////////////////
internal interface InputStreamOpener { internal interface InputStreamOpener {
val mimeType: String val mimeType: String
val uri: Uri val uri: Uri

View File

@ -22,7 +22,6 @@ import jp.juggler.subwaytooter.actpost.CompletionHelper
import jp.juggler.subwaytooter.actpost.FeaturedTagCache import jp.juggler.subwaytooter.actpost.FeaturedTagCache
import jp.juggler.subwaytooter.actpost.addAttachment import jp.juggler.subwaytooter.actpost.addAttachment
import jp.juggler.subwaytooter.actpost.applyMushroomText import jp.juggler.subwaytooter.actpost.applyMushroomText
import jp.juggler.subwaytooter.actpost.rearrangeAttachments
import jp.juggler.subwaytooter.actpost.onPickCustomThumbnailImpl import jp.juggler.subwaytooter.actpost.onPickCustomThumbnailImpl
import jp.juggler.subwaytooter.actpost.onPostAttachmentCompleteImpl import jp.juggler.subwaytooter.actpost.onPostAttachmentCompleteImpl
import jp.juggler.subwaytooter.actpost.openAttachment import jp.juggler.subwaytooter.actpost.openAttachment
@ -33,6 +32,7 @@ import jp.juggler.subwaytooter.actpost.performAttachmentClick
import jp.juggler.subwaytooter.actpost.performMore import jp.juggler.subwaytooter.actpost.performMore
import jp.juggler.subwaytooter.actpost.performPost import jp.juggler.subwaytooter.actpost.performPost
import jp.juggler.subwaytooter.actpost.performSchedule import jp.juggler.subwaytooter.actpost.performSchedule
import jp.juggler.subwaytooter.actpost.rearrangeAttachments
import jp.juggler.subwaytooter.actpost.removeReply import jp.juggler.subwaytooter.actpost.removeReply
import jp.juggler.subwaytooter.actpost.resetSchedule import jp.juggler.subwaytooter.actpost.resetSchedule
import jp.juggler.subwaytooter.actpost.restoreState import jp.juggler.subwaytooter.actpost.restoreState
@ -82,7 +82,7 @@ import java.util.concurrent.ConcurrentHashMap
class ActPost : AppCompatActivity(), class ActPost : AppCompatActivity(),
View.OnClickListener, View.OnClickListener,
PostAttachment.Callback, PostAttachment.Callback,
MyClickableSpanHandler, AttachmentPicker.Callback { MyClickableSpanHandler {
companion object { companion object {
private val log = LogCategory("ActPost") private val log = LogCategory("ActPost")
@ -215,7 +215,21 @@ class ActPost : AppCompatActivity(),
appState = App1.getAppState(this) appState = App1.getAppState(this)
handler = appState.handler handler = appState.handler
attachmentUploader = AttachmentUploader(this, handler) attachmentUploader = AttachmentUploader(this, handler)
attachmentPicker = AttachmentPicker(this, this) attachmentPicker = AttachmentPicker(this, object : AttachmentPicker.Callback {
override suspend fun onPickAttachment(uri: Uri, mimeType: String?) {
addAttachment(uri, mimeType)
}
override suspend fun onPickCustomThumbnail(
attachmentId: String?,
src: GetContentResultEntry,
) {
val pa = attachmentList.find { it.attachment?.id?.toString() == attachmentId }
?: error("missing attachment for attachmentId=$attachmentId")
onPickCustomThumbnailImpl(pa, src)
}
})
density = resources.displayMetrics.density density = resources.displayMetrics.density
arMushroom.register(this) arMushroom.register(this)
@ -315,6 +329,7 @@ class ActPost : AppCompatActivity(),
R.id.btnFeaturedTag -> completionHelper.openFeaturedTagList( R.id.btnFeaturedTag -> completionHelper.openFeaturedTagList(
featuredTagCache[account?.acct?.ascii ?: ""]?.list featuredTagCache[account?.acct?.ascii ?: ""]?.list
) )
R.id.btnAttachmentsRearrange -> rearrangeAttachments() R.id.btnAttachmentsRearrange -> rearrangeAttachments()
R.id.ibSchedule -> performSchedule() R.id.ibSchedule -> performSchedule()
R.id.ibScheduleReset -> resetSchedule() R.id.ibScheduleReset -> resetSchedule()
@ -337,10 +352,6 @@ class ActPost : AppCompatActivity(),
openBrowser(span.linkInfo.url) openBrowser(span.linkInfo.url)
} }
override fun onPickAttachment(uri: Uri, mimeType: String?) {
addAttachment(uri, mimeType)
}
override fun onPostAttachmentProgress() { override fun onPostAttachmentProgress() {
launchIO { launchIO {
try { try {
@ -355,15 +366,6 @@ class ActPost : AppCompatActivity(),
onPostAttachmentCompleteImpl(pa) onPostAttachmentCompleteImpl(pa)
} }
override fun resumeCustomThumbnailTarget(id: String?): PostAttachment? {
id ?: return null
return attachmentList.find { it.attachment?.id?.toString() == id }
}
override fun onPickCustomThumbnail(pa: PostAttachment, src: GetContentResultEntry) {
onPickCustomThumbnailImpl(pa, src)
}
fun initUI() { fun initUI() {
setContentView(views.root) setContentView(views.root)

View File

@ -269,7 +269,9 @@ fun ActPost.performAttachmentClick(idx: Int) {
TootAttachmentType.GIFV, TootAttachmentType.GIFV,
TootAttachmentType.Video, TootAttachmentType.Video,
-> action(getString(R.string.custom_thumbnail)) { -> action(getString(R.string.custom_thumbnail)) {
attachmentPicker.openCustomThumbnail(pa) attachmentPicker.openCustomThumbnail(
attachmentId = pa.attachment?.id?.toString()
)
} }
else -> Unit else -> Unit

View File

@ -4,6 +4,11 @@ import android.content.ContentValues
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.actionsDialog
@ -17,8 +22,8 @@ import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
import jp.juggler.util.ui.ActivityResultHandler import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.isNotOk import jp.juggler.util.ui.isNotOk
import jp.juggler.util.ui.isOk
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
class AttachmentPicker( class AttachmentPicker(
@ -33,9 +38,8 @@ class AttachmentPicker(
// callback after media selected // callback after media selected
interface Callback { interface Callback {
fun onPickAttachment(uri: Uri, mimeType: String? = null) suspend fun onPickAttachment(uri: Uri, mimeType: String? = null)
fun onPickCustomThumbnail(pa: PostAttachment, src: GetContentResultEntry) suspend fun onPickCustomThumbnail(attachmentId: String?, src: GetContentResultEntry)
fun resumeCustomThumbnailTarget(id: String?): PostAttachment?
} }
// actions after permission granted // actions after permission granted
@ -43,7 +47,6 @@ class AttachmentPicker(
@Serializable @Serializable
data class States( data class States(
@Serializable(with = UriSerializer::class) @Serializable(with = UriSerializer::class)
var uriCameraImage: Uri? = null, var uriCameraImage: Uri? = null,
@ -55,59 +58,48 @@ class AttachmentPicker(
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
// activity result handlers // activity result handlers
private val arAttachmentChooser = ActivityResultHandler(log) { r -> private var pickThumbnailLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null
private val pickThumbnailCallback = ActivityResultCallback<Uri?> {
handleThumbnailResult(it)
}
private var pickVisualMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null
private val pickVisualMediaCallback = ActivityResultCallback<List<Uri>?> { uris ->
uris?.handleGetContentResult(activity.contentResolver)?.pickAll()
}
private val prPickAudio = permissionSpecAudioPicker.requester { openAudioPicker() }
private val arPickAudio = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(activity.contentResolver)?.pickAll() r.data?.handleGetContentResult(activity.contentResolver)?.pickAll()
} }
private val arCamera = ActivityResultHandler(log) { r -> private val prCamera = permissionSpecCamera.requester { openStillCamera() }
if (r.isNotOk) { private val arCamera = ActivityResultHandler(log) { handleCameraResult(it) }
// 失敗したら DBからデータを削除
states.uriCameraImage?.let { uri ->
activity.contentResolver.delete(uri, null, null)
states.uriCameraImage = null
}
} else {
// 画像のURL
when (val uri = r.data?.data ?: states.uriCameraImage) {
null -> activity.showToast(false, "missing image uri")
else -> callback.onPickAttachment(uri)
}
}
}
private val arCapture = ActivityResultHandler(log) { r -> private val prCapture = permissionSpecCapture.requester { openPicker() }
if (r.isNotOk) return@ActivityResultHandler private val arCapture = ActivityResultHandler(log) { handleCaptureResult(it) }
r.data?.data?.let { callback.onPickAttachment(it) }
}
private val arCustomThumbnail = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data
?.handleGetContentResult(activity.contentResolver)
?.firstOrNull()
?.let {
callback.resumeCustomThumbnailTarget(states.customThumbnailTargetId)?.let { pa ->
callback.onPickCustomThumbnail(pa, it)
}
}
}
private val prPickAttachment = permissionSpecImagePicker.requester { openPicker() }
private val prPickCustomThumbnail = permissionSpecImagePicker.requester {
callback.resumeCustomThumbnailTarget(states.customThumbnailTargetId)
?.let { openCustomThumbnail(it) }
}
init { init {
// must register all ARHs before onStart // must register all ARHs before onStart
prPickAttachment.register(activity) prPickAudio.register(activity)
prPickCustomThumbnail.register(activity) arPickAudio.register(activity)
arAttachmentChooser.register(activity)
prCamera.register(activity)
arCamera.register(activity) arCamera.register(activity)
arCapture.register(activity) arCapture.register(activity)
arCustomThumbnail.register(activity)
pickVisualMediaLauncher = activity.registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(4),
pickVisualMediaCallback,
)
pickThumbnailLauncher = activity.registerForActivityResult(
ActivityResultContracts.PickVisualMedia(),
pickThumbnailCallback,
)
} }
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
@ -132,21 +124,17 @@ class AttachmentPicker(
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
fun openPicker() { fun openPicker() {
if (!prPickAttachment.checkOrLaunch()) return
activity.run { activity.run {
launchAndShowError { launchAndShowError {
actionsDialog { actionsDialog {
action(getString(R.string.pick_images)) { action(getString(R.string.pick_images_or_video)) {
openAttachmentChooser(R.string.pick_images, "image/*", "video/*") openVisualMediaPicker()
}
action(getString(R.string.pick_videos)) {
openAttachmentChooser(R.string.pick_videos, "video/*")
} }
action(getString(R.string.pick_audios)) { action(getString(R.string.pick_audios)) {
openAttachmentChooser(R.string.pick_audios, "audio/*") openAudioPicker()
} }
action(getString(R.string.image_capture)) { action(getString(R.string.image_capture)) {
performCamera() openStillCamera()
} }
action(getString(R.string.video_capture)) { action(getString(R.string.video_capture)) {
performCapture( performCapture(
@ -165,43 +153,74 @@ class AttachmentPicker(
} }
} }
private fun openAttachmentChooser(titleId: Int, vararg mimeTypes: String) { private fun openVisualMediaPicker() {
// SAFのIntentで開く (pickVisualMediaLauncher
try { ?: error("pickVisualMediaLauncher is not registered."))
val intent = intentGetContent(true, activity.getString(titleId), mimeTypes) .launch(
arAttachmentChooser.launch(intent) PickVisualMediaRequest(
} catch (ex: Throwable) { ActivityResultContracts.PickVisualMedia.ImageAndVideo
log.e(ex, "openAttachmentChooser failed.") )
activity.showToast(ex, "openAttachmentChooser failed.") )
}
private fun openAudioPicker() {
if (!prPickAudio.checkOrLaunch()) return
activity.launchAndShowError {
val intent = intentGetContent(
allowMultiple = true,
caption = activity.getString(R.string.pick_audios),
mimeTypes = arrayOf("audio/*"),
)
arPickAudio.launch(intent)
} }
} }
private fun performCamera() { private fun openStillCamera() {
try { if (!prCamera.checkOrLaunch()) return
val values = ContentValues().apply { activity.launchAndShowError {
put(MediaStore.Images.Media.TITLE, "${System.currentTimeMillis()}.jpg") val newUri = activity.contentResolver.insert(
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") /* url = */
} MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
/* values = */
val newUri = ContentValues().apply {
activity.contentResolver.insert( put(MediaStore.Images.Media.TITLE, "${System.currentTimeMillis()}.jpg")
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
values },
) ).also { states.uriCameraImage = it }
.also { states.uriCameraImage = it }
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, newUri) putExtra(MediaStore.EXTRA_OUTPUT, newUri)
} }
arCamera.launch(intent) arCamera.launch(intent)
} catch (ex: Throwable) {
log.e(ex, "performCamera failed.")
activity.showToast(ex, "performCamera failed.")
} }
} }
private fun performCapture(action: String, errorCaption: String) { private fun handleCameraResult(r: ActivityResult) {
activity.launchAndShowError {
when {
r.isOk -> when (val uri = r.data?.data ?: states.uriCameraImage) {
null -> activity.showToast(false, "missing image uri")
else -> callback.onPickAttachment(uri)
}
// 失敗したら DBからデータを削除
else -> states.uriCameraImage?.let { uri ->
activity.contentResolver.delete(uri, null, null)
states.uriCameraImage = null
}
}
}
}
/**
* 動画や音声をキャプチャする
* - Uriは呼び出し先に任せっきり
*/
private fun performCapture(
action: String,
errorCaption: String,
) {
if (!prCapture.checkOrLaunch()) return
try { try {
arCapture.launch(Intent(action)) arCapture.launch(Intent(action))
} catch (ex: Throwable) { } catch (ex: Throwable) {
@ -210,28 +229,46 @@ class AttachmentPicker(
} }
} }
private fun ArrayList<GetContentResultEntry>.pickAll() = private fun handleCaptureResult(r: ActivityResult) {
forEach { callback.onPickAttachment(it.uri, it.mimeType) } activity.launchAndShowError {
if (r.isOk) {
when (val uri = r.data?.data) {
null -> activity.showToast(false, "missing media uri")
else -> callback.onPickAttachment(uri)
}
}
}
}
private fun List<GetContentResultEntry>.pickAll() {
activity.launchAndShowError {
forEach { callback.onPickAttachment(it.uri, it.mimeType) }
}
}
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// Mastodon's custom thumbnail // Mastodon's custom thumbnail
fun openCustomThumbnail(pa: PostAttachment) { fun openCustomThumbnail(attachmentId: String?) {
try { states.customThumbnailTargetId = attachmentId
states.customThumbnailTargetId = pa.attachment?.id?.toString() ?: error("attachmentId is null")
?: return activity.launchAndShowError {
if (!prPickCustomThumbnail.checkOrLaunch()) return (pickThumbnailLauncher
// SAFのIntentで開く ?: error("pickThumbnailLauncher is not registered."))
arCustomThumbnail.launch( .launch(
intentGetContent( PickVisualMediaRequest(
false, ActivityResultContracts.PickVisualMedia.ImageOnly,
activity.getString(R.string.pick_images), )
arrayOf("image/*")
) )
) }
} catch (ex: Throwable) { }
log.e(ex, "openCustomThumbnail failed.")
activity.showToast(ex, "openCustomThumbnail failed.") private fun handleThumbnailResult(uri: Uri?) {
uri ?: return
activity.launchAndShowError {
listOf(uri).handleGetContentResult(activity.contentResolver).firstOrNull()?.let {
callback.onPickCustomThumbnail(states.customThumbnailTargetId, it)
}
} }
} }
} }

View File

@ -43,50 +43,111 @@ class PermissionSpec(
} }
} }
val permissionSpecNotification = if (Build.VERSION.SDK_INT >= 33) { val permissionSpecNotification = when {
PermissionSpec( Build.VERSION.SDK_INT >= 33 -> PermissionSpec(
permissions = listOf( permissions = listOf(
Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS,
), ),
deniedId = R.string.permission_denied_notifications, deniedId = R.string.permission_denied_notifications,
rationalId = R.string.permission_rational_notifications, rationalId = R.string.permission_rational_notifications,
) )
} else { else -> PermissionSpec(
PermissionSpec(
permissions = emptyList(), permissions = emptyList(),
deniedId = R.string.permission_denied_notifications, deniedId = R.string.permission_denied_notifications,
rationalId = R.string.permission_rational_notifications, rationalId = R.string.permission_rational_notifications,
) )
} }
val permissionSpecMediaDownload = if (Build.VERSION.SDK_INT >= 33) { val permissionSpecMediaDownload = when {
PermissionSpec( Build.VERSION.SDK_INT >= 33 -> PermissionSpec(
permissions = listOf(Manifest.permission.POST_NOTIFICATIONS), permissions = listOf(Manifest.permission.POST_NOTIFICATIONS),
deniedId = R.string.permission_denied_download_manager, deniedId = R.string.permission_denied_download_manager,
rationalId = R.string.permission_rational_download_manager, rationalId = R.string.permission_rational_download_manager,
) )
} else { else -> PermissionSpec(
PermissionSpec(
permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
deniedId = R.string.permission_denied_media_access, deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access, rationalId = R.string.permission_rational_media_access,
) )
} }
val permissionSpecImagePicker = if (Build.VERSION.SDK_INT >= 33) { /**
PermissionSpec( * 静止画のみを端末から選ぶ
*/
val permissionSpecImagePicker = when {
Build.VERSION.SDK_INT >= 34 -> PermissionSpec(
permissions = emptyList(),
deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access,
)
Build.VERSION.SDK_INT >= 33 -> PermissionSpec(
permissions = listOf( permissions = listOf(
Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_AUDIO,
), ),
deniedId = R.string.permission_denied_media_access, deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access, rationalId = R.string.permission_rational_media_access,
) )
} else { else -> PermissionSpec(
PermissionSpec(
permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
deniedId = R.string.permission_denied_media_access, deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access, rationalId = R.string.permission_rational_media_access,
) )
} }
/**
* オーディオのみを端末から選ぶ
*/
val permissionSpecAudioPicker = when {
Build.VERSION.SDK_INT >= 33 -> PermissionSpec(
permissions = listOf(
Manifest.permission.READ_MEDIA_AUDIO,
),
deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access,
)
else -> {
PermissionSpec(
permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access,
)
}
}
/**
* カメラ撮影用の実行時パーミッション
* https://developer.android.com/media/camera/camera-deprecated/photobasics?hl=ja#TaskPhotoView
*/
val permissionSpecCamera = when {
Build.VERSION.SDK_INT >= 29 -> PermissionSpec(
permissions = emptyList(),
deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access,
)
else -> PermissionSpec(
permissions = listOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
),
deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access,
)
}
/**
* 動画や音声のキャプチャ前
*/
val permissionSpecCapture = when {
Build.VERSION.SDK_INT >= 29 -> PermissionSpec(
permissions = emptyList(),
deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access,
)
else -> PermissionSpec(
permissions = listOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
),
deniedId = R.string.permission_denied_media_access,
rationalId = R.string.permission_rational_media_access,
)
}

View File

@ -537,6 +537,7 @@
<string name="performance">性能</string> <string name="performance">性能</string>
<string name="pick_image">画像を選択</string> <string name="pick_image">画像を選択</string>
<string name="pick_images">画像を選択…</string> <string name="pick_images">画像を選択…</string>
<string name="pick_images_or_video">画像や動画を選択…</string>
<string name="pick_videos">動画を選択…</string> <string name="pick_videos">動画を選択…</string>
<string name="pick_audios">音声ファイルを選択…</string> <string name="pick_audios">音声ファイルを選択…</string>
<string name="please_add_account">アカウントがありません。事前にアカウントの追加を行ってください。</string> <string name="please_add_account">アカウントがありません。事前にアカウントの追加を行ってください。</string>

View File

@ -320,6 +320,7 @@
<string name="color_and_background">Color and Background…</string> <string name="color_and_background">Color and Background…</string>
<string name="column_background">Column background</string> <string name="column_background">Column background</string>
<string name="pick_image">Pick an image</string> <string name="pick_image">Pick an image</string>
<string name="pick_images_or_video">Pick images or video…</string>
<string name="pick_images">Pick image(s)…</string> <string name="pick_images">Pick image(s)…</string>
<string name="pick_videos">Pick video(s)…</string> <string name="pick_videos">Pick video(s)…</string>
<string name="pick_audios">Choose audio file(s)…</string> <string name="pick_audios">Choose audio file(s)…</string>

View File

@ -105,7 +105,7 @@ dependencies {
api("org.jetbrains.kotlinx:kotlinx-coroutines-android:${Vers.kotlinxCoroutinesVersion}") api("org.jetbrains.kotlinx:kotlinx-coroutines-android:${Vers.kotlinxCoroutinesVersion}")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Vers.kotlinxCoroutinesVersion}") api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Vers.kotlinxCoroutinesVersion}")
api("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") api("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") api("org.jetbrains.kotlinx:kotlinx-serialization-json:${Vers.kotlinxSerializationLibVersion}")
api("ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0") api("ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0")
//non-OSS dependency api "androidx.media3:media3-cast:$media3Version" //non-OSS dependency api "androidx.media3:media3-cast:$media3Version"

View File

@ -1,5 +1,6 @@
package jp.juggler.util.data package jp.juggler.util.data
import android.content.ClipData
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -283,28 +284,26 @@ data class GetContentResultEntry(
var time: Long? = null, var time: Long? = null,
) )
// returns list of pair of uri and mime-type. fun MutableList<GetContentResultEntry>.addNoDuplicate(
fun Intent.handleGetContentResult(contentResolver: ContentResolver): ArrayList<GetContentResultEntry> { contentResolver: ContentResolver,
val urlList = ArrayList<GetContentResultEntry>() uri: Uri?,
// 単一選択 type: String? = null,
data?.let { ) {
val mimeType = try { uri ?: return
type ?: contentResolver.getType(it) if (any { it.uri == uri }) return
} catch (ex: Throwable) { val mimeType = try {
log.w(ex, "contentResolver.getType failed. uri=$it") type ?: contentResolver.getType(uri)
null } catch (ex: Throwable) {
} log.w(ex, "contentResolver.getType failed. uri=$uri")
urlList.add(GetContentResultEntry(it, mimeType)) return
} }
// 複数選択 add(GetContentResultEntry(uri, mimeType))
this.clipData?.let { clipData -> }
for (uri in (0 until clipData.itemCount).mapNotNull { clipData.getItemAt(it)?.uri }) {
if (urlList.none { it.uri == uri }) { fun List<GetContentResultEntry>.grantPermissions(
urlList.add(GetContentResultEntry(uri)) contentResolver: ContentResolver,
} ) {
} forEach {
}
urlList.forEach {
try { try {
contentResolver.takePersistableUriPermission( contentResolver.takePersistableUriPermission(
it.uri, it.uri,
@ -313,5 +312,29 @@ fun Intent.handleGetContentResult(contentResolver: ContentResolver): ArrayList<G
} catch (_: Throwable) { } catch (_: Throwable) {
} }
} }
return urlList
} }
// returns list of pair of uri and mime-type.
fun List<Uri>.handleGetContentResult(contentResolver: ContentResolver) =
buildList {
this@handleGetContentResult.forEach {
addNoDuplicate(contentResolver, it)
}
grantPermissions(contentResolver)
}
val ClipData.uris
get() = (0 until itemCount).mapNotNull { getItemAt(it)?.uri }
// returns list of pair of uri and mime-type.
fun Intent.handleGetContentResult(contentResolver: ContentResolver) =
buildList {
// 単一選択
addNoDuplicate(contentResolver, data, type)
// 複数選択
clipData?.uris?.forEach {
addNoDuplicate(contentResolver, it)
}
grantPermissions(contentResolver)
}

View File

@ -378,6 +378,9 @@ val AppCompatActivity.isLiveActivity: Boolean
val ActivityResult.isNotOk val ActivityResult.isNotOk
get() = Activity.RESULT_OK != resultCode get() = Activity.RESULT_OK != resultCode
val ActivityResult.isOk
get() = Activity.RESULT_OK == resultCode
/** /**
* Ringtone pickerの処理結果のUriまたはnull * Ringtone pickerの処理結果のUriまたはnull
*/ */

View File

@ -21,7 +21,8 @@ buildscript {
} }
plugins { plugins {
id("org.jetbrains.kotlin.jvm") version (Vers.kotlinVersion) apply false kotlin("jvm") version (Vers.kotlinVersion) apply false
kotlin("plugin.serialization") version (Vers.kotlinxSerializationPluginVersion) apply true // !!
id("org.jetbrains.kotlin.android") version (Vers.kotlinVersion) apply false id("org.jetbrains.kotlin.android") version (Vers.kotlinVersion) apply false
id("com.google.devtools.ksp") version (Vers.kspVersion) apply false id("com.google.devtools.ksp") version (Vers.kspVersion) apply false
} }

View File

@ -36,6 +36,8 @@ object Vers {
const val koinVersion = "3.5.0" const val koinVersion = "3.5.0"
const val kotlinTestVersion = kotlinVersion // "1.9.22" const val kotlinTestVersion = kotlinVersion // "1.9.22"
const val kotlinxCoroutinesVersion = "1.7.3" const val kotlinxCoroutinesVersion = "1.7.3"
const val kotlinxSerializationPluginVersion = kotlinVersion
const val kotlinxSerializationLibVersion = "1.6.2"
const val kspVersion = "$kotlinVersion-1.0.16" const val kspVersion = "$kotlinVersion-1.0.16"
const val lifecycleVersion = "2.6.2" const val lifecycleVersion = "2.6.2"
const val materialVersion = "1.11.0" const val materialVersion = "1.11.0"