diff --git a/app/build.gradle b/app/build.gradle index 54419025..2ddae5ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,7 +85,7 @@ android { } } -ext.lifecycleVersion = "2.2.0" +ext.lifecycleVersion = "2.3.1" ext.roomVersion = '2.3.0' ext.retrofitVersion = '2.9.0' ext.okhttpVersion = '4.9.1' @@ -97,16 +97,16 @@ ext.materialdrawerVersion = '8.2.0' dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "androidx.core:core-ktx:1.3.2" - implementation "androidx.appcompat:appcompat:1.2.0" - implementation "androidx.fragment:fragment-ktx:1.2.5" + implementation "androidx.core:core-ktx:1.5.0" + implementation "androidx.appcompat:appcompat:1.3.0" + implementation "androidx.fragment:fragment-ktx:1.3.3" implementation "androidx.browser:browser:1.3.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.recyclerview:recyclerview:1.2.0" implementation "androidx.exifinterface:exifinterface:1.3.2" implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.preference:preference-ktx:1.1.1" - implementation "androidx.sharetarget:sharetarget:1.0.0" + implementation "androidx.sharetarget:sharetarget:1.1.0" implementation "androidx.emoji:emoji:1.1.0" implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" @@ -116,7 +116,7 @@ dependencies { implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation "androidx.paging:paging-runtime-ktx:2.1.2" implementation "androidx.viewpager2:viewpager2:1.0.0" - implementation "androidx.work:work-runtime:2.4.0" + implementation "androidx.work:work-runtime:2.5.0" implementation "androidx.room:room-runtime:$roomVersion" implementation "androidx.room:room-rxjava3:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 92994f16..e348f036 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -199,6 +199,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requesters.containsKey(requestCode)) { PermissionRequester requester = requesters.remove(requestCode); requester.onRequestPermissionsResult(permissions, grantResults); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index f71f2653..9458b26a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.components.compose import android.Manifest -import android.app.Activity import android.app.ProgressDialog import android.content.Context import android.content.Intent @@ -28,13 +27,13 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Parcelable -import android.provider.MediaStore import android.util.Log import android.view.KeyEvent import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.* +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.StringRes @@ -85,12 +84,12 @@ import kotlin.math.max import kotlin.math.min class ComposeActivity : BaseActivity(), - ComposeOptionsListener, - ComposeAutoCompleteAdapter.AutocompletionProvider, - OnEmojiSelectedListener, - Injectable, - InputConnectionCompat.OnCommitContentListener, - ComposeScheduleView.OnTimeSetListener { + ComposeOptionsListener, + ComposeAutoCompleteAdapter.AutocompletionProvider, + OnEmojiSelectedListener, + Injectable, + InputConnectionCompat.OnCommitContentListener, + ComposeScheduleView.OnTimeSetListener { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -114,6 +113,21 @@ class ComposeActivity : BaseActivity(), private val maxUploadMediaNumber = 4 private var mediaCount = 0 + private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success) { + pickMedia(photoUploadUri!!) + } + } + private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris -> + if (mediaCount + uris.size > maxUploadMediaNumber) { + Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() + } else { + uris.forEach { uri -> + pickMedia(uri) + } + } + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -130,16 +144,16 @@ class ComposeActivity : BaseActivity(), setupAvatar(preferences, activeAccount) val mediaAdapter = MediaPreviewAdapter( - this, - onAddCaption = { item -> - makeCaptionDialog(item.description, item.uri) { newDescription -> - viewModel.updateDescription(item.localId, newDescription) - } - }, - onRemove = this::removeMediaFromQueue + this, + onAddCaption = { item -> + makeCaptionDialog(item.description, item.uri) { newDescription -> + viewModel.updateDescription(item.localId, newDescription) + } + }, + onRemove = this::removeMediaFromQueue ) binding.composeMediaPreviewBar.layoutManager = - LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.itemAnimator = null @@ -255,11 +269,11 @@ class ComposeActivity : BaseActivity(), binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } binding.composeEditField.setAdapter( - ComposeAutoCompleteAdapter( - this, - preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ) + ComposeAutoCompleteAdapter( + this, + preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) ) binding.composeEditField.setTokenizer(ComposeTokenizer()) @@ -275,7 +289,7 @@ class ComposeActivity : BaseActivity(), // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O - || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { + || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) } } @@ -390,13 +404,13 @@ class ComposeActivity : BaseActivity(), val animateAvatars = preferences.getBoolean("animateGifAvatars", false) loadAvatar( - activeAccount.profilePictureUrl, - binding.composeAvatar, - avatarSize / 8, - animateAvatars + activeAccount.profilePictureUrl, + binding.composeAvatar, + avatarSize / 8, + animateAvatars ) binding.composeAvatar.contentDescription = getString(R.string.compose_active_account_description, - activeAccount.fullName) + activeAccount.fullName) } private fun replaceTextAtCaret(text: CharSequence) { @@ -602,10 +616,10 @@ class ComposeActivity : BaseActivity(), addMediaBehavior.removeBottomSheetCallback(this) if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this@ComposeActivity, - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) } else { - initiateMediaPicking() + pickMediaFile.launch(true) } } } @@ -620,7 +634,7 @@ class ComposeActivity : BaseActivity(), addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED val instanceParams = viewModel.instanceParams.value!! showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions, - instanceParams.pollMaxLength, viewModel::updatePoll) + instanceParams.pollMaxLength, viewModel::updatePoll) } private fun setupPollView() { @@ -740,8 +754,8 @@ class ComposeActivity : BaseActivity(), } else if (characterCount <= maximumTootCharacters) { if (viewModel.media.value!!.isNotEmpty()) { finishingUploadDialog = ProgressDialog.show( - this, getString(R.string.dialog_title_finishing_media_upload), - getString(R.string.dialog_message_uploading_media), true, true) + this, getString(R.string.dialog_title_finishing_media_upload), + getString(R.string.dialog_message_uploading_media), true, true) } viewModel.sendStatus(contentText, spoilerText).observe(this, { @@ -755,20 +769,20 @@ class ComposeActivity : BaseActivity(), } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, - grantResults: IntArray) { + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - initiateMediaPicking() + pickMediaFile.launch(true) } else { - val bar = Snackbar.make(binding.activityCompose, R.string.error_media_upload_permission, - Snackbar.LENGTH_SHORT).apply { - + Snackbar.make(binding.activityCompose, R.string.error_media_upload_permission, + Snackbar.LENGTH_SHORT).apply { + setAction(R.string.action_retry) { onMediaPick() } + //necessary so snackbar is shown over everything + view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + show() } - bar.setAction(R.string.action_retry) { onMediaPick() } - //necessary so snackbar is shown over everything - bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) - bar.show() } } } @@ -776,50 +790,32 @@ class ComposeActivity : BaseActivity(), private fun initiateCameraApp() { addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - // We don't need to ask for permission in this case, because the used calls require - // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was - // way before permission dialogues have been introduced. - val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - if (intent.resolveActivity(packageManager) != null) { - val photoFile: File = try { - createNewImageFile(this) - } catch (ex: IOException) { - displayTransientError(R.string.error_media_upload_opening) - return - } - - // Continue only if the File was successfully created - photoUploadUri = FileProvider.getUriForFile(this, - BuildConfig.APPLICATION_ID + ".fileprovider", - photoFile) - intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri) - startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT) + val photoFile: File = try { + createNewImageFile(this) + } catch (ex: IOException) { + displayTransientError(R.string.error_media_upload_opening) + return } - } - private fun initiateMediaPicking() { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - - val mimeTypes = arrayOf("image/*", "video/*", "audio/*") - intent.type = "*/*" - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) - intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - startActivityForResult(intent, MEDIA_PICK_RESULT) + // Continue only if the File was successfully created + photoUploadUri = FileProvider.getUriForFile(this, + BuildConfig.APPLICATION_ID + ".fileprovider", + photoFile) + takePicture.launch(photoUploadUri) } private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable ThemeUtils.setDrawableTint(this, button.drawable, - if (colorActive) android.R.attr.textColorTertiary - else R.attr.textColorDisabled) + if (colorActive) android.R.attr.textColorTertiary + else R.attr.textColorDisabled) } private fun enablePollButton(enable: Boolean) { binding.addPollTextActionTextView.isEnabled = enable val textColor = ThemeUtils.getColor(this, - if (enable) android.R.attr.textColorTertiary - else R.attr.textColorDisabled) + if (enable) android.R.attr.textColorTertiary + else R.attr.textColorDisabled) binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) } @@ -828,31 +824,6 @@ class ComposeActivity : BaseActivity(), viewModel.removeMediaFromQueue(item) } - override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { - super.onActivityResult(requestCode, resultCode, intent) - if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { - if (intent.data != null) { - // Single media, upload it and done. - pickMedia(intent.data!!) - } else if (intent.clipData != null) { - val clipData = intent.clipData!! - val count = clipData.itemCount - if (mediaCount + count > maxUploadMediaNumber) { - // check if exist media + upcoming media > 4, then prob error message. - Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() - } else { - // if not grater then 4, upload all multiple media. - for (i in 0 until count) { - val imageUri = clipData.getItemAt(i).getUri() - pickMedia(imageUri) - } - } - } - } else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { - pickMedia(photoUploadUri!!) - } - } - private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) { withLifecycleContext { viewModel.pickMedia(uri).observe { exceptionOrItem -> @@ -908,9 +879,9 @@ class ComposeActivity : BaseActivity(), override fun onBackPressed() { // Acting like a teen: deliberately ignoring parent. if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { + addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN @@ -945,12 +916,12 @@ class ComposeActivity : BaseActivity(), val contentWarning = binding.composeContentWarningField.text.toString() if (viewModel.didChange(contentText, contentWarning)) { AlertDialog.Builder(this) - .setMessage(R.string.compose_save_draft) - .setPositiveButton(R.string.action_save) { _, _ -> - saveDraftAndFinish(contentText, contentWarning) - } - .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } - .show() + .setMessage(R.string.compose_save_draft) + .setPositiveButton(R.string.action_save) { _, _ -> + saveDraftAndFinish(contentText, contentWarning) + } + .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } + .show() } else { finishWithoutSlideOutAnimation() } @@ -982,13 +953,13 @@ class ComposeActivity : BaseActivity(), } data class QueuedMedia( - val localId: Long, - val uri: Uri, - val type: Type, - val mediaSize: Long, - val uploadPercent: Int = 0, - val id: String? = null, - val description: String? = null + val localId: Long, + val uri: Uri, + val type: Type, + val mediaSize: Long, + val uploadPercent: Int = 0, + val id: String? = null, + val description: String? = null ) { enum class Type { IMAGE, VIDEO, AUDIO; @@ -1011,31 +982,29 @@ class ComposeActivity : BaseActivity(), @Parcelize data class ComposeOptions( - // Let's keep fields var until all consumers are Kotlin - var scheduledTootId: String? = null, - var draftId: Int? = null, - var tootText: String? = null, - var mediaUrls: List? = null, - var mediaDescriptions: List? = null, - var mentionedUsernames: Set? = null, - var inReplyToId: String? = null, - var replyVisibility: Status.Visibility? = null, - var visibility: Status.Visibility? = null, - var contentWarning: String? = null, - var replyingStatusAuthor: String? = null, - var replyingStatusContent: String? = null, - var mediaAttachments: List? = null, - var draftAttachments: List? = null, - var scheduledAt: String? = null, - var sensitive: Boolean? = null, - var poll: NewPoll? = null, - var modifiedInitialState: Boolean? = null + // Let's keep fields var until all consumers are Kotlin + var scheduledTootId: String? = null, + var draftId: Int? = null, + var tootText: String? = null, + var mediaUrls: List? = null, + var mediaDescriptions: List? = null, + var mentionedUsernames: Set? = null, + var inReplyToId: String? = null, + var replyVisibility: Status.Visibility? = null, + var visibility: Status.Visibility? = null, + var contentWarning: String? = null, + var replyingStatusAuthor: String? = null, + var replyingStatusContent: String? = null, + var mediaAttachments: List? = null, + var draftAttachments: List? = null, + var scheduledAt: String? = null, + var sensitive: Boolean? = null, + var poll: NewPoll? = null, + var modifiedInitialState: Boolean? = null ) : Parcelable companion object { private const val TAG = "ComposeActivity" // logging tag - private const val MEDIA_PICK_RESULT = 1 - private const val MEDIA_TAKE_PHOTO_RESULT = 2 private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt b/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt new file mode 100644 index 00000000..ae09d9e4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PickMediaFiles.kt @@ -0,0 +1,52 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract + +class PickMediaFiles : ActivityResultContract>() { + override fun createIntent(context: Context, allowMultiple: Boolean): Intent { + return Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + .apply { + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*", "audio/*")) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): List { + if (resultCode == Activity.RESULT_OK) { + val intentData = intent?.data + val clipData = intent?.clipData + if (intentData != null) { + // Single media, upload it and done. + return listOf(intentData) + } else if (clipData != null) { + val result: MutableList = mutableListOf() + for (i in 0 until clipData.itemCount) { + result.add(clipData.getItemAt(i).uri) + } + return result + } + } + return emptyList() + } +} \ No newline at end of file