Compare commits

...

3 Commits

Author SHA1 Message Date
tateisu f8aaedf86a v5.545 2024-01-07 07:58:31 +09:00
tateisu 2cbbbcce43 使わない列挙型の削除 2024-01-07 07:56:16 +09:00
tateisu e703d7460b メディアアクセス権限の有無に応じて使用するピッカーを変える 2024-01-07 00:53:28 +09:00
20 changed files with 622 additions and 376 deletions

View File

@ -25,8 +25,8 @@ android {
defaultConfig { defaultConfig {
targetSdk = Vers.stTargetSdkVersion targetSdk = Vers.stTargetSdkVersion
minSdk = Vers.stMinSdkVersion minSdk = Vers.stMinSdkVersion
versionCode = 544 versionCode = 545
versionName = "5.544" versionName = "5.545"
applicationId = "jp.juggler.subwaytooter" applicationId = "jp.juggler.subwaytooter"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@ -1,7 +1,6 @@
package jp.juggler.subwaytooter package jp.juggler.subwaytooter
import android.app.Activity import android.app.Activity
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
@ -9,7 +8,6 @@ import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.provider.MediaStore
import android.text.Editable import android.text.Editable
import android.text.SpannableString import android.text.SpannableString
import android.text.TextWatcher import android.text.TextWatcher
@ -21,11 +19,6 @@ import android.widget.CompoundButton
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.Spinner 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
@ -63,6 +56,7 @@ 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.UriAndType
import jp.juggler.util.data.UriSerializer import jp.juggler.util.data.UriSerializer
import jp.juggler.util.data.getDocumentName import jp.juggler.util.data.getDocumentName
import jp.juggler.util.data.getStreamSize import jp.juggler.util.data.getStreamSize
@ -198,18 +192,12 @@ class ActAccountSetting : AppCompatActivity(),
///////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////
private var permissionCamera = permissionSpecCamera.requester { private val cameraOpener = CameraOpener {
openCamera() uploadImage(state.propName, it)
} }
private var pickImageLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null private val visualMediaPicker = VisualMediaPickerCompat {
uploadImage(state.propName, it?.firstOrNull())
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 ->
@ -222,14 +210,10 @@ class ActAccountSetting : AppCompatActivity(),
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
backPressed { handleBackPressed() } backPressed { handleBackPressed() }
pickImageLauncher = registerForActivityResult( visualMediaPicker.register(this)
ActivityResultContracts.PickVisualMedia(), cameraOpener.register(this)
pickImageCallback,
)
permissionCamera.register(this)
arShowAcctColor.register(this) arShowAcctColor.register(this)
arCameraImage.register(this)
if (savedInstanceState != null) { if (savedInstanceState != null) {
savedInstanceState.getString(ACTIVITY_STATE) savedInstanceState.getString(ACTIVITY_STATE)
@ -1357,75 +1341,15 @@ class ActAccountSetting : AppCompatActivity(),
launchAndShowError { launchAndShowError {
actionsDialog { actionsDialog {
action(getString(R.string.pick_image)) { action(getString(R.string.pick_image)) {
openPickImage() visualMediaPicker.open()
} }
action(getString(R.string.image_capture)) { action(getString(R.string.image_capture)) {
openCamera() cameraOpener.open()
} }
} }
} }
} }
private fun openPickImage() {
(pickImageLauncher ?: error("pickImageLauncher not registered")).launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
)
)
}
private fun handlePickImageResult(uri: Uri?) {
uri ?: return
uploadImage(
state.propName,
uri,
uri.resolveMimeType(null, this),
)
}
private fun openCamera() {
if (!permissionCamera.checkOrLaunch()) return
launchAndShowError(errorCaption = "openCamera failed.") {
// カメラで撮影
val filename = System.currentTimeMillis().toString() + ".jpg"
val values = ContentValues().apply {
put(MediaStore.Images.Media.TITLE, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
.also { state.uriCameraImage = it }
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, uri)
}
arCameraImage.launch(intent)
}
}
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 {
@ -1515,7 +1439,10 @@ class ActAccountSetting : AppCompatActivity(),
} }
} }
private fun uploadImage(propName: String, uri: Uri, mimeType: String?) { private fun uploadImage(propName: String, src: UriAndType?) {
src ?: return
val uri = src.uri
val mimeType = src.mimeType
if (mimeType == null) { if (mimeType == null) {
showToast(false, "mime type is not provided.") showToast(false, "mime type is not provided.")

View File

@ -64,7 +64,7 @@ import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchProgress import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.cast import jp.juggler.util.data.cast
import jp.juggler.util.data.defaultLocale import jp.juggler.util.data.defaultLocale
import jp.juggler.util.data.handleGetContentResult import jp.juggler.util.data.checkMimeTypeAndGrant
import jp.juggler.util.data.intentOpenDocument import jp.juggler.util.data.intentOpenDocument
import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.notZero import jp.juggler.util.data.notZero
@ -132,7 +132,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli
private val arImportAppData = ActivityResultHandler(log) { r -> private val arImportAppData = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(contentResolver) r.data?.checkMimeTypeAndGrant(contentResolver)
?.firstOrNull() ?.firstOrNull()
?.uri?.let { importAppData2(false, it) } ?.uri?.let { importAppData2(false, it) }
} }
@ -1031,7 +1031,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli
private fun handleFontResult(item: AppSettingItem?, data: Intent, fileName: String) { private fun handleFontResult(item: AppSettingItem?, data: Intent, fileName: String) {
item ?: error("handleFontResult : setting item is null") item ?: error("handleFontResult : setting item is null")
data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let { data.checkMimeTypeAndGrant(contentResolver).firstOrNull()?.uri?.let {
val file = saveTimelineFont(it, fileName) val file = saveTimelineFont(it, fileName)
if (file != null) { if (file != null) {
(item.pref as? StringPref)?.value = file.absolutePath (item.pref as? StringPref)?.value = file.absolutePath

View File

@ -74,7 +74,7 @@ class ActColumnCustomize : AppCompatActivity(), View.OnClickListener, ColorPicke
private val arColumnBackgroundImage = ActivityResultHandler(log) { r -> private val arColumnBackgroundImage = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(contentResolver) r.data?.checkMimeTypeAndGrant(contentResolver)
?.firstOrNull()?.uri?.let { updateBackground(it) } ?.firstOrNull()?.uri?.let { updateBackground(it) }
} }

View File

@ -132,7 +132,7 @@ class ActLanguageFilter : AppCompatActivity(), View.OnClickListener {
private val arImport = ActivityResultHandler(log) { r -> private val arImport = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(contentResolver) r.data?.checkMimeTypeAndGrant(contentResolver)
?.firstOrNull()?.uri?.let { import2(it) } ?.firstOrNull()?.uri?.let { import2(it) }
} }

View File

@ -2,7 +2,6 @@ package jp.juggler.subwaytooter
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.text.Editable import android.text.Editable
@ -65,7 +64,7 @@ import jp.juggler.util.backPressed
import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchIO import jp.juggler.util.coroutine.launchIO
import jp.juggler.util.coroutine.launchMain import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.GetContentResultEntry import jp.juggler.util.data.UriAndType
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.string import jp.juggler.util.string
@ -216,14 +215,15 @@ class ActPost : AppCompatActivity(),
handler = appState.handler handler = appState.handler
attachmentUploader = AttachmentUploader(this, handler) attachmentUploader = AttachmentUploader(this, handler)
attachmentPicker = AttachmentPicker(this, object : AttachmentPicker.Callback { attachmentPicker = AttachmentPicker(this, object : AttachmentPicker.Callback {
override suspend fun onPickAttachment(uri: Uri, mimeType: String?) { override suspend fun onPickAttachment(item: UriAndType) {
addAttachment(uri, mimeType) addAttachment(item.uri, item.mimeType)
} }
override suspend fun onPickCustomThumbnail( override suspend fun onPickCustomThumbnail(
attachmentId: String?, attachmentId: String?,
src: GetContentResultEntry, src: UriAndType?,
) { ) {
src ?: return
val pa = attachmentList.find { it.attachment?.id?.toString() == attachmentId } val pa = attachmentList.find { it.attachment?.id?.toString() == attachmentId }
?: error("missing attachment for attachmentId=$attachmentId") ?: error("missing attachment for attachmentId=$attachmentId")
onPickCustomThumbnailImpl(pa, src) onPickCustomThumbnailImpl(pa, src)

View File

@ -29,9 +29,8 @@ import jp.juggler.subwaytooter.util.AttachmentRequest
import jp.juggler.subwaytooter.util.PostAttachment import jp.juggler.subwaytooter.util.PostAttachment
import jp.juggler.subwaytooter.view.MyNetworkImageView import jp.juggler.subwaytooter.view.MyNetworkImageView
import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.CharacterGroup import jp.juggler.util.data.CharacterGroup
import jp.juggler.util.data.GetContentResultEntry import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.buildJsonObject import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.decodeJsonArray import jp.juggler.util.data.decodeJsonArray
import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notEmpty
@ -269,9 +268,7 @@ 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( attachmentPicker.openThumbnailPicker(pa)
attachmentId = pa.attachment?.id?.toString()
)
} }
else -> Unit else -> Unit
@ -436,20 +433,18 @@ suspend fun ActPost.editAttachmentDescription(
} }
} }
fun ActPost.onPickCustomThumbnailImpl(pa: PostAttachment, src: GetContentResultEntry) { suspend fun ActPost.onPickCustomThumbnailImpl(pa: PostAttachment, src: UriAndType) {
when (val account = this.account) { when (val account = this.account) {
null -> showToast(false, R.string.account_select_please) null -> showToast(false, R.string.account_select_please)
else -> launchMain { else -> if (pa.attachment?.isEdit == true) {
if (pa.attachment?.isEdit == true) { showToast(
showToast( true,
true, "Sorry, updateing thumbnail is not yet supported in case of editing post."
"Sorry, updateing thumbnail is not yet supported in case of editing post." )
) } else {
} else { val result = attachmentUploader.uploadCustomThumbnail(account, src, pa)
val result = attachmentUploader.uploadCustomThumbnail(account, src, pa) result?.error?.let { showToast(true, it) }
result?.error?.let { showToast(true, it) } showMediaAttachment()
showMediaAttachment()
}
} }
} }
} }

View File

@ -3,7 +3,11 @@ package jp.juggler.subwaytooter.actpost
import android.os.Bundle import android.os.Bundle
import jp.juggler.subwaytooter.ActPost import jp.juggler.subwaytooter.ActPost
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.EntityIdSerializer
import jp.juggler.subwaytooter.api.entity.TootScheduled
import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.api.entity.parseItem
import jp.juggler.subwaytooter.kJson import jp.juggler.subwaytooter.kJson
import jp.juggler.subwaytooter.util.AttachmentPicker import jp.juggler.subwaytooter.util.AttachmentPicker
import jp.juggler.subwaytooter.util.PostAttachment import jp.juggler.subwaytooter.util.PostAttachment
@ -11,7 +15,6 @@ import jp.juggler.util.data.decodeJsonObject
import jp.juggler.util.data.toJsonArray import jp.juggler.util.data.toJsonArray
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
private val log = LogCategory("ActPostStates") private val log = LogCategory("ActPostStates")

View File

@ -3,10 +3,12 @@ package jp.juggler.subwaytooter.pref
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
import androidx.startup.Initializer import androidx.startup.Initializer
import jp.juggler.util.data.mayUri
import jp.juggler.util.os.applicationContextSafe import jp.juggler.util.os.applicationContextSafe
import java.util.* import java.util.UUID
class PrefDevice(context: Context) { class PrefDevice(context: Context) {
@ -28,6 +30,10 @@ class PrefDevice(context: Context) {
private const val PREF_TIME_LAST_ENDPOINT_REGISTER = "timeLastEndpointRegister" private const val PREF_TIME_LAST_ENDPOINT_REGISTER = "timeLastEndpointRegister"
private const val PREF_SUPRESS_REQUEST_NOTIFICATION_PERMISSION = private const val PREF_SUPRESS_REQUEST_NOTIFICATION_PERMISSION =
"supressRequestNotificationPermission" "supressRequestNotificationPermission"
private const val PREF_MEDIA_PICKER_MULTIPLE = "mediaPickerMultiple"
private const val PREF_CAMERA_OPENER_LAST_URI = "cameraOpenerLastUri"
private const val PREF_CAPTURE_ACTION = "captureAction"
private const val PREF_CAPTURE_ERROR_CAPTION = "captureErrorCaption"
const val PUSH_DISTRIBUTOR_FCM = "fcm" const val PUSH_DISTRIBUTOR_FCM = "fcm"
const val PUSH_DISTRIBUTOR_NONE = "none" const val PUSH_DISTRIBUTOR_NONE = "none"
@ -153,6 +159,31 @@ class PrefDevice(context: Context) {
value.saveTo(PREF_SUPRESS_REQUEST_NOTIFICATION_PERMISSION) value.saveTo(PREF_SUPRESS_REQUEST_NOTIFICATION_PERMISSION)
} }
var mediaPickerMultiple: Boolean
get() = boolean(PREF_MEDIA_PICKER_MULTIPLE) ?: false
set(value) {
value.saveTo(PREF_MEDIA_PICKER_MULTIPLE)
}
var cameraOpenerLastUri: Uri?
get() = string(PREF_CAMERA_OPENER_LAST_URI)?.mayUri()
set(value) {
(value?.toString() ?: "").saveTo(PREF_CAMERA_OPENER_LAST_URI)
}
val captureAction
get() = string(PREF_CAPTURE_ACTION)
val captureErrorCaption
get() = string(PREF_CAPTURE_ERROR_CAPTION)
fun setCaptureParams(action: String, errorCaption: String) {
edit {
it.putString(PREF_CAPTURE_ACTION, action)
it.putString(PREF_CAPTURE_ERROR_CAPTION, errorCaption)
}
}
////////////////////////////////// //////////////////////////////////
// 以下は古い // 以下は古い

View File

@ -1,28 +1,13 @@
package jp.juggler.subwaytooter.util package jp.juggler.subwaytooter.util
import android.content.ContentValues
import android.content.Intent
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
import jp.juggler.subwaytooter.kJson import jp.juggler.subwaytooter.kJson
import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.GetContentResultEntry import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.UriSerializer
import jp.juggler.util.data.handleGetContentResult
import jp.juggler.util.data.intentGetContent
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.isNotOk
import jp.juggler.util.ui.isOk
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -38,18 +23,15 @@ class AttachmentPicker(
// callback after media selected // callback after media selected
interface Callback { interface Callback {
suspend fun onPickAttachment(uri: Uri, mimeType: String? = null) suspend fun onPickAttachment(item: UriAndType)
suspend fun onPickCustomThumbnail(attachmentId: String?, src: GetContentResultEntry) suspend fun onPickCustomThumbnail(attachmentId: String?, src: UriAndType?)
} }
// actions after permission granted // // actions after permission granted
enum class AfterPermission { Attachment, CustomThumbnail, } // enum class AfterPermission { Attachment, CustomThumbnail, }
@Serializable @Serializable
data class States( data class States(
@Serializable(with = UriSerializer::class)
var uriCameraImage: Uri? = null,
var customThumbnailTargetId: String? = null, var customThumbnailTargetId: String? = null,
) )
@ -58,67 +40,43 @@ class AttachmentPicker(
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
// activity result handlers // activity result handlers
private var pickThumbnailLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null private val visualMediaPickerThumbnail = VisualMediaPickerCompat {
callback.onPickCustomThumbnail(states.customThumbnailTargetId, it?.firstOrNull())
private val pickThumbnailCallback = ActivityResultCallback<Uri?> {
handleThumbnailResult(it)
} }
private var pickVisualMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null private val visualMediaPickerAttachment = VisualMediaPickerCompat { it?.pickAll() }
private val pickVisualMediaCallback = ActivityResultCallback<List<Uri>?> { uris -> private val audioPicker = AudioPicker { it?.pickAll() }
uris?.handleGetContentResult(activity.contentResolver)?.pickAll()
}
private val prPickAudio = permissionSpecAudioPicker.requester { openAudioPicker() } private val cameraOpener = CameraOpener { callback.onPickAttachment(it) }
private val arPickAudio = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(activity.contentResolver)?.pickAll()
}
private val prCamera = permissionSpecCamera.requester { openStillCamera() } private val captureOpener = CaptureOpener { callback.onPickAttachment(it) }
private val arCamera = ActivityResultHandler(log) { handleCameraResult(it) }
private val prCapture = permissionSpecCapture.requester { openPicker() }
private val arCapture = ActivityResultHandler(log) { handleCaptureResult(it) }
init { init {
// must register all ARHs before onStart visualMediaPickerAttachment.register(activity)
prPickAudio.register(activity) visualMediaPickerThumbnail.register(activity)
arPickAudio.register(activity) cameraOpener.register(activity)
audioPicker.register(activity)
prCamera.register(activity) captureOpener.register(activity)
arCamera.register(activity)
arCapture.register(activity)
pickVisualMediaLauncher = activity.registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(4),
pickVisualMediaCallback,
)
pickThumbnailLauncher = activity.registerForActivityResult(
ActivityResultContracts.PickVisualMedia(),
pickThumbnailCallback,
)
} }
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
// states // states
fun reset() { fun reset() {
states.uriCameraImage = null cameraOpener.reset()
} }
fun encodeState(): String { fun encodeState(): String {
val encoded = kJson.encodeToString(states) val encoded = kJson.encodeToString(states)
val decoded = kJson.decodeFromString<States>(encoded) val decoded = kJson.decodeFromString<States>(encoded)
log.d("encodeState: ${decoded.uriCameraImage},$encoded") log.d("encodeState: states=$states, encoded=$encoded, decoded=$decoded")
return encoded return encoded
} }
fun restoreState(encoded: String) { fun restoreState(encoded: String) {
states = kJson.decodeFromString(encoded) states = kJson.decodeFromString(encoded)
log.d("restoreState: ${states.uriCameraImage},$encoded") log.d("restoreState: states=$states, encoded=$encoded")
} }
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
@ -128,22 +86,25 @@ class AttachmentPicker(
launchAndShowError { launchAndShowError {
actionsDialog { actionsDialog {
action(getString(R.string.pick_images_or_video)) { action(getString(R.string.pick_images_or_video)) {
openVisualMediaPicker() visualMediaPickerAttachment.open(
multiple = true,
allowVideo = true,
)
} }
action(getString(R.string.pick_audios)) { action(getString(R.string.pick_audios)) {
openAudioPicker() audioPicker.open()
} }
action(getString(R.string.image_capture)) { action(getString(R.string.image_capture)) {
openStillCamera() cameraOpener.open()
} }
action(getString(R.string.video_capture)) { action(getString(R.string.video_capture)) {
performCapture( captureOpener.open(
MediaStore.ACTION_VIDEO_CAPTURE, MediaStore.ACTION_VIDEO_CAPTURE,
"can't open video capture app." "can't open video capture app."
) )
} }
action(getString(R.string.voice_capture)) { action(getString(R.string.voice_capture)) {
performCapture( captureOpener.open(
MediaStore.Audio.Media.RECORD_SOUND_ACTION, MediaStore.Audio.Media.RECORD_SOUND_ACTION,
"can't open voice capture app." "can't open voice capture app."
) )
@ -153,122 +114,15 @@ class AttachmentPicker(
} }
} }
private fun openVisualMediaPicker() { private suspend fun List<UriAndType>.pickAll() {
(pickVisualMediaLauncher forEach { callback.onPickAttachment(it) }
?: error("pickVisualMediaLauncher is not registered."))
.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageAndVideo
)
)
} }
private fun openAudioPicker() { // ActPostAttachmentから呼ばれる
if (!prPickAudio.checkOrLaunch()) return fun openThumbnailPicker(pa: PostAttachment) {
activity.launchAndShowError { states.customThumbnailTargetId =
val intent = intentGetContent( pa.attachment?.id?.toString()
allowMultiple = true, ?: error("attachmentId is null")
caption = activity.getString(R.string.pick_audios), visualMediaPickerThumbnail.open()
mimeTypes = arrayOf("audio/*"),
)
arPickAudio.launch(intent)
}
}
private fun openStillCamera() {
if (!prCamera.checkOrLaunch()) return
activity.launchAndShowError {
val newUri = activity.contentResolver.insert(
/* url = */
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
/* values = */
ContentValues().apply {
put(MediaStore.Images.Media.TITLE, "${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
},
).also { states.uriCameraImage = it }
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, newUri)
}
arCamera.launch(intent)
}
}
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 {
arCapture.launch(Intent(action))
} catch (ex: Throwable) {
log.e(ex, errorCaption)
activity.showToast(ex, errorCaption)
}
}
private fun handleCaptureResult(r: ActivityResult) {
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
fun openCustomThumbnail(attachmentId: String?) {
states.customThumbnailTargetId = attachmentId
?: error("attachmentId is null")
activity.launchAndShowError {
(pickThumbnailLauncher
?: error("pickThumbnailLauncher is not registered."))
.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
)
)
}
}
private fun handleThumbnailResult(uri: Uri?) {
uri ?: return
activity.launchAndShowError {
listOf(uri).handleGetContentResult(activity.contentResolver).firstOrNull()?.let {
callback.onPickCustomThumbnail(states.customThumbnailTargetId, it)
}
}
} }
} }

View File

@ -20,7 +20,7 @@ import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchIO import jp.juggler.util.coroutine.launchIO
import jp.juggler.util.data.GetContentResultEntry import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.asciiPattern import jp.juggler.util.data.asciiPattern
import jp.juggler.util.data.buildJsonObject import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.encodeHex import jp.juggler.util.data.encodeHex
@ -316,7 +316,7 @@ class AttachmentUploader(
// 添付データのカスタムサムネイル // 添付データのカスタムサムネイル
suspend fun uploadCustomThumbnail( suspend fun uploadCustomThumbnail(
account: SavedAccount, account: SavedAccount,
src: GetContentResultEntry, src: UriAndType,
pa: PostAttachment, pa: PostAttachment,
): TootApiResult? = try { ): TootApiResult? = try {
safeContext.runApiTask(account) { client -> safeContext.runApiTask(account) { client ->
@ -335,8 +335,9 @@ class AttachmentUploader(
val maxBytesImage = ar.maxBytesImage(instance, mediaConfig) val maxBytesImage = ar.maxBytesImage(instance, mediaConfig)
val opener = ar.createOpener() val opener = ar.createOpener()
try { pa.progress = ""
try {
if (opener.contentLength > maxBytesImage.toLong()) { if (opener.contentLength > maxBytesImage.toLong()) {
return@runApiTask TootApiResult( return@runApiTask TootApiResult(
getString( getString(
@ -354,6 +355,7 @@ class AttachmentUploader(
if (account.isMisskey) { if (account.isMisskey) {
TootApiResult("custom thumbnail is not supported on misskey account.") TootApiResult("custom thumbnail is not supported on misskey account.")
} else { } else {
val result = client.request( val result = client.request(
"/api/v1/media/${pa.attachment?.id}", "/api/v1/media/${pa.attachment?.id}",
MultipartBody.Builder() MultipartBody.Builder()

View File

@ -0,0 +1,50 @@
package jp.juggler.subwaytooter.util
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.checkMimeTypeAndGrant
import jp.juggler.util.data.intentGetContent
import jp.juggler.util.log.LogCategory
import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.isOk
import jp.juggler.util.ui.launch
class AudioPicker(
private val onPicked: suspend (list: List<UriAndType>?) -> Unit,
) {
companion object {
private val log = LogCategory("AudioPicker")
}
private lateinit var activity: AppCompatActivity
private val prPickAudio = permissionSpecAudioPicker.requester {
activity.launchAndShowError {
open()
}
}
private val arPickAudio = ActivityResultHandler(log) { r ->
activity.launchAndShowError {
if (r.isOk) {
onPicked(r.data?.checkMimeTypeAndGrant(activity.contentResolver))
}
}
}
fun register(activity: AppCompatActivity) {
this.activity = activity
prPickAudio.register(activity)
arPickAudio.register(activity)
}
fun open() {
if (!prPickAudio.checkOrLaunch()) return
intentGetContent(
allowMultiple = true,
caption = activity.getString(R.string.pick_audios),
mimeTypes = arrayOf("audio/*"),
).launch(arPickAudio)
}
}

View File

@ -0,0 +1,85 @@
package jp.juggler.subwaytooter.util
import android.content.ContentValues
import android.content.Intent
import android.provider.MediaStore
import androidx.activity.result.ActivityResult
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.checkMimeTypeAndGrant
import jp.juggler.util.log.LogCategory
import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.isOk
class CameraOpener(
private val onCaptured: suspend (UriAndType) -> Unit,
) {
companion object {
private val log = LogCategory("LogCategory")
}
private lateinit var activity: AppCompatActivity
private val prefDevice: PrefDevice
get() = activity.prefDevice
private val prCameraImage = permissionSpecCamera.requester { open() }
private val arCameraImage = ActivityResultHandler(log) { handleCameraResult(it) }
fun register(activity: AppCompatActivity) {
this.activity = activity
prCameraImage.register(activity)
arCameraImage.register(activity)
}
fun reset() {
prefDevice.cameraOpenerLastUri = null
}
fun open() {
if (!prCameraImage.checkOrLaunch()) return
// カメラで撮影
val filename = System.currentTimeMillis().toString() + ".jpg"
val values = ContentValues().apply {
put(MediaStore.Images.Media.TITLE, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
}
val uri = activity.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
values
).also { prefDevice.cameraOpenerLastUri = it }
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, uri)
}
arCameraImage.launch(intent)
}
private fun handleCameraResult(r: ActivityResult) {
activity.launchAndShowError {
when (
val item = when {
r.isOk -> listOfNotNull(
r.data?.data
?: prefDevice.cameraOpenerLastUri
).checkMimeTypeAndGrant(activity.contentResolver)
else -> null
}?.firstOrNull()
) {
null -> {
// 失敗したら DBからデータを削除
prefDevice.cameraOpenerLastUri?.let {
activity.contentResolver.delete(it, null, null)
}
prefDevice.cameraOpenerLastUri = null
}
else -> onCaptured(item)
}
}
}
}

View File

@ -0,0 +1,72 @@
package jp.juggler.subwaytooter.util
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.checkMimeTypeAndGrant
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.isOk
/**
* 動画や音声をキャプチャする
* - Uriは呼び出し先に任せっきり
*/
class CaptureOpener(
private val onCaptured: suspend (UriAndType) -> Unit,
) {
companion object {
private val log = LogCategory("CaptureOpener")
}
private lateinit var activity: AppCompatActivity
private val prefDevice: PrefDevice
get() = activity.prefDevice
private val prCapture = permissionSpecCapture.requester {
open(
prefDevice.captureAction,
prefDevice.captureErrorCaption,
)
}
private val arCapture = ActivityResultHandler(log) { handleCaptureResult(it) }
fun register(activity: AppCompatActivity) {
this.activity = activity
prCapture.register(activity)
arCapture.register(activity)
}
fun open(
action: String?,
errorCaption: String?,
) {
action ?: return
errorCaption ?: return
prefDevice.setCaptureParams(action, errorCaption)
if (!prCapture.checkOrLaunch()) return
try {
arCapture.launch(Intent(action))
} catch (ex: Throwable) {
log.e(ex, errorCaption)
activity.showToast(ex, errorCaption)
}
}
private fun handleCaptureResult(r: ActivityResult) {
activity.launchAndShowError {
if (r.isOk) {
r.data?.checkMimeTypeAndGrant(activity.contentResolver)?.firstOrNull()?.let {
onCaptured(it)
}
}
}
}
}

View File

@ -42,7 +42,7 @@ class PermissionRequester(
private var getContext: (() -> Context?)? = null private var getContext: (() -> Context?)? = null
private val activity val activity
get() = getContext?.invoke() as? FragmentActivity get() = getContext?.invoke() as? FragmentActivity
// ActivityのonCreate()から呼び出す // ActivityのonCreate()から呼び出す
@ -63,6 +63,12 @@ class PermissionRequester(
) )
} }
fun hasPermissions() :Boolean{
val activity = activity ?: error("missing activity.")
val listNotGranted = spec.listNotGranded(activity)
return listNotGranted.isEmpty()
}
/** /**
* 実行時権限が全て揃っているならtrueを返す * 実行時権限が全て揃っているならtrueを返す
* そうでなければ権限の要求を行いfalseを返す * そうでなければ権限の要求を行いfalseを返す

View File

@ -0,0 +1,216 @@
package jp.juggler.subwaytooter.util
import android.Manifest
import android.content.Intent
import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.checkMimeTypeAndGrant
import jp.juggler.util.data.intentGetContent
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showError
import jp.juggler.util.ui.isOk
class VisualMediaPickerCompat(
private val onPicked: suspend (entries: List<UriAndType>?) -> Unit,
) {
companion object {
private val log = LogCategory("VisualMediaPickerCompat")
}
private var activity: AppCompatActivity? = null
private var prefDevice: PrefDevice? = null
private var pickMedia1: ActivityResultLauncher<PickVisualMediaRequest>? = null
private var pickMediaMultiple: ActivityResultLauncher<PickVisualMediaRequest>? = null
private var arSafPicker: ActivityResultLauncher<Intent>? = null
/**
* SAFのピッカーを使うか判定するのに使う
*/
private val prSafPickerImage = when {
// 34以降でも権限をチェック
Build.VERSION.SDK_INT >= 33 -> PermissionSpec(
permissions = listOf(
Manifest.permission.READ_MEDIA_IMAGES,
),
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,
)
}.requester {
openSafPicker(
multiple = prefDevice?.mediaPickerMultiple ?: false,
allowVideo = false,
)
}
/**
* SAFのピッカーを使うか判定するのに使う
*/
private val prSafPickerImageAndVideo = when {
// 34以降でも権限をチェック
Build.VERSION.SDK_INT >= 33 -> PermissionSpec(
permissions = listOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
),
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,
)
}.requester {
openSafPicker(
multiple = prefDevice?.mediaPickerMultiple ?: false,
allowVideo = true,
)
}
fun register(activity: AppCompatActivity, multipleLimit: Int = 4) {
prefDevice = activity.prefDevice
this.activity = activity
pickMedia1 = activity.registerForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri ->
activity.launchAndShowError {
onPicked((uri?.let { listOf(it) })?.checkMimeTypeAndGrant(activity.contentResolver))
}
}
pickMediaMultiple = activity.registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(multipleLimit),
) { uris ->
activity.launchAndShowError {
onPicked(uris?.notEmpty()?.checkMimeTypeAndGrant(activity.contentResolver))
}
}
prSafPickerImage.register(activity)
prSafPickerImageAndVideo.register(activity)
arSafPicker = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { r ->
activity.launchAndShowError {
if (r.isOk) {
onPicked(
r.data?.checkMimeTypeAndGrant(activity.contentResolver)
)
}
}
}
}
fun open(
multiple: Boolean = false,
allowVideo: Boolean = false,
) {
// SAFのピッカーに必要な権限を持っているか?
val hasSafPermissions = try {
when {
allowVideo -> prSafPickerImageAndVideo
else -> prSafPickerImage
}.hasPermissions()
} catch (ex: Throwable) {
log.e(ex, "can't check media permissions.")
activity?.showError(ex, "can't check media permissions.")
return
}
when {
// API 33まで、またはAPI34以降でも権限があればSAFのピッカーを使う
Build.VERSION.SDK_INT < 34 || hasSafPermissions -> openSafPicker(
multiple = multiple,
allowVideo = allowVideo,
)
// API34以降で権限が不十分ならJetPack Activity の写真選択ツールを使う
else -> openVisualMediaPicker(
multiple = multiple,
allowVideo = allowVideo,
)
}
}
private fun openVisualMediaPicker(
multiple: Boolean,
allowVideo: Boolean,
) {
log.i("openVisualMediaPicker multiple=$multiple, allowVideo=$allowVideo")
val mediaType = when (allowVideo) {
true -> ActivityResultContracts.PickVisualMedia.ImageAndVideo
else -> ActivityResultContracts.PickVisualMedia.ImageOnly
}
val pickerLauncher = when {
multiple -> pickMediaMultiple
else -> pickMedia1
}
pickerLauncher ?: error("openVisualMediaPicker: pickerLauncher is not registered.")
pickerLauncher.launch(PickVisualMediaRequest(mediaType))
}
private fun openSafPicker(
multiple: Boolean,
allowVideo: Boolean,
) {
log.i("openSafPicker multiple=$multiple, allowVideo=$allowVideo")
val activity = this.activity
?: error("missing activity")
prefDevice?.mediaPickerMultiple = multiple
val permissionRequester = when {
allowVideo -> prSafPickerImageAndVideo
else -> prSafPickerImage
}
if (!permissionRequester.checkOrLaunch()) return
// SAFのIntentで開く
try {
val captionId = when {
multiple -> when {
allowVideo -> R.string.pick_images_or_video
else -> R.string.pick_images
}
else -> R.string.pick_image
}
val intent = intentGetContent(
allowMultiple = true,
caption = activity.getString(captionId),
mimeTypes = when {
allowVideo -> arrayOf("image/*", "video/*")
else -> arrayOf("image/*")
}
)
arSafPicker!!.launch(intent)
} catch (ex: Throwable) {
activity.showError(ex, "openVisualMediaPicker33 failed.")
}
}
}

View File

@ -278,13 +278,9 @@ fun intentGetContent(
return Intent.createChooser(intent, caption) return Intent.createChooser(intent, caption)
} }
data class GetContentResultEntry( data class UriAndType(val uri: Uri, val mimeType: String?)
val uri: Uri,
val mimeType: String? = null,
var time: Long? = null,
)
fun MutableList<GetContentResultEntry>.addNoDuplicate( fun MutableList<UriAndType>.addNoDuplicate(
contentResolver: ContentResolver, contentResolver: ContentResolver,
uri: Uri?, uri: Uri?,
type: String? = null, type: String? = null,
@ -295,12 +291,12 @@ fun MutableList<GetContentResultEntry>.addNoDuplicate(
type ?: contentResolver.getType(uri) type ?: contentResolver.getType(uri)
} catch (ex: Throwable) { } catch (ex: Throwable) {
log.w(ex, "contentResolver.getType failed. uri=$uri") log.w(ex, "contentResolver.getType failed. uri=$uri")
return null
} }
add(GetContentResultEntry(uri, mimeType)) add(UriAndType(uri, mimeType))
} }
fun List<GetContentResultEntry>.grantPermissions( fun List<UriAndType>.grantPermissions(
contentResolver: ContentResolver, contentResolver: ContentResolver,
) { ) {
forEach { forEach {
@ -314,27 +310,32 @@ fun List<GetContentResultEntry>.grantPermissions(
} }
} }
// returns list of pair of uri and mime-type. /**
fun List<Uri>.handleGetContentResult(contentResolver: ContentResolver) = * URIのリストに対してMIMEタイプの取得とtakePersistableUriPermissionを行う
buildList { * @return UriAndTypeのリスト
this@handleGetContentResult.forEach { */
addNoDuplicate(contentResolver, it) fun List<Uri>.checkMimeTypeAndGrant(
} contentResolver: ContentResolver,
grantPermissions(contentResolver) ) = buildList {
} this@checkMimeTypeAndGrant.forEach { addNoDuplicate(contentResolver, it) }
grantPermissions(contentResolver)
}
val ClipData.uris val ClipData.uris
get() = (0 until itemCount).mapNotNull { getItemAt(it)?.uri } get() = (0 until itemCount).mapNotNull { getItemAt(it)?.uri }
// returns list of pair of uri and mime-type. /**
fun Intent.handleGetContentResult(contentResolver: ContentResolver) = * ピッカーが返したIntentからURIのリストを読みMIMEタイプの取得とtakePersistableUriPermissionを行う
buildList { * @return UriAndTypeのリスト
// 単一選択 */
addNoDuplicate(contentResolver, data, type) fun Intent.checkMimeTypeAndGrant(
contentResolver: ContentResolver,
) = buildList {
// 単一選択
addNoDuplicate(contentResolver, data, type)
// 複数選択 // 複数選択
clipData?.uris?.forEach { clipData?.uris?.forEach { addNoDuplicate(contentResolver, it) }
addNoDuplicate(contentResolver, it)
} grantPermissions(contentResolver)
grantPermissions(contentResolver) }
}

View File

@ -11,7 +11,6 @@ import android.widget.PopupWindow
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.util.coroutine.AppDispatchers.withTimeoutSafe import jp.juggler.util.coroutine.AppDispatchers.withTimeoutSafe
import jp.juggler.util.coroutine.runOnMainLooper import jp.juggler.util.coroutine.runOnMainLooper
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -181,7 +180,7 @@ fun Activity.dialogOrToast(message: String?) {
fun Activity.dialogOrToast(@StringRes stringId: Int, vararg args: Any) = fun Activity.dialogOrToast(@StringRes stringId: Int, vararg args: Any) =
dialogOrToast(getString(stringId, *args)) dialogOrToast(getString(stringId, *args))
fun AppCompatActivity.showError(ex: Throwable, caption: String? = null) { fun Activity.showError(ex: Throwable, caption: String? = null) {
log.e(ex, caption ?: "(showError)") log.e(ex, caption ?: "(showError)")
// キャンセル例外はUIに表示しない // キャンセル例外はUIに表示しない

View File

@ -0,0 +1,48 @@
package jp.juggler.util.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityOptionsCompat
import androidx.fragment.app.FragmentActivity
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
class ActivityResultHandler(
private val log: LogCategory,
private val callback: (ActivityResult) -> Unit,
) {
private var launcher: ActivityResultLauncher<Intent>? = null
private var getContext: (() -> Context?)? = null
private val context
get() = getContext?.invoke()
// startForActivityResultの代わりに呼び出す
fun launch(intent: Intent, options: ActivityOptionsCompat? = null) = try {
(launcher ?: error("ActivityResultHandler not registered."))
.launch(intent, options)
} catch (ex: Throwable) {
log.e(ex, "launch failed")
context?.showToast(ex, "activity launch failed.")
}
// onCreate時に呼び出す
fun register(a: FragmentActivity) {
getContext = { a.applicationContext }
this.launcher = a.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { callback(it) }
}
}
fun Intent.launch(ar: ActivityResultHandler) = ar.launch(this)
val ActivityResult.isNotOk
get() = Activity.RESULT_OK != resultCode
val ActivityResult.isOk
get() = Activity.RESULT_OK == resultCode

View File

@ -1,9 +1,7 @@
package jp.juggler.util.ui package jp.juggler.util.ui
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Resources import android.content.res.Resources
import android.content.res.TypedArray import android.content.res.TypedArray
@ -26,18 +24,13 @@ import android.view.View
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import jp.juggler.util.data.clip import jp.juggler.util.data.clip
import jp.juggler.util.data.notZero import jp.juggler.util.data.notZero
import jp.juggler.util.getUriExtra import jp.juggler.util.getUriExtra
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
private val log = LogCategory("UiUtils") private val log = LogCategory("UiUtils")
@ -342,45 +335,9 @@ var View.isEnabledAlpha: Boolean
///////////////////////////////////////////////// /////////////////////////////////////////////////
class ActivityResultHandler(
private val log: LogCategory,
private val callback: (ActivityResult) -> Unit,
) {
private var launcher: ActivityResultLauncher<Intent>? = null
private var getContext: (() -> Context?)? = null
private val context
get() = getContext?.invoke()
// startForActivityResultの代わりに呼び出す
fun launch(intent: Intent, options: ActivityOptionsCompat? = null) = try {
(launcher ?: error("ActivityResultHandler not registered."))
.launch(intent, options)
} catch (ex: Throwable) {
log.e(ex, "launch failed")
context?.showToast(ex, "activity launch failed.")
}
// onCreate時に呼び出す
fun register(a: FragmentActivity) {
getContext = { a.applicationContext }
this.launcher = a.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { callback(it) }
}
}
fun Intent.launch(ar: ActivityResultHandler) = ar.launch(this)
val AppCompatActivity.isLiveActivity: Boolean val AppCompatActivity.isLiveActivity: Boolean
get() = !(isFinishing || isDestroyed) get() = !(isFinishing || isDestroyed)
val ActivityResult.isNotOk
get() = Activity.RESULT_OK != resultCode
val ActivityResult.isOk
get() = Activity.RESULT_OK == resultCode
/** /**
* Ringtone pickerの処理結果のUriまたはnull * Ringtone pickerの処理結果のUriまたはnull
*/ */