diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f242dd3a..fe095056 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,7 @@ import java.util.Properties plugins { id("com.android.application") id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.serialization") + kotlin("plugin.serialization") id("com.google.devtools.ksp") id("io.gitlab.arturbosch.detekt") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef15f5e4..a6037b45 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,10 +63,14 @@ android:maxSdkVersion="32" tools:ignore="ScopedStorage" /> + + + + diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt b/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt index bf0b1182..fa3915c7 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt @@ -14,43 +14,75 @@ import android.text.Editable import android.text.SpannableString import android.text.TextWatcher 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.AppCompatActivity import androidx.core.content.ContextCompat import com.jrummyapps.android.colorpicker.ColorPickerDialog 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.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.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.dialog.DlgConfirm.confirm 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.pushRepo import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.subwaytooter.table.daoSavedAccount 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.launchAndShowError import jp.juggler.util.coroutine.launchMain 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.showToast import jp.juggler.util.log.withCaption +import jp.juggler.util.long import jp.juggler.util.media.ResizeConfig import jp.juggler.util.media.ResizeType import jp.juggler.util.media.createResizedBitmap import jp.juggler.util.network.toPatch import jp.juggler.util.network.toPost 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.serialization.encodeToString import okhttp3.MediaType @@ -164,61 +196,39 @@ class ActAccountSetting : AppCompatActivity(), loadLanguageList() } - /////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////// + + private var permissionCamera = permissionSpecCamera.requester { + openCamera() + } + + private var pickImageLauncher: ActivityResultLauncher? = null + + private val pickImageCallback = ActivityResultCallback { + handlePickImageResult(it) + } + + private val arCameraImage = ActivityResultHandler(log) { + handleCameraResult(it) + } private val arShowAcctColor = ActivityResultHandler(log) { r -> - if (r.isNotOk) return@ActivityResultHandler - showAcctColor() + if (r.isOk) 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?) { super.onCreate(savedInstanceState) backPressed { handleBackPressed() } - prPickAvater.register(this) - prPickHeader.register(this) + pickImageLauncher = registerForActivityResult( + ActivityResultContracts.PickVisualMedia(), + pickImageCallback, + ) + permissionCamera.register(this) arShowAcctColor.register(this) - arAddAttachment.register(this) arCameraImage.register(this) if (savedInstanceState != null) { @@ -1335,64 +1345,89 @@ class ActAccountSetting : AppCompatActivity(), } private fun pickAvatarImage() { - openPicker(prPickAvater) + openImagePickerOrCamera("avatar") } private fun pickHeaderImage() { - openPicker(prPickHeader) + openImagePickerOrCamera("header") } - private fun openPicker(permissionRequester: PermissionRequester) { + private fun openImagePickerOrCamera(propName: String) { + state.propName = propName launchAndShowError { - if (!permissionRequester.checkOrLaunch()) return@launchAndShowError - val propName = when (permissionRequester) { - prPickHeader -> "header" - else -> "avatar" - } actionsDialog { action(getString(R.string.pick_image)) { - performAttachment(propName) + openPickImage() } action(getString(R.string.image_capture)) { - performCamera(propName) + openCamera() } } } } - private fun performAttachment(propName: String) { - try { - state.propName = propName - val intent = intentGetContent(false, getString(R.string.pick_image), arrayOf("image/*")) - arAddAttachment.launch(intent) - } catch (ex: Throwable) { - log.e(ex, "performAttachment failed.") - showToast(ex, "performAttachment failed.") - } + private fun openPickImage() { + (pickImageLauncher ?: error("pickImageLauncher not registered")).launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly, + ) + ) } - 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 values = ContentValues() - values.put(MediaStore.Images.Media.TITLE, filename) - values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + 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) - state.uriCameraImage = uri + .also { state.uriCameraImage = it } - val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - intent.putExtra(MediaStore.EXTRA_OUTPUT, uri) - - state.propName = propName + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, uri) + } 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 { val mimeType: String val uri: Uri diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt index 86d89ea2..deec86cb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt @@ -22,7 +22,6 @@ import jp.juggler.subwaytooter.actpost.CompletionHelper import jp.juggler.subwaytooter.actpost.FeaturedTagCache import jp.juggler.subwaytooter.actpost.addAttachment import jp.juggler.subwaytooter.actpost.applyMushroomText -import jp.juggler.subwaytooter.actpost.rearrangeAttachments import jp.juggler.subwaytooter.actpost.onPickCustomThumbnailImpl import jp.juggler.subwaytooter.actpost.onPostAttachmentCompleteImpl 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.performPost import jp.juggler.subwaytooter.actpost.performSchedule +import jp.juggler.subwaytooter.actpost.rearrangeAttachments import jp.juggler.subwaytooter.actpost.removeReply import jp.juggler.subwaytooter.actpost.resetSchedule import jp.juggler.subwaytooter.actpost.restoreState @@ -82,7 +82,7 @@ import java.util.concurrent.ConcurrentHashMap class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callback, - MyClickableSpanHandler, AttachmentPicker.Callback { + MyClickableSpanHandler { companion object { private val log = LogCategory("ActPost") @@ -215,7 +215,21 @@ class ActPost : AppCompatActivity(), appState = App1.getAppState(this) handler = appState.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 arMushroom.register(this) @@ -315,6 +329,7 @@ class ActPost : AppCompatActivity(), R.id.btnFeaturedTag -> completionHelper.openFeaturedTagList( featuredTagCache[account?.acct?.ascii ?: ""]?.list ) + R.id.btnAttachmentsRearrange -> rearrangeAttachments() R.id.ibSchedule -> performSchedule() R.id.ibScheduleReset -> resetSchedule() @@ -337,10 +352,6 @@ class ActPost : AppCompatActivity(), openBrowser(span.linkInfo.url) } - override fun onPickAttachment(uri: Uri, mimeType: String?) { - addAttachment(uri, mimeType) - } - override fun onPostAttachmentProgress() { launchIO { try { @@ -355,15 +366,6 @@ class ActPost : AppCompatActivity(), 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() { setContentView(views.root) diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt index c905bc27..a007deb9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt @@ -269,7 +269,9 @@ fun ActPost.performAttachmentClick(idx: Int) { TootAttachmentType.GIFV, TootAttachmentType.Video, -> action(getString(R.string.custom_thumbnail)) { - attachmentPicker.openCustomThumbnail(pa) + attachmentPicker.openCustomThumbnail( + attachmentId = pa.attachment?.id?.toString() + ) } else -> Unit diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt index c94bb145..9e9c7c69 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt @@ -4,6 +4,11 @@ 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 @@ -17,8 +22,8 @@ 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.decodeFromString import kotlinx.serialization.encodeToString class AttachmentPicker( @@ -33,9 +38,8 @@ class AttachmentPicker( // callback after media selected interface Callback { - fun onPickAttachment(uri: Uri, mimeType: String? = null) - fun onPickCustomThumbnail(pa: PostAttachment, src: GetContentResultEntry) - fun resumeCustomThumbnailTarget(id: String?): PostAttachment? + suspend fun onPickAttachment(uri: Uri, mimeType: String? = null) + suspend fun onPickCustomThumbnail(attachmentId: String?, src: GetContentResultEntry) } // actions after permission granted @@ -43,7 +47,6 @@ class AttachmentPicker( @Serializable data class States( - @Serializable(with = UriSerializer::class) var uriCameraImage: Uri? = null, @@ -55,59 +58,48 @@ class AttachmentPicker( //////////////////////////////////////////////////////////////////////// // activity result handlers - private val arAttachmentChooser = ActivityResultHandler(log) { r -> + private var pickThumbnailLauncher: ActivityResultLauncher? = null + + private val pickThumbnailCallback = ActivityResultCallback { + handleThumbnailResult(it) + } + + private var pickVisualMediaLauncher: ActivityResultLauncher? = null + + private val pickVisualMediaCallback = ActivityResultCallback?> { uris -> + uris?.handleGetContentResult(activity.contentResolver)?.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 arCamera = ActivityResultHandler(log) { r -> - if (r.isNotOk) { - // 失敗したら 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 prCamera = permissionSpecCamera.requester { openStillCamera() } + private val arCamera = ActivityResultHandler(log) { handleCameraResult(it) } - private val arCapture = ActivityResultHandler(log) { r -> - if (r.isNotOk) return@ActivityResultHandler - 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) } - } + private val prCapture = permissionSpecCapture.requester { openPicker() } + private val arCapture = ActivityResultHandler(log) { handleCaptureResult(it) } init { // must register all ARHs before onStart - prPickAttachment.register(activity) - prPickCustomThumbnail.register(activity) - arAttachmentChooser.register(activity) + prPickAudio.register(activity) + arPickAudio.register(activity) + + prCamera.register(activity) arCamera.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() { - if (!prPickAttachment.checkOrLaunch()) return activity.run { launchAndShowError { actionsDialog { - action(getString(R.string.pick_images)) { - openAttachmentChooser(R.string.pick_images, "image/*", "video/*") - } - action(getString(R.string.pick_videos)) { - openAttachmentChooser(R.string.pick_videos, "video/*") + action(getString(R.string.pick_images_or_video)) { + openVisualMediaPicker() } action(getString(R.string.pick_audios)) { - openAttachmentChooser(R.string.pick_audios, "audio/*") + openAudioPicker() } action(getString(R.string.image_capture)) { - performCamera() + openStillCamera() } action(getString(R.string.video_capture)) { performCapture( @@ -165,43 +153,74 @@ class AttachmentPicker( } } - private fun openAttachmentChooser(titleId: Int, vararg mimeTypes: String) { - // SAFのIntentで開く - try { - val intent = intentGetContent(true, activity.getString(titleId), mimeTypes) - arAttachmentChooser.launch(intent) - } catch (ex: Throwable) { - log.e(ex, "openAttachmentChooser failed.") - activity.showToast(ex, "openAttachmentChooser failed.") + private fun openVisualMediaPicker() { + (pickVisualMediaLauncher + ?: error("pickVisualMediaLauncher is not registered.")) + .launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageAndVideo + ) + ) + } + + 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() { - try { - val values = ContentValues().apply { - put(MediaStore.Images.Media.TITLE, "${System.currentTimeMillis()}.jpg") - put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - } - - val newUri = - activity.contentResolver.insert( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - values - ) - .also { states.uriCameraImage = it } + 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) - } 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 { arCapture.launch(Intent(action)) } catch (ex: Throwable) { @@ -210,28 +229,46 @@ class AttachmentPicker( } } - private fun ArrayList.pickAll() = - forEach { callback.onPickAttachment(it.uri, it.mimeType) } + 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.pickAll() { + activity.launchAndShowError { + forEach { callback.onPickAttachment(it.uri, it.mimeType) } + } + } /////////////////////////////////////////////////////////////////////////////// // Mastodon's custom thumbnail - fun openCustomThumbnail(pa: PostAttachment) { - try { - states.customThumbnailTargetId = pa.attachment?.id?.toString() - ?: return - if (!prPickCustomThumbnail.checkOrLaunch()) return - // SAFのIntentで開く - arCustomThumbnail.launch( - intentGetContent( - false, - activity.getString(R.string.pick_images), - arrayOf("image/*") + fun openCustomThumbnail(attachmentId: String?) { + states.customThumbnailTargetId = attachmentId + ?: error("attachmentId is null") + activity.launchAndShowError { + (pickThumbnailLauncher + ?: error("pickThumbnailLauncher is not registered.")) + .launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly, + ) ) - ) - } 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) + } } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/PermissionSpec.kt b/app/src/main/java/jp/juggler/subwaytooter/util/PermissionSpec.kt index 95a6d42f..4cbc911b 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/PermissionSpec.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/PermissionSpec.kt @@ -43,50 +43,111 @@ class PermissionSpec( } } -val permissionSpecNotification = if (Build.VERSION.SDK_INT >= 33) { - PermissionSpec( +val permissionSpecNotification = when { + Build.VERSION.SDK_INT >= 33 -> PermissionSpec( permissions = listOf( Manifest.permission.POST_NOTIFICATIONS, ), deniedId = R.string.permission_denied_notifications, rationalId = R.string.permission_rational_notifications, ) -} else { - PermissionSpec( + else -> PermissionSpec( permissions = emptyList(), deniedId = R.string.permission_denied_notifications, rationalId = R.string.permission_rational_notifications, ) } -val permissionSpecMediaDownload = if (Build.VERSION.SDK_INT >= 33) { - PermissionSpec( +val permissionSpecMediaDownload = when { + Build.VERSION.SDK_INT >= 33 -> PermissionSpec( permissions = listOf(Manifest.permission.POST_NOTIFICATIONS), deniedId = R.string.permission_denied_download_manager, rationalId = R.string.permission_rational_download_manager, ) -} else { - PermissionSpec( + else -> PermissionSpec( permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), deniedId = R.string.permission_denied_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( Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.READ_MEDIA_AUDIO, ), deniedId = R.string.permission_denied_media_access, rationalId = R.string.permission_rational_media_access, ) -} else { - PermissionSpec( + else -> PermissionSpec( permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), deniedId = R.string.permission_denied_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, + ) +} diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b72d6040..6135e7b7 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -537,6 +537,7 @@ 性能 画像を選択 画像を選択… + 画像や動画を選択… 動画を選択… 音声ファイルを選択… アカウントがありません。事前にアカウントの追加を行ってください。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75617731..cc7c061b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -320,6 +320,7 @@ Color and Background… Column background Pick an image + Pick images or video… Pick image(s)… Pick video(s)… Choose audio file(s)… diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 47f667d6..a6790d35 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -105,7 +105,7 @@ dependencies { api("org.jetbrains.kotlinx:kotlinx-coroutines-android:${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-serialization-json:1.5.1") + api("org.jetbrains.kotlinx:kotlinx-serialization-json:${Vers.kotlinxSerializationLibVersion}") api("ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0") //non-OSS dependency api "androidx.media3:media3-cast:$media3Version" diff --git a/base/src/main/java/jp/juggler/util/data/StorageUtils.kt b/base/src/main/java/jp/juggler/util/data/StorageUtils.kt index 355558f6..fccaf4f0 100644 --- a/base/src/main/java/jp/juggler/util/data/StorageUtils.kt +++ b/base/src/main/java/jp/juggler/util/data/StorageUtils.kt @@ -1,5 +1,6 @@ package jp.juggler.util.data +import android.content.ClipData import android.content.ContentResolver import android.content.Context import android.content.Intent @@ -283,28 +284,26 @@ data class GetContentResultEntry( var time: Long? = null, ) -// returns list of pair of uri and mime-type. -fun Intent.handleGetContentResult(contentResolver: ContentResolver): ArrayList { - val urlList = ArrayList() - // 単一選択 - data?.let { - val mimeType = try { - type ?: contentResolver.getType(it) - } catch (ex: Throwable) { - log.w(ex, "contentResolver.getType failed. uri=$it") - null - } - urlList.add(GetContentResultEntry(it, mimeType)) +fun MutableList.addNoDuplicate( + contentResolver: ContentResolver, + uri: Uri?, + type: String? = null, +) { + uri ?: return + if (any { it.uri == uri }) return + val mimeType = try { + type ?: contentResolver.getType(uri) + } catch (ex: Throwable) { + log.w(ex, "contentResolver.getType failed. uri=$uri") + return } - // 複数選択 - this.clipData?.let { clipData -> - for (uri in (0 until clipData.itemCount).mapNotNull { clipData.getItemAt(it)?.uri }) { - if (urlList.none { it.uri == uri }) { - urlList.add(GetContentResultEntry(uri)) - } - } - } - urlList.forEach { + add(GetContentResultEntry(uri, mimeType)) +} + +fun List.grantPermissions( + contentResolver: ContentResolver, +) { + forEach { try { contentResolver.takePersistableUriPermission( it.uri, @@ -313,5 +312,29 @@ fun Intent.handleGetContentResult(contentResolver: ContentResolver): ArrayList.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) + } diff --git a/base/src/main/java/jp/juggler/util/ui/UiUtils.kt b/base/src/main/java/jp/juggler/util/ui/UiUtils.kt index 3c6b93dc..c239d6f6 100644 --- a/base/src/main/java/jp/juggler/util/ui/UiUtils.kt +++ b/base/src/main/java/jp/juggler/util/ui/UiUtils.kt @@ -378,6 +378,9 @@ val AppCompatActivity.isLiveActivity: Boolean val ActivityResult.isNotOk get() = Activity.RESULT_OK != resultCode +val ActivityResult.isOk + get() = Activity.RESULT_OK == resultCode + /** * Ringtone pickerの処理結果のUriまたはnull */ diff --git a/build.gradle.kts b/build.gradle.kts index 05929c51..898f0a75 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,8 @@ buildscript { } 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("com.google.devtools.ksp") version (Vers.kspVersion) apply false } diff --git a/buildSrc/src/main/java/Vers.kt b/buildSrc/src/main/java/Vers.kt index 21bc0f33..b2b53b07 100644 --- a/buildSrc/src/main/java/Vers.kt +++ b/buildSrc/src/main/java/Vers.kt @@ -36,6 +36,8 @@ object Vers { const val koinVersion = "3.5.0" const val kotlinTestVersion = kotlinVersion // "1.9.22" 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 lifecycleVersion = "2.6.2" const val materialVersion = "1.11.0"