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 {
targetSdk = Vers.stTargetSdkVersion
minSdk = Vers.stMinSdkVersion
versionCode = 544
versionName = "5.544"
versionCode = 545
versionName = "5.545"
applicationId = "jp.juggler.subwaytooter"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@ -1,7 +1,6 @@
package jp.juggler.subwaytooter
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
@ -9,7 +8,6 @@ import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.provider.MediaStore
import android.text.Editable
import android.text.SpannableString
import android.text.TextWatcher
@ -21,11 +19,6 @@ 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.AppCompatActivity
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.launchMain
import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.UriAndType
import jp.juggler.util.data.UriSerializer
import jp.juggler.util.data.getDocumentName
import jp.juggler.util.data.getStreamSize
@ -198,18 +192,12 @@ class ActAccountSetting : AppCompatActivity(),
/////////////////////////////////////////////////////////////////////
private var permissionCamera = permissionSpecCamera.requester {
openCamera()
private val cameraOpener = CameraOpener {
uploadImage(state.propName, it)
}
private var pickImageLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null
private val pickImageCallback = ActivityResultCallback<Uri?> {
handlePickImageResult(it)
}
private val arCameraImage = ActivityResultHandler(log) {
handleCameraResult(it)
private val visualMediaPicker = VisualMediaPickerCompat {
uploadImage(state.propName, it?.firstOrNull())
}
private val arShowAcctColor = ActivityResultHandler(log) { r ->
@ -222,14 +210,10 @@ class ActAccountSetting : AppCompatActivity(),
super.onCreate(savedInstanceState)
backPressed { handleBackPressed() }
pickImageLauncher = registerForActivityResult(
ActivityResultContracts.PickVisualMedia(),
pickImageCallback,
)
visualMediaPicker.register(this)
cameraOpener.register(this)
permissionCamera.register(this)
arShowAcctColor.register(this)
arCameraImage.register(this)
if (savedInstanceState != null) {
savedInstanceState.getString(ACTIVITY_STATE)
@ -1357,75 +1341,15 @@ class ActAccountSetting : AppCompatActivity(),
launchAndShowError {
actionsDialog {
action(getString(R.string.pick_image)) {
openPickImage()
visualMediaPicker.open()
}
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 {
@ -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) {
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.data.cast
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.notEmpty
import jp.juggler.util.data.notZero
@ -132,7 +132,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli
private val arImportAppData = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(contentResolver)
r.data?.checkMimeTypeAndGrant(contentResolver)
?.firstOrNull()
?.uri?.let { importAppData2(false, it) }
}
@ -1031,7 +1031,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli
private fun handleFontResult(item: AppSettingItem?, data: Intent, fileName: String) {
item ?: error("handleFontResult : setting item is null")
data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let {
data.checkMimeTypeAndGrant(contentResolver).firstOrNull()?.uri?.let {
val file = saveTimelineFont(it, fileName)
if (file != null) {
(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 ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(contentResolver)
r.data?.checkMimeTypeAndGrant(contentResolver)
?.firstOrNull()?.uri?.let { updateBackground(it) }
}

View File

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

View File

@ -2,7 +2,6 @@ package jp.juggler.subwaytooter
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.text.Editable
@ -65,7 +64,7 @@ import jp.juggler.util.backPressed
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchIO
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.showToast
import jp.juggler.util.string
@ -216,14 +215,15 @@ class ActPost : AppCompatActivity(),
handler = appState.handler
attachmentUploader = AttachmentUploader(this, handler)
attachmentPicker = AttachmentPicker(this, object : AttachmentPicker.Callback {
override suspend fun onPickAttachment(uri: Uri, mimeType: String?) {
addAttachment(uri, mimeType)
override suspend fun onPickAttachment(item: UriAndType) {
addAttachment(item.uri, item.mimeType)
}
override suspend fun onPickCustomThumbnail(
attachmentId: String?,
src: GetContentResultEntry,
src: UriAndType?,
) {
src ?: return
val pa = attachmentList.find { it.attachment?.id?.toString() == attachmentId }
?: error("missing attachment for attachmentId=$attachmentId")
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.view.MyNetworkImageView
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
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.decodeJsonArray
import jp.juggler.util.data.notEmpty
@ -269,9 +268,7 @@ fun ActPost.performAttachmentClick(idx: Int) {
TootAttachmentType.GIFV,
TootAttachmentType.Video,
-> action(getString(R.string.custom_thumbnail)) {
attachmentPicker.openCustomThumbnail(
attachmentId = pa.attachment?.id?.toString()
)
attachmentPicker.openThumbnailPicker(pa)
}
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) {
null -> showToast(false, R.string.account_select_please)
else -> launchMain {
if (pa.attachment?.isEdit == true) {
showToast(
true,
"Sorry, updateing thumbnail is not yet supported in case of editing post."
)
} else {
val result = attachmentUploader.uploadCustomThumbnail(account, src, pa)
result?.error?.let { showToast(true, it) }
showMediaAttachment()
}
else -> if (pa.attachment?.isEdit == true) {
showToast(
true,
"Sorry, updateing thumbnail is not yet supported in case of editing post."
)
} else {
val result = attachmentUploader.uploadCustomThumbnail(account, src, pa)
result?.error?.let { showToast(true, it) }
showMediaAttachment()
}
}
}

View File

@ -3,7 +3,11 @@ package jp.juggler.subwaytooter.actpost
import android.os.Bundle
import jp.juggler.subwaytooter.ActPost
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.util.AttachmentPicker
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.log.LogCategory
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
private val log = LogCategory("ActPostStates")

View File

@ -3,10 +3,12 @@ package jp.juggler.subwaytooter.pref
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Rect
import android.net.Uri
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import jp.juggler.util.data.mayUri
import jp.juggler.util.os.applicationContextSafe
import java.util.*
import java.util.UUID
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_SUPRESS_REQUEST_NOTIFICATION_PERMISSION =
"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_NONE = "none"
@ -153,6 +159,31 @@ class PrefDevice(context: Context) {
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
import android.content.ContentValues
import android.content.Intent
import android.net.Uri
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 jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.kJson
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.GetContentResultEntry
import jp.juggler.util.data.UriSerializer
import jp.juggler.util.data.handleGetContentResult
import jp.juggler.util.data.intentGetContent
import jp.juggler.util.data.UriAndType
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.encodeToString
@ -38,18 +23,15 @@ class AttachmentPicker(
// callback after media selected
interface Callback {
suspend fun onPickAttachment(uri: Uri, mimeType: String? = null)
suspend fun onPickCustomThumbnail(attachmentId: String?, src: GetContentResultEntry)
suspend fun onPickAttachment(item: UriAndType)
suspend fun onPickCustomThumbnail(attachmentId: String?, src: UriAndType?)
}
// actions after permission granted
enum class AfterPermission { Attachment, CustomThumbnail, }
// // actions after permission granted
// enum class AfterPermission { Attachment, CustomThumbnail, }
@Serializable
data class States(
@Serializable(with = UriSerializer::class)
var uriCameraImage: Uri? = null,
var customThumbnailTargetId: String? = null,
)
@ -58,67 +40,43 @@ class AttachmentPicker(
////////////////////////////////////////////////////////////////////////
// activity result handlers
private var pickThumbnailLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null
private val pickThumbnailCallback = ActivityResultCallback<Uri?> {
handleThumbnailResult(it)
private val visualMediaPickerThumbnail = VisualMediaPickerCompat {
callback.onPickCustomThumbnail(states.customThumbnailTargetId, it?.firstOrNull())
}
private var pickVisualMediaLauncher: ActivityResultLauncher<PickVisualMediaRequest>? = null
private val visualMediaPickerAttachment = VisualMediaPickerCompat { it?.pickAll() }
private val pickVisualMediaCallback = ActivityResultCallback<List<Uri>?> { uris ->
uris?.handleGetContentResult(activity.contentResolver)?.pickAll()
}
private val audioPicker = AudioPicker { it?.pickAll() }
private val prPickAudio = permissionSpecAudioPicker.requester { openAudioPicker() }
private val arPickAudio = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.handleGetContentResult(activity.contentResolver)?.pickAll()
}
private val cameraOpener = CameraOpener { callback.onPickAttachment(it) }
private val prCamera = permissionSpecCamera.requester { openStillCamera() }
private val arCamera = ActivityResultHandler(log) { handleCameraResult(it) }
private val prCapture = permissionSpecCapture.requester { openPicker() }
private val arCapture = ActivityResultHandler(log) { handleCaptureResult(it) }
private val captureOpener = CaptureOpener { callback.onPickAttachment(it) }
init {
// must register all ARHs before onStart
prPickAudio.register(activity)
arPickAudio.register(activity)
prCamera.register(activity)
arCamera.register(activity)
arCapture.register(activity)
pickVisualMediaLauncher = activity.registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(4),
pickVisualMediaCallback,
)
pickThumbnailLauncher = activity.registerForActivityResult(
ActivityResultContracts.PickVisualMedia(),
pickThumbnailCallback,
)
visualMediaPickerAttachment.register(activity)
visualMediaPickerThumbnail.register(activity)
cameraOpener.register(activity)
audioPicker.register(activity)
captureOpener.register(activity)
}
////////////////////////////////////////////////////////////////////////
// states
fun reset() {
states.uriCameraImage = null
cameraOpener.reset()
}
fun encodeState(): String {
val encoded = kJson.encodeToString(states)
val decoded = kJson.decodeFromString<States>(encoded)
log.d("encodeState: ${decoded.uriCameraImage},$encoded")
log.d("encodeState: states=$states, encoded=$encoded, decoded=$decoded")
return encoded
}
fun restoreState(encoded: String) {
states = kJson.decodeFromString(encoded)
log.d("restoreState: ${states.uriCameraImage},$encoded")
log.d("restoreState: states=$states, encoded=$encoded")
}
////////////////////////////////////////////////////////////////////////
@ -128,22 +86,25 @@ class AttachmentPicker(
launchAndShowError {
actionsDialog {
action(getString(R.string.pick_images_or_video)) {
openVisualMediaPicker()
visualMediaPickerAttachment.open(
multiple = true,
allowVideo = true,
)
}
action(getString(R.string.pick_audios)) {
openAudioPicker()
audioPicker.open()
}
action(getString(R.string.image_capture)) {
openStillCamera()
cameraOpener.open()
}
action(getString(R.string.video_capture)) {
performCapture(
captureOpener.open(
MediaStore.ACTION_VIDEO_CAPTURE,
"can't open video capture app."
)
}
action(getString(R.string.voice_capture)) {
performCapture(
captureOpener.open(
MediaStore.Audio.Media.RECORD_SOUND_ACTION,
"can't open voice capture app."
)
@ -153,122 +114,15 @@ class AttachmentPicker(
}
}
private fun openVisualMediaPicker() {
(pickVisualMediaLauncher
?: error("pickVisualMediaLauncher is not registered."))
.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageAndVideo
)
)
private suspend fun List<UriAndType>.pickAll() {
forEach { callback.onPickAttachment(it) }
}
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 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)
}
}
// ActPostAttachmentから呼ばれる
fun openThumbnailPicker(pa: PostAttachment) {
states.customThumbnailTargetId =
pa.attachment?.id?.toString()
?: error("attachmentId is null")
visualMediaPickerThumbnail.open()
}
}

View File

@ -20,7 +20,7 @@ import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.coroutine.AppDispatchers
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.buildJsonObject
import jp.juggler.util.data.encodeHex
@ -316,7 +316,7 @@ class AttachmentUploader(
// 添付データのカスタムサムネイル
suspend fun uploadCustomThumbnail(
account: SavedAccount,
src: GetContentResultEntry,
src: UriAndType,
pa: PostAttachment,
): TootApiResult? = try {
safeContext.runApiTask(account) { client ->
@ -335,8 +335,9 @@ class AttachmentUploader(
val maxBytesImage = ar.maxBytesImage(instance, mediaConfig)
val opener = ar.createOpener()
try {
pa.progress = ""
try {
if (opener.contentLength > maxBytesImage.toLong()) {
return@runApiTask TootApiResult(
getString(
@ -354,6 +355,7 @@ class AttachmentUploader(
if (account.isMisskey) {
TootApiResult("custom thumbnail is not supported on misskey account.")
} else {
val result = client.request(
"/api/v1/media/${pa.attachment?.id}",
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 val activity
val activity
get() = getContext?.invoke() as? FragmentActivity
// 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を返す
* そうでなければ権限の要求を行い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)
}
data class GetContentResultEntry(
val uri: Uri,
val mimeType: String? = null,
var time: Long? = null,
)
data class UriAndType(val uri: Uri, val mimeType: String?)
fun MutableList<GetContentResultEntry>.addNoDuplicate(
fun MutableList<UriAndType>.addNoDuplicate(
contentResolver: ContentResolver,
uri: Uri?,
type: String? = null,
@ -295,12 +291,12 @@ fun MutableList<GetContentResultEntry>.addNoDuplicate(
type ?: contentResolver.getType(uri)
} catch (ex: Throwable) {
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,
) {
forEach {
@ -314,27 +310,32 @@ fun List<GetContentResultEntry>.grantPermissions(
}
}
// returns list of pair of uri and mime-type.
fun List<Uri>.handleGetContentResult(contentResolver: ContentResolver) =
buildList {
this@handleGetContentResult.forEach {
addNoDuplicate(contentResolver, it)
}
grantPermissions(contentResolver)
}
/**
* URIのリストに対してMIMEタイプの取得とtakePersistableUriPermissionを行う
* @return UriAndTypeのリスト
*/
fun List<Uri>.checkMimeTypeAndGrant(
contentResolver: ContentResolver,
) = buildList {
this@checkMimeTypeAndGrant.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)
/**
* ピッカーが返したIntentからURIのリストを読みMIMEタイプの取得とtakePersistableUriPermissionを行う
* @return UriAndTypeのリスト
*/
fun Intent.checkMimeTypeAndGrant(
contentResolver: ContentResolver,
) = buildList {
// 単一選択
addNoDuplicate(contentResolver, data, type)
// 複数選択
clipData?.uris?.forEach {
addNoDuplicate(contentResolver, it)
}
grantPermissions(contentResolver)
}
// 複数選択
clipData?.uris?.forEach { addNoDuplicate(contentResolver, it) }
grantPermissions(contentResolver)
}

View File

@ -11,7 +11,6 @@ import android.widget.PopupWindow
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.util.coroutine.AppDispatchers.withTimeoutSafe
import jp.juggler.util.coroutine.runOnMainLooper
import kotlinx.coroutines.CancellationException
@ -181,7 +180,7 @@ fun Activity.dialogOrToast(message: String?) {
fun Activity.dialogOrToast(@StringRes stringId: Int, vararg args: Any) =
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)")
// キャンセル例外は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
import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources
import android.content.res.TypedArray
@ -26,18 +24,13 @@ import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import jp.juggler.util.data.clip
import jp.juggler.util.data.notZero
import jp.juggler.util.getUriExtra
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
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
get() = !(isFinishing || isDestroyed)
val ActivityResult.isNotOk
get() = Activity.RESULT_OK != resultCode
val ActivityResult.isOk
get() = Activity.RESULT_OK == resultCode
/**
* Ringtone pickerの処理結果のUriまたはnull
*/