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"