ComposeActivity refactor (#1541)
* Convert ComposeActivity to Kotlin * More ComposeActivity cleanups * Move ComposeActivity to it's own package * Remove ComposeActivity.IntentBuilder * Re-do part of the media downsizing/uploading * Add sending of status to ViewModel, draft media descriptions * Allow uploading video, update description after uploading * Enable camera, enable upload cancelling * Cleanup of ComposeActivity * Extract CaptionDialog, extract ComposeActivity methods * Fix handling of redrafted media * Add initial state and media uploading out of Activity * Change ComposeOptions.mentionedUsernames to be Set rather than List We probably don't want repeated usernames when we are writing a post and Set provides such guarantee for free plus it tells it to the callers. The only disadvantage is lack of order but it shouldn't be a problem. * Add combineOptionalLiveData. Add docs. It it useful for nullable LiveData's. I think we cannot differentiate between value not being set and value being null so I just added the variant without null check. * Add poll support to Compose. * cleanup code * move more classes into compose package * cleanup code * fix button behavior * add error handling for media upload * add caching for instance data again * merge develop * fix scheduled toots * delete unused string * cleanup ComposeActivity * fix restoring media from drafts * make media upload code a little bit clearer * cleanup autocomplete search code * avoid duplicate object creation in SavedTootActivity * perf: avoid unnecessary work when initializing ComposeActivity * add license header to new files * use small toot button on bigger displays * fix ComposeActivityTest * fix bad merge * use Singles.zip instead of Single.zip
This commit is contained in:
parent
9457aa73b2
commit
8770fbe986
68 changed files with 3162 additions and 2666 deletions
|
@ -0,0 +1,994 @@
|
|||
/* Copyright 2019 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.ProgressDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.preference.PreferenceManager
|
||||
import android.provider.MediaStore
|
||||
import android.text.TextUtils
|
||||
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.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.adapter.EmojiAdapter
|
||||
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
||||
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
|
||||
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
|
||||
import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.android.synthetic.main.activity_compose.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class ComposeActivity : BaseActivity(),
|
||||
ComposeOptionsListener,
|
||||
ComposeAutoCompleteAdapter.AutocompletionProvider,
|
||||
OnEmojiSelectedListener,
|
||||
Injectable,
|
||||
InputConnectionCompat.OnCommitContentListener,
|
||||
TimePickerDialog.OnTimeSetListener {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private lateinit var composeOptionsBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var addMediaBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
||||
|
||||
// this only exists when a status is trying to be sent, but uploads are still occurring
|
||||
private var finishingUploadDialog: ProgressDialog? = null
|
||||
private var currentInputContentInfo: InputContentInfoCompat? = null
|
||||
private var currentFlags: Int = 0
|
||||
private var photoUploadUri: Uri? = null
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
|
||||
|
||||
private var composeOptions: ComposeOptions? = null
|
||||
private lateinit var viewModel: ComposeViewModel
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
||||
if (theme == "black") {
|
||||
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
||||
}
|
||||
setContentView(R.layout.activity_compose)
|
||||
|
||||
setupActionBar()
|
||||
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
|
||||
val activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
setupAvatar(preferences, activeAccount)
|
||||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
makeCaptionDialog(item.description, item.uri) { newDescription ->
|
||||
viewModel.updateDescription(item.localId, newDescription)
|
||||
}
|
||||
},
|
||||
onRemove = this::removeMediaFromQueue
|
||||
)
|
||||
composeMediaPreviewBar.layoutManager =
|
||||
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
||||
composeMediaPreviewBar.adapter = mediaAdapter
|
||||
composeMediaPreviewBar.itemAnimator = null
|
||||
|
||||
viewModel = ViewModelProviders.of(this, viewModelFactory)[ComposeViewModel::class.java]
|
||||
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
setupButtons()
|
||||
|
||||
/* If the composer is started up as a reply to another post, override the "starting" state
|
||||
* based on what the intent from the reply request passes. */
|
||||
if (intent != null) {
|
||||
this.composeOptions = intent.getParcelableExtra<ComposeOptions?>(COMPOSE_OPTIONS_EXTRA)
|
||||
viewModel.setup(composeOptions)
|
||||
setupReplyViews(composeOptions?.replyingStatusAuthor)
|
||||
val tootText = composeOptions?.tootText
|
||||
if (!tootText.isNullOrEmpty()) {
|
||||
composeEditField.setText(tootText)
|
||||
}
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) {
|
||||
composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupComposeField(viewModel.startingText)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
applyShareIntent(intent, savedInstanceState)
|
||||
|
||||
composeEditField.requestFocus()
|
||||
}
|
||||
|
||||
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
|
||||
if (intent != null && savedInstanceState == null) {
|
||||
/* Get incoming images being sent through a share action from another app. Only do this
|
||||
* when savedInstanceState is null, otherwise both the images from the intent and the
|
||||
* instance state will be re-queued. */
|
||||
val type = intent.type
|
||||
if (type != null) {
|
||||
if (type.startsWith("image/") || type.startsWith("video/")) {
|
||||
val uriList = ArrayList<Uri>()
|
||||
if (intent.action != null) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
if (uri != null) {
|
||||
uriList.add(uri)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
val list = intent.getParcelableArrayListExtra<Uri>(
|
||||
Intent.EXTRA_STREAM)
|
||||
if (list != null) {
|
||||
for (uri in list) {
|
||||
if (uri != null) {
|
||||
uriList.add(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uri in uriList) {
|
||||
pickMedia(uri)
|
||||
}
|
||||
} else if (type == "text/plain") {
|
||||
val action = intent.action
|
||||
if (action != null && action == Intent.ACTION_SEND) {
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val shareBody = if (subject != null && text != null) {
|
||||
if (subject != text && !text.contains(subject)) {
|
||||
String.format("%s\n%s", subject, text)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
} else text ?: subject
|
||||
|
||||
if (shareBody != null) {
|
||||
val start = composeEditField.selectionStart.coerceAtLeast(0)
|
||||
val end = composeEditField.selectionEnd.coerceAtLeast(0)
|
||||
val left = min(start, end)
|
||||
val right = max(start, end)
|
||||
composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupReplyViews(replyingStatusAuthor: String?) {
|
||||
if (replyingStatusAuthor != null) {
|
||||
composeReplyView.show()
|
||||
composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
||||
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).sizeDp(12)
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
|
||||
composeReplyView.setOnClickListener {
|
||||
TransitionManager.beginDelayedTransition(composeReplyContentView.parent as ViewGroup)
|
||||
|
||||
if (composeReplyContentView.isVisible) {
|
||||
composeReplyContentView.hide()
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
} else {
|
||||
composeReplyContentView.show()
|
||||
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).sizeDp(12)
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it }
|
||||
}
|
||||
|
||||
private fun setupContentWarningField(startingContentWarning: String?) {
|
||||
if (startingContentWarning != null) {
|
||||
composeContentWarningField.setText(startingContentWarning)
|
||||
}
|
||||
composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||
}
|
||||
|
||||
private fun setupComposeField(startingText: String?) {
|
||||
composeEditField.setOnCommitContentListener(this)
|
||||
|
||||
composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
|
||||
|
||||
composeEditField.setAdapter(
|
||||
ComposeAutoCompleteAdapter(this))
|
||||
composeEditField.setTokenizer(ComposeTokenizer())
|
||||
|
||||
composeEditField.setText(startingText)
|
||||
composeEditField.setSelection(composeEditField.length())
|
||||
|
||||
val mentionColour = composeEditField.linkTextColors.defaultColor
|
||||
highlightSpans(composeEditField.text, mentionColour)
|
||||
composeEditField.afterTextChanged { editable ->
|
||||
highlightSpans(editable, mentionColour)
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
|
||||
withLifecycleContext {
|
||||
viewModel.instanceParams.observe { instanceData ->
|
||||
maximumTootCharacters = instanceData.maxChars
|
||||
updateVisibleCharactersLeft()
|
||||
composeScheduleButton.visible(instanceData.supportsScheduled)
|
||||
}
|
||||
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
|
||||
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
|
||||
updateSensitiveMediaToggle(markSensitive, showContentWarning)
|
||||
showContentWarning(showContentWarning)
|
||||
}.subscribe()
|
||||
viewModel.statusVisibility.observe { visibility ->
|
||||
setStatusVisibility(visibility)
|
||||
}
|
||||
viewModel.media.observe { media ->
|
||||
composeMediaPreviewBar.visible(media.isNotEmpty())
|
||||
mediaAdapter.submitList(media)
|
||||
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
|
||||
}
|
||||
viewModel.poll.observe { poll ->
|
||||
pollPreview.visible(poll != null)
|
||||
poll?.let(pollPreview::setPoll)
|
||||
}
|
||||
viewModel.scheduledAt.observe {scheduledAt ->
|
||||
if(scheduledAt == null) {
|
||||
composeScheduleView.resetSchedule()
|
||||
} else {
|
||||
composeScheduleView.setDateTime(scheduledAt)
|
||||
}
|
||||
updateScheduleButton()
|
||||
}
|
||||
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
|
||||
val active = poll == null
|
||||
&& media!!.size != 4
|
||||
&& media.firstOrNull()?.type != QueuedMedia.Type.VIDEO
|
||||
enableButton(composeAddMediaButton, active, active)
|
||||
enablePollButton(media.isNullOrEmpty())
|
||||
}.subscribe()
|
||||
viewModel.uploadError.observe {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
composeOptionsBottomSheet.listener = this
|
||||
|
||||
composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsBottomSheet)
|
||||
addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet)
|
||||
scheduleBehavior = BottomSheetBehavior.from(composeScheduleView)
|
||||
emojiBehavior = BottomSheetBehavior.from(emojiView)
|
||||
|
||||
emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false)
|
||||
enableButton(composeEmojiButton, clickable = false, colorActive = false)
|
||||
|
||||
// Setup the interface buttons.
|
||||
composeTootButton.setOnClickListener { onSendClicked() }
|
||||
composeAddMediaButton.setOnClickListener { openPickDialog() }
|
||||
composeToggleVisibilityButton.setOnClickListener { showComposeOptions() }
|
||||
composeContentWarningButton.setOnClickListener { onContentWarningChanged() }
|
||||
composeEmojiButton.setOnClickListener { showEmojis() }
|
||||
composeHideMediaButton.setOnClickListener { toggleHideMedia() }
|
||||
composeScheduleButton.setOnClickListener { onScheduleClick() }
|
||||
composeScheduleView.setResetOnClickListener { resetSchedule() }
|
||||
atButton.setOnClickListener { atButtonClicked() }
|
||||
hashButton.setOnClickListener { hashButtonClicked() }
|
||||
|
||||
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18)
|
||||
actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
|
||||
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18)
|
||||
actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
|
||||
|
||||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18)
|
||||
addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
|
||||
|
||||
actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
||||
actionPhotoPick.setOnClickListener { onMediaPick() }
|
||||
addPollTextActionTextView.setOnClickListener { openPollDialog() }
|
||||
}
|
||||
|
||||
private fun setupActionBar() {
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.run {
|
||||
title = null
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
val closeIcon = AppCompatResources.getDrawable(this@ComposeActivity, R.drawable.ic_close_24dp)
|
||||
ThemeUtils.setDrawableTint(this@ComposeActivity, closeIcon!!, R.attr.compose_close_button_tint)
|
||||
setHomeAsUpIndicator(closeIcon)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) {
|
||||
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
|
||||
val a = obtainStyledAttributes(null, actionBarSizeAttr)
|
||||
val avatarSize = a.getDimensionPixelSize(0, 1)
|
||||
a.recycle()
|
||||
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
loadAvatar(
|
||||
activeAccount.profilePictureUrl,
|
||||
composeAvatar,
|
||||
avatarSize / 8,
|
||||
animateAvatars
|
||||
)
|
||||
composeAvatar.contentDescription = getString(R.string.compose_active_account_description,
|
||||
activeAccount.fullName)
|
||||
}
|
||||
|
||||
private fun replaceTextAtCaret(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = composeEditField.selectionStart.coerceAtMost(composeEditField.selectionEnd)
|
||||
val end = composeEditField.selectionStart.coerceAtLeast(composeEditField.selectionEnd)
|
||||
composeEditField.text.replace(start, end, text)
|
||||
|
||||
// Set the cursor after the inserted text
|
||||
composeEditField.setSelection(start + text.length)
|
||||
}
|
||||
|
||||
private fun atButtonClicked() {
|
||||
replaceTextAtCaret("@")
|
||||
}
|
||||
|
||||
private fun hashButtonClicked() {
|
||||
replaceTextAtCaret("#")
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
if (currentInputContentInfo != null) {
|
||||
outState.putParcelable("commitContentInputContentInfo",
|
||||
currentInputContentInfo!!.unwrap() as Parcelable?)
|
||||
outState.putInt("commitContentFlags", currentFlags)
|
||||
}
|
||||
currentInputContentInfo = null
|
||||
currentFlags = 0
|
||||
outState.putParcelable("photoUploadUri", photoUploadUri)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
val bar = Snackbar.make(activityCompose, stringId, Snackbar.LENGTH_LONG)
|
||||
//necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.show()
|
||||
}
|
||||
|
||||
private fun toggleHideMedia() {
|
||||
this.viewModel.toggleMarkSensitive()
|
||||
}
|
||||
|
||||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(composeHideMediaButton.parent as ViewGroup)
|
||||
|
||||
if (viewModel.media.value.isNullOrEmpty()) {
|
||||
composeHideMediaButton.hide()
|
||||
} else {
|
||||
composeHideMediaButton.show()
|
||||
@ColorInt val color = if (contentWarningShown) {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
||||
composeHideMediaButton.isClickable = false
|
||||
ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue)
|
||||
|
||||
} else {
|
||||
composeHideMediaButton.isClickable = true
|
||||
if (markMediaSensitive) {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
} else {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
}
|
||||
}
|
||||
composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateScheduleButton() {
|
||||
@ColorInt val color = if (composeScheduleView.time == null) {
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
} else {
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
}
|
||||
composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun enableButtons(enable: Boolean) {
|
||||
composeAddMediaButton.isClickable = enable
|
||||
composeToggleVisibilityButton.isClickable = enable
|
||||
composeEmojiButton.isClickable = enable
|
||||
composeHideMediaButton.isClickable = enable
|
||||
composeScheduleButton.isClickable = enable
|
||||
composeTootButton.isEnabled = enable
|
||||
}
|
||||
|
||||
private fun setStatusVisibility(visibility: Status.Visibility) {
|
||||
composeOptionsBottomSheet.setStatusVisibility(visibility)
|
||||
composeTootButton.setStatusVisibility(visibility)
|
||||
|
||||
val iconRes = when (visibility) {
|
||||
Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp
|
||||
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
|
||||
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
|
||||
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
|
||||
else -> R.drawable.ic_lock_open_24dp
|
||||
}
|
||||
val drawable = ThemeUtils.getTintedDrawable(this, iconRes, android.R.attr.textColorTertiary)
|
||||
composeToggleVisibilityButton.setImageDrawable(drawable)
|
||||
}
|
||||
|
||||
private fun showComposeOptions() {
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScheduleClick() {
|
||||
if(viewModel.scheduledAt.value == null) {
|
||||
composeScheduleView.openPickDateDialog()
|
||||
} else {
|
||||
showScheduleView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showScheduleView() {
|
||||
if (scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmojis() {
|
||||
emojiView.adapter?.let {
|
||||
if (it.itemCount == 0) {
|
||||
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
|
||||
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPickDialog() {
|
||||
if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMediaPick() {
|
||||
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
//Wait until bottom sheet is not collapsed and show next screen after
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
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)
|
||||
} else {
|
||||
initiateMediaPicking()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
}
|
||||
)
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
private fun openPollDialog() {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
val instanceParams = viewModel.instanceParams.value!!
|
||||
showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions,
|
||||
instanceParams.pollMaxLength, viewModel::updatePoll)
|
||||
}
|
||||
|
||||
private fun setupPollView() {
|
||||
val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
||||
|
||||
val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
layoutParams.setMargins(margin, margin, margin, marginBottom)
|
||||
pollPreview.layoutParams = layoutParams
|
||||
|
||||
pollPreview.setOnClickListener {
|
||||
val popup = PopupMenu(this, pollPreview)
|
||||
val editId = 1
|
||||
val removeId = 2
|
||||
popup.menu.add(0, editId, 0, R.string.edit_poll)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
editId -> openPollDialog()
|
||||
removeId -> removePoll()
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun removePoll() {
|
||||
viewModel.poll.value = null
|
||||
pollPreview.hide()
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(visibility: Status.Visibility) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
viewModel.statusVisibility.value = visibility
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun calculateTextLength(): Int {
|
||||
var offset = 0
|
||||
val urlSpans = composeEditField.urls
|
||||
if (urlSpans != null) {
|
||||
for (span in urlSpans) {
|
||||
offset += max(0, span.url.length - MAXIMUM_URL_LENGTH)
|
||||
}
|
||||
}
|
||||
var length = composeEditField.length() - offset
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
length += composeContentWarningField.length()
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
private fun updateVisibleCharactersLeft() {
|
||||
composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength())
|
||||
}
|
||||
|
||||
private fun onContentWarningChanged() {
|
||||
val showWarning = composeContentWarningBar.isGone
|
||||
viewModel.showContentWarning.value = showWarning
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
|
||||
private fun onSendClicked() {
|
||||
enableButtons(false)
|
||||
sendStatus()
|
||||
}
|
||||
|
||||
/** This is for the fancy keyboards which can insert images and stuff. */
|
||||
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle): Boolean {
|
||||
try {
|
||||
currentInputContentInfo?.releasePermission()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "InputContentInfoCompat#releasePermission() failed." + e.message)
|
||||
} finally {
|
||||
currentInputContentInfo = null
|
||||
}
|
||||
|
||||
// Verify the returned content's type is of the correct MIME type
|
||||
val supported = inputContentInfo.description.hasMimeType("image/*")
|
||||
|
||||
return supported && onCommitContentInternal(inputContentInfo, flags)
|
||||
}
|
||||
|
||||
private fun onCommitContentInternal(inputContentInfo: InputContentInfoCompat, flags: Int): Boolean {
|
||||
if (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the file size before putting handing it off to be put in the queue.
|
||||
pickMedia(inputContentInfo.contentUri)
|
||||
|
||||
currentInputContentInfo = inputContentInfo
|
||||
currentFlags = flags
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun sendStatus() {
|
||||
val contentText = composeEditField.text.toString()
|
||||
var spoilerText = ""
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
spoilerText = composeContentWarningField.text.toString()
|
||||
}
|
||||
val characterCount = calculateTextLength()
|
||||
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) {
|
||||
composeEditField.error = getString(R.string.error_empty)
|
||||
enableButtons(true)
|
||||
} else if (characterCount <= maximumTootCharacters) {
|
||||
finishingUploadDialog = ProgressDialog.show(
|
||||
this, getString(R.string.dialog_title_finishing_media_upload),
|
||||
getString(R.string.dialog_message_uploading_media), true, true)
|
||||
|
||||
viewModel.sendStatus(contentText, spoilerText).observe(this, Observer {
|
||||
finishingUploadDialog?.dismiss()
|
||||
finishWithoutSlideOutAnimation()
|
||||
})
|
||||
|
||||
} else {
|
||||
composeEditField.error = getString(R.string.error_compose_character_limit)
|
||||
enableButtons(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
|
||||
grantResults: IntArray) {
|
||||
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
initiateMediaPicking()
|
||||
} else {
|
||||
val bar = Snackbar.make(activityCompose, R.string.error_media_upload_permission,
|
||||
Snackbar.LENGTH_SHORT).apply {
|
||||
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initiateMediaPicking() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
|
||||
val mimeTypes = arrayOf("image/*", "video/*")
|
||||
intent.type = "*/*"
|
||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
startActivityForResult(intent, MEDIA_PICK_RESULT)
|
||||
}
|
||||
|
||||
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.image_button_disabled_tint)
|
||||
}
|
||||
|
||||
private fun enablePollButton(enable: Boolean) {
|
||||
addPollTextActionTextView.isEnabled = enable
|
||||
val textColor = ThemeUtils.getColor(this,
|
||||
if (enable) android.R.attr.textColorTertiary
|
||||
else R.attr.image_button_disabled_tint)
|
||||
addPollTextActionTextView.setTextColor(textColor)
|
||||
addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
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) {
|
||||
pickMedia(intent.data!!)
|
||||
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickMedia(uri: Uri) {
|
||||
withLifecycleContext {
|
||||
viewModel.pickMedia(uri).observe { exceptionOrItem ->
|
||||
exceptionOrItem.asLeftOrNull()?.let {
|
||||
val errorId = when (it) {
|
||||
is VideoSizeException -> {
|
||||
R.string.error_video_upload_size
|
||||
}
|
||||
is VideoOrImageException -> {
|
||||
R.string.error_media_upload_image_or_video
|
||||
}
|
||||
else -> {
|
||||
R.string.error_media_upload_opening
|
||||
}
|
||||
}
|
||||
displayTransientError(errorId)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showContentWarning(show: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(composeContentWarningBar.parent as ViewGroup)
|
||||
@ColorInt val color = if (show) {
|
||||
composeContentWarningBar.show()
|
||||
composeContentWarningField.setSelection(composeContentWarningField.text.length)
|
||||
composeContentWarningField.requestFocus()
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
} else {
|
||||
composeContentWarningBar.hide()
|
||||
composeEditField.requestFocus()
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
}
|
||||
composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
handleCloseButton()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
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) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
handleCloseButton()
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
Log.d(TAG, event.toString())
|
||||
if (event.isCtrlPressed) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
// send toot by pressing CTRL + ENTER
|
||||
this.onSendClicked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
onBackPressed()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
private fun handleCloseButton() {
|
||||
val contentText = composeEditField.text.toString()
|
||||
val contentWarning = 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()
|
||||
} else {
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
|
||||
viewModel.saveDraft(contentText, contentWarning)
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return viewModel.searchAutocompleteSuggestions(token)
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(shortcode: String) {
|
||||
replaceTextAtCaret(":$shortcode: ")
|
||||
}
|
||||
|
||||
private fun setEmojiList(emojiList: List<Emoji>?) {
|
||||
if (emojiList != null) {
|
||||
emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity)
|
||||
enableButton(composeEmojiButton, true, emojiList.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
enum class Type {
|
||||
IMAGE, VIDEO;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) {
|
||||
composeScheduleView.onTimeSet(hourOfDay, minute)
|
||||
viewModel.updateScheduledAt(composeScheduleView.time)
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
private fun resetSchedule() {
|
||||
viewModel.updateScheduledAt(null)
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ComposeOptions(
|
||||
// Let's keep fields var until all consumers are Kotlin
|
||||
var savedTootUid: Int? = null,
|
||||
var tootText: String? = null,
|
||||
var mediaUrls: List<String>? = null,
|
||||
var mediaDescriptions: List<String>? = null,
|
||||
var mentionedUsernames: Set<String>? = 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<Attachment>? = null,
|
||||
var scheduledAt: String? = null,
|
||||
var sensitive: Boolean? = null,
|
||||
var poll: NewPoll? = 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
|
||||
|
||||
private const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
|
||||
|
||||
// Mastodon only counts URLs as this long in terms of status character limits
|
||||
@VisibleForTesting
|
||||
const val MAXIMUM_URL_LENGTH = 23
|
||||
|
||||
@JvmStatic
|
||||
fun startIntent(context: Context, options: ComposeOptions): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(COMPOSE_OPTIONS_EXTRA, options)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun canHandleMimeType(mimeType: String?): Boolean {
|
||||
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType == "text/plain")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,467 @@
|
|||
/* Copyright 2019 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.InstanceEntity
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.TootToSend
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.rxkotlin.Singles
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
open class RxAwareViewModel : ViewModel() {
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
fun Disposable.autoDispose() = disposables.add(this)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when trying to add an image when video is already present or the other way around
|
||||
*/
|
||||
class VideoOrImageException : Exception()
|
||||
|
||||
|
||||
class ComposeViewModel
|
||||
@Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val mediaUploader: MediaUploader,
|
||||
private val serviceClient: ServiceClient,
|
||||
private val saveTootHelper: SaveTootHelper,
|
||||
private val db: AppDatabase
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
private var replyingStatusContent: String? = null
|
||||
internal var startingText: String? = null
|
||||
private var savedTootUid: Int = 0
|
||||
private var startingContentWarning: String? = null
|
||||
private var inReplyToId: String? = null
|
||||
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
|
||||
|
||||
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData()
|
||||
|
||||
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
|
||||
ComposeInstanceParams(
|
||||
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
|
||||
)
|
||||
}
|
||||
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
|
||||
val markMediaAsSensitive =
|
||||
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
|
||||
fun toggleMarkSensitive() {
|
||||
this.markMediaAsSensitive.value = !this.markMediaAsSensitive.value!!
|
||||
}
|
||||
|
||||
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
|
||||
val showContentWarning = mutableLiveData(false)
|
||||
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
|
||||
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
|
||||
|
||||
val media = mutableLiveData<List<QueuedMedia>>(listOf())
|
||||
val uploadError = MutableLiveData<Throwable>()
|
||||
|
||||
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
|
||||
|
||||
|
||||
init {
|
||||
|
||||
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
|
||||
InstanceEntity(
|
||||
instance = accountManager.activeAccount?.domain!!,
|
||||
emojiList = emojis,
|
||||
maximumTootCharacters = instance.maxTootChars,
|
||||
maxPollOptions = instance.pollLimits?.maxOptions,
|
||||
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
|
||||
version = instance.version
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
db.instanceDao().insertOrReplace(it)
|
||||
}
|
||||
.onErrorResumeNext(
|
||||
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
|
||||
)
|
||||
.subscribe ({ instanceEntity ->
|
||||
emoji.postValue(instanceEntity.emojiList)
|
||||
instance.postValue(instanceEntity)
|
||||
}, { throwable ->
|
||||
// this can happen on network error when no cached data is available
|
||||
Log.w(TAG, "error loading instance data", throwable)
|
||||
})
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun pickMedia(uri: Uri): LiveData<Either<Throwable, QueuedMedia>> {
|
||||
// We are not calling .toLiveData() here because we don't want to stop the process when
|
||||
// the Activity goes away temporarily (like on screen rotation).
|
||||
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
|
||||
mediaUploader.prepareMedia(uri)
|
||||
.map { (type, uri, size) ->
|
||||
val mediaItems = media.value!!
|
||||
if (type == QueuedMedia.Type.VIDEO
|
||||
&& mediaItems.isNotEmpty()
|
||||
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
|
||||
throw VideoOrImageException()
|
||||
} else {
|
||||
addMediaToQueue(type, uri, size)
|
||||
}
|
||||
}
|
||||
.subscribe({ queuedMedia ->
|
||||
liveData.postValue(Either.Right(queuedMedia))
|
||||
}, { error ->
|
||||
liveData.postValue(Either.Left(error))
|
||||
})
|
||||
.autoDispose()
|
||||
return liveData
|
||||
}
|
||||
|
||||
private fun addMediaToQueue(type: QueuedMedia.Type, uri: Uri, mediaSize: Long): QueuedMedia {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize)
|
||||
media.value = media.value!! + mediaItem
|
||||
mediaToDisposable[mediaItem.localId] = mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.subscribe ({ event ->
|
||||
val item = media.value?.find { it.localId == mediaItem.localId }
|
||||
?: return@subscribe
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
is UploadEvent.FinishedEvent ->
|
||||
item.copy(id = event.attachment.id, uploadPercent = -1)
|
||||
}
|
||||
synchronized(media) {
|
||||
val mediaValue = media.value!!
|
||||
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
|
||||
media.postValue(if (index == -1) {
|
||||
mediaValue + newMediaItem
|
||||
} else {
|
||||
mediaValue.toMutableList().also { it[index] = newMediaItem }
|
||||
})
|
||||
}
|
||||
}, { error ->
|
||||
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
|
||||
uploadError.postValue(error)
|
||||
})
|
||||
return mediaItem
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, -1, id, description)
|
||||
media.value = media.value!! + mediaItem
|
||||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToDisposable[item.localId]?.dispose()
|
||||
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
|
||||
}
|
||||
|
||||
fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
|
||||
val textChanged = !(content.isNullOrEmpty()
|
||||
|| startingText?.startsWith(content.toString()) ?: false)
|
||||
|
||||
val contentWarningChanged = showContentWarning.value!!
|
||||
&& !contentWarning.isNullOrEmpty()
|
||||
&& !startingContentWarning!!.startsWith(contentWarning.toString())
|
||||
val mediaChanged = media.value!!.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
|
||||
return textChanged || contentWarningChanged || mediaChanged || pollChanged
|
||||
}
|
||||
|
||||
fun deleteDraft() {
|
||||
saveTootHelper.deleteDraft(this.savedTootUid)
|
||||
}
|
||||
|
||||
fun saveDraft(content: String, contentWarning: String) {
|
||||
val mediaUris = mutableListOf<String>()
|
||||
val mediaDescriptions = mutableListOf<String?>()
|
||||
for (item in media.value!!) {
|
||||
mediaUris.add(item.uri.toString())
|
||||
mediaDescriptions.add(item.description)
|
||||
}
|
||||
saveTootHelper.saveToot(
|
||||
content,
|
||||
contentWarning,
|
||||
null,
|
||||
mediaUris,
|
||||
mediaDescriptions,
|
||||
savedTootUid,
|
||||
inReplyToId,
|
||||
replyingStatusContent,
|
||||
replyingStatusAuthor,
|
||||
statusVisibility.value!!,
|
||||
poll.value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send status to the server.
|
||||
* Uses current state plus provided arguments.
|
||||
* @return LiveData which will signal once the screen can be closed or null if there are errors
|
||||
*/
|
||||
fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String
|
||||
): LiveData<Unit> {
|
||||
return media
|
||||
.filter { items -> items.all { it.uploadPercent == -1 } }
|
||||
.map {
|
||||
val mediaIds = ArrayList<String>()
|
||||
val mediaUris = ArrayList<Uri>()
|
||||
val mediaDescriptions = ArrayList<String>()
|
||||
for (item in media.value!!) {
|
||||
mediaIds.add(item.id!!)
|
||||
mediaUris.add(item.uri)
|
||||
mediaDescriptions.add(item.description ?: "")
|
||||
}
|
||||
|
||||
val tootToSend = TootToSend(
|
||||
content,
|
||||
spoilerText,
|
||||
statusVisibility.value!!.serverString(),
|
||||
mediaUris.isNotEmpty() && markMediaAsSensitive.value!!,
|
||||
mediaIds,
|
||||
mediaUris.map { it.toString() },
|
||||
mediaDescriptions,
|
||||
scheduledAt = scheduledAt.value,
|
||||
inReplyToId = null,
|
||||
poll = poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
savedJsonUrls = null,
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
savedTootUid = 0,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0
|
||||
)
|
||||
serviceClient.sendToot(tootToSend)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
|
||||
val newList = media.value!!.toMutableList()
|
||||
val index = newList.indexOfFirst { it.localId == localId }
|
||||
if (index != -1) {
|
||||
newList[index] = newList[index].copy(description = description)
|
||||
}
|
||||
media.value = newList
|
||||
val completedCaptioningLiveData = MutableLiveData<Boolean>()
|
||||
media.observeForever(object : Observer<List<QueuedMedia>> {
|
||||
override fun onChanged(mediaItems: List<QueuedMedia>) {
|
||||
val updatedItem = mediaItems.find { it.localId == localId }
|
||||
if (updatedItem == null) {
|
||||
media.removeObserver(this)
|
||||
} else if (updatedItem.id != null) {
|
||||
api.updateMedia(updatedItem.id, description)
|
||||
.subscribe({
|
||||
completedCaptioningLiveData.postValue(true)
|
||||
}, {
|
||||
completedCaptioningLiveData.postValue(false)
|
||||
})
|
||||
.autoDispose()
|
||||
media.removeObserver(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
return completedCaptioningLiveData
|
||||
}
|
||||
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return try {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.blockingGet()
|
||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
'#' -> {
|
||||
return try {
|
||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.blockingGet()
|
||||
.hashtags
|
||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
|
||||
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
|
||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
for (emoji in emojiList) {
|
||||
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
|
||||
if (shortcode.startsWith(incomplete)) {
|
||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
}
|
||||
}
|
||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
||||
}
|
||||
results.addAll(resultsInside)
|
||||
return results
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (uploadDisposable in mediaToDisposable.values) {
|
||||
uploadDisposable.dispose()
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
|
||||
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
|
||||
startingVisibility = Status.Visibility.byNum(
|
||||
preferredVisibility.num.coerceAtLeast(replyVisibility.num))
|
||||
statusVisibility.value = startingVisibility
|
||||
|
||||
inReplyToId = composeOptions?.inReplyToId
|
||||
|
||||
|
||||
val contentWarning = composeOptions?.contentWarning
|
||||
if (contentWarning != null) {
|
||||
startingContentWarning = contentWarning
|
||||
}
|
||||
|
||||
// recreate media list
|
||||
// when coming from SavedTootActivity
|
||||
val loadedDraftMediaUris = composeOptions?.mediaUrls
|
||||
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
|
||||
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) {
|
||||
loadedDraftMediaUris.zip(loadedDraftMediaDescriptions)
|
||||
.forEach { (uri, description) ->
|
||||
pickMedia(uri.toUri()).observeForever { errorOrItem ->
|
||||
if (errorOrItem.isRight() && description != null) {
|
||||
updateDescription(errorOrItem.asRight().localId, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else composeOptions?.mediaAttachments?.forEach { a ->
|
||||
// when coming from redraft
|
||||
val mediaType = when (a.type) {
|
||||
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
|
||||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||
else -> QueuedMedia.Type.IMAGE
|
||||
}
|
||||
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
|
||||
}
|
||||
|
||||
|
||||
composeOptions?.savedTootUid?.let { uid ->
|
||||
this.savedTootUid = uid
|
||||
startingText = composeOptions.tootText
|
||||
}
|
||||
|
||||
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
|
||||
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
|
||||
startingVisibility = tootVisibility
|
||||
}
|
||||
val mentionedUsernames = composeOptions?.mentionedUsernames
|
||||
if (mentionedUsernames != null) {
|
||||
val builder = StringBuilder()
|
||||
for (name in mentionedUsernames) {
|
||||
builder.append('@')
|
||||
builder.append(name)
|
||||
builder.append(' ')
|
||||
}
|
||||
startingText = builder.toString()
|
||||
}
|
||||
|
||||
|
||||
scheduledAt.value = composeOptions?.scheduledAt
|
||||
|
||||
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
|
||||
|
||||
val poll = composeOptions?.poll
|
||||
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
|
||||
this.poll.value = poll
|
||||
}
|
||||
replyingStatusContent = composeOptions?.replyingStatusContent
|
||||
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
|
||||
}
|
||||
|
||||
fun updatePoll(newPoll: NewPoll) {
|
||||
poll.value = newPoll
|
||||
}
|
||||
|
||||
fun updateScheduledAt(newScheduledAt: String?) {
|
||||
scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
|
||||
|
||||
const val DEFAULT_CHARACTER_LIMIT = 500
|
||||
private const val DEFAULT_MAX_OPTION_COUNT = 4
|
||||
private const val DEFAULT_MAX_OPTION_LENGTH = 25
|
||||
|
||||
data class ComposeInstanceParams(
|
||||
val maxChars: Int,
|
||||
val pollMaxOptions: Int,
|
||||
val pollMaxLength: Int,
|
||||
val supportsScheduled: Boolean
|
||||
)
|
|
@ -0,0 +1,154 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import com.keylesspalace.tusky.util.IOUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize;
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation;
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap;
|
||||
|
||||
/**
|
||||
* Reduces the file size of images to fit under a given limit by resizing them, maintaining both
|
||||
* aspect ratio and orientation.
|
||||
*/
|
||||
public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
||||
private int sizeLimit;
|
||||
private ContentResolver contentResolver;
|
||||
private Listener listener;
|
||||
private File tempFile;
|
||||
|
||||
/**
|
||||
* @param sizeLimit the maximum number of bytes each image can take
|
||||
* @param contentResolver to resolve the specified images' URIs
|
||||
* @param tempFile the file where the result will be stored
|
||||
* @param listener to whom the results are given
|
||||
*/
|
||||
public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) {
|
||||
this.sizeLimit = sizeLimit;
|
||||
this.contentResolver = contentResolver;
|
||||
this.tempFile = tempFile;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Boolean doInBackground(Uri... uris) {
|
||||
boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile);
|
||||
if (isCancelled()) {
|
||||
return false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean successful) {
|
||||
if (successful) {
|
||||
listener.onSuccess(tempFile);
|
||||
} else {
|
||||
listener.onFailure();
|
||||
}
|
||||
super.onPostExecute(successful);
|
||||
}
|
||||
|
||||
public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver,
|
||||
File tempFile) {
|
||||
for (Uri uri : uris) {
|
||||
InputStream inputStream;
|
||||
try {
|
||||
inputStream = contentResolver.openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
// Initially, just get the image dimensions.
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inJustDecodeBounds = true;
|
||||
BitmapFactory.decodeStream(inputStream, null, options);
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
// Get EXIF data, for orientation info.
|
||||
int orientation = getImageOrientation(uri, contentResolver);
|
||||
/* Unfortunately, there isn't a determined worst case compression ratio for image
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
int scaledImageSize = 1024;
|
||||
do {
|
||||
OutputStream stream;
|
||||
try {
|
||||
stream = new FileOutputStream(tempFile);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
inputStream = contentResolver.openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize);
|
||||
options.inJustDecodeBounds = false;
|
||||
Bitmap scaledBitmap;
|
||||
try {
|
||||
scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options);
|
||||
} catch (OutOfMemoryError error) {
|
||||
return false;
|
||||
} finally {
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
}
|
||||
if (scaledBitmap == null) {
|
||||
return false;
|
||||
}
|
||||
Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation);
|
||||
if (reorientedBitmap == null) {
|
||||
scaledBitmap.recycle();
|
||||
return false;
|
||||
}
|
||||
Bitmap.CompressFormat format;
|
||||
/* It's not likely the user will give transparent images over the upload limit, but
|
||||
* if they do, make sure the transparency is retained. */
|
||||
if (!reorientedBitmap.hasAlpha()) {
|
||||
format = Bitmap.CompressFormat.JPEG;
|
||||
} else {
|
||||
format = Bitmap.CompressFormat.PNG;
|
||||
}
|
||||
reorientedBitmap.compress(format, 85, stream);
|
||||
reorientedBitmap.recycle();
|
||||
scaledImageSize /= 2;
|
||||
} while (tempFile.length() > sizeLimit);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to communicate the results of the task.
|
||||
*/
|
||||
public interface Listener {
|
||||
void onSuccess(File file);
|
||||
|
||||
void onFailure();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
/* Copyright 2019 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupMenu
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.view.ProgressImageView
|
||||
|
||||
class MediaPreviewAdapter(
|
||||
context: Context,
|
||||
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
|
||||
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
|
||||
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||
|
||||
fun submitList(list: List<ComposeActivity.QueuedMedia>) {
|
||||
this.differ.submitList(list)
|
||||
}
|
||||
|
||||
private fun onMediaClick(position: Int, view: View) {
|
||||
val item = differ.currentList[position]
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val addCaptionId = 1
|
||||
val removeId = 2
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
addCaptionId -> onAddCaption(item)
|
||||
removeId -> onRemove(item)
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private val thumbnailViewSize =
|
||||
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
|
||||
|
||||
override fun getItemCount(): Int = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
|
||||
return PreviewViewHolder(ProgressImageView(parent.context))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) {
|
||||
val item = differ.currentList[position]
|
||||
holder.progressImageView.setChecked(!item.description.isNullOrEmpty())
|
||||
holder.progressImageView.setProgress(item.uploadPercent)
|
||||
Glide.with(holder.itemView.context)
|
||||
.load(item.uri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.dontAnimate()
|
||||
.into(holder.progressImageView)
|
||||
}
|
||||
|
||||
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
|
||||
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem.localId == newItem.localId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
})
|
||||
|
||||
inner class PreviewViewHolder(val progressImageView: ProgressImageView)
|
||||
: RecyclerView.ViewHolder(progressImageView) {
|
||||
init {
|
||||
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
|
||||
val margin = itemView.context.resources
|
||||
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
val marginBottom = itemView.context.resources
|
||||
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
||||
layoutParams.setMargins(margin, 0, margin, marginBottom)
|
||||
progressImageView.layoutParams = layoutParams
|
||||
progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
progressImageView.setOnClickListener {
|
||||
onMediaClick(adapterPosition, progressImageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
/* Copyright 2019 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.ProgressRequestBody
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
sealed class UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent()
|
||||
data class FinishedEvent(val attachment: Attachment) : UploadEvent()
|
||||
}
|
||||
|
||||
fun createNewImageFile(context: Context): File {
|
||||
// Create an image file name
|
||||
val randomId = randomAlphanumericString(12)
|
||||
val imageFileName = "Tusky_${randomId}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(
|
||||
imageFileName, /* prefix */
|
||||
".jpg", /* suffix */
|
||||
storageDir /* directory */
|
||||
)
|
||||
}
|
||||
|
||||
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
||||
|
||||
interface MediaUploader {
|
||||
fun prepareMedia(inUri: Uri): Single<PreparedMedia>
|
||||
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent>
|
||||
}
|
||||
|
||||
class VideoSizeException : Exception()
|
||||
class MediaTypeException : Exception()
|
||||
class CouldNotOpenFileException : Exception()
|
||||
|
||||
class MediaUploaderImpl(
|
||||
private val context: Context,
|
||||
private val mastodonApi: MastodonApi
|
||||
) : MediaUploader {
|
||||
override fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable
|
||||
.fromCallable {
|
||||
if (shouldResizeMedia(media)) {
|
||||
downsize(media)
|
||||
}
|
||||
media
|
||||
}
|
||||
.switchMap { upload(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
override fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
|
||||
return Single.fromCallable {
|
||||
var mediaSize = getMediaSize(contentResolver, inUri)
|
||||
var uri = inUri
|
||||
val mimeType = contentResolver.getType(uri)
|
||||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
||||
try {
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
uri = inUri
|
||||
}
|
||||
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
|
||||
if (mimeType != null) {
|
||||
val topLevelType = mimeType.substring(0, mimeType.indexOf('/'))
|
||||
when (topLevelType) {
|
||||
"video" -> {
|
||||
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
private fun upload(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable.create { emitter ->
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
val filename = String.format("%s_%s_%s.%s",
|
||||
context.getString(R.string.app_name),
|
||||
Date().time.toString(),
|
||||
randomAlphanumericString(10),
|
||||
fileExtension)
|
||||
|
||||
val stream = contentResolver.openInputStream(media.uri)
|
||||
|
||||
if (mimeType == null) mimeType = "multipart/form-data"
|
||||
|
||||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(stream, media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
emitter.onNext(UploadEvent.ProgressEvent(percentage))
|
||||
}
|
||||
lastProgress = percentage
|
||||
}
|
||||
|
||||
val body = MultipartBody.Part.createFormData("file", filename, fileBody)
|
||||
|
||||
val uploadDisposable = mastodonApi.uploadMedia(body)
|
||||
.subscribe({ attachment ->
|
||||
emitter.onNext(UploadEvent.FinishedEvent(attachment))
|
||||
emitter.onComplete()
|
||||
}, { e ->
|
||||
emitter.onError(e)
|
||||
})
|
||||
|
||||
// Cancel the request when our observable is cancelled
|
||||
emitter.setDisposable(uploadDisposable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downsize(media: QueuedMedia): QueuedMedia {
|
||||
val file = createNewImageFile(context)
|
||||
DownsizeImageTask.resize(arrayOf(media.uri),
|
||||
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file)
|
||||
return media.copy(uri = file.toUri(), mediaSize = file.length())
|
||||
}
|
||||
|
||||
private fun shouldResizeMedia(media: QueuedMedia): Boolean {
|
||||
return media.type == QueuedMedia.Type.IMAGE
|
||||
&& (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT
|
||||
|| getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "MediaUploaderImpl"
|
||||
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
|
||||
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
/* Copyright 2019 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
@file:JvmName("AddPollDialog")
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.AddPollOptionsAdapter
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import kotlinx.android.synthetic.main.dialog_add_poll.view.*
|
||||
|
||||
fun showAddPollDialog(
|
||||
context: Context,
|
||||
poll: NewPoll?,
|
||||
maxOptionCount: Int,
|
||||
maxOptionLength: Int,
|
||||
onUpdatePoll: (NewPoll) -> Unit
|
||||
) {
|
||||
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_add_poll, null)
|
||||
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_poll_24dp)
|
||||
.setTitle(R.string.create_poll_title)
|
||||
.setView(view)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.create()
|
||||
|
||||
val adapter = AddPollOptionsAdapter(
|
||||
options = poll?.options?.toMutableList() ?: mutableListOf("", ""),
|
||||
maxOptionLength = maxOptionLength,
|
||||
onOptionRemoved = { valid ->
|
||||
view.addChoiceButton.isEnabled = true
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
|
||||
},
|
||||
onOptionChanged = { valid ->
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
|
||||
}
|
||||
)
|
||||
|
||||
view.pollChoices.adapter = adapter
|
||||
|
||||
view.addChoiceButton.setOnClickListener {
|
||||
if (adapter.itemCount < maxOptionCount) {
|
||||
adapter.addChoice()
|
||||
}
|
||||
if (adapter.itemCount >= maxOptionCount) {
|
||||
it.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast {
|
||||
it <= poll?.expiresIn ?: 0
|
||||
}
|
||||
|
||||
view.pollDurationSpinner.setSelection(pollDurationId)
|
||||
|
||||
view.multipleChoicesCheckBox.isChecked = poll?.multiple ?: false
|
||||
|
||||
dialog.setOnShowListener {
|
||||
val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
button.setOnClickListener {
|
||||
val selectedPollDurationId = view.pollDurationSpinner.selectedItemPosition
|
||||
|
||||
val pollDuration = context.resources
|
||||
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
|
||||
|
||||
onUpdatePoll(NewPoll(
|
||||
options = adapter.pollOptions,
|
||||
expiresIn = pollDuration,
|
||||
multiple = view.multipleChoicesCheckBox.isChecked
|
||||
))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
|
||||
// make the dialog focusable so the keyboard does not stay behind it
|
||||
dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
/* Copyright 2019 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.text.InputFilter
|
||||
import android.text.InputType
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.util.withLifecycleContext
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420
|
||||
|
||||
|
||||
fun <T> T.makeCaptionDialog(existingDescription: String?,
|
||||
previewUri: Uri,
|
||||
onUpdateDescription: (String) -> LiveData<Boolean>
|
||||
) where T : Activity, T : LifecycleOwner {
|
||||
val dialogLayout = LinearLayout(this)
|
||||
val padding = Utils.dpToPx(this, 8)
|
||||
dialogLayout.setPadding(padding, padding, padding, padding)
|
||||
|
||||
dialogLayout.orientation = LinearLayout.VERTICAL
|
||||
val imageView = ImageView(this)
|
||||
|
||||
val displayMetrics = DisplayMetrics()
|
||||
windowManager.defaultDisplay.getMetrics(displayMetrics)
|
||||
|
||||
val margin = Utils.dpToPx(this, 4)
|
||||
dialogLayout.addView(imageView)
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
|
||||
imageView.layoutParams.height = 0
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
|
||||
|
||||
val input = EditText(this)
|
||||
input.hint = getString(R.string.hint_describe_for_visually_impaired,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT)
|
||||
dialogLayout.addView(input)
|
||||
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
|
||||
input.setLines(2)
|
||||
input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||
input.setText(existingDescription)
|
||||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
|
||||
val okListener = { dialog: DialogInterface, _: Int ->
|
||||
onUpdateDescription(input.text.toString())
|
||||
withLifecycleContext {
|
||||
onUpdateDescription(input.text.toString())
|
||||
.observe { success -> if (!success) showFailedCaptionMessage() }
|
||||
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(dialogLayout)
|
||||
.setPositiveButton(android.R.string.ok, okListener)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
||||
val window = dialog.window
|
||||
window?.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
dialog.show()
|
||||
|
||||
// Load the image and manually set it into the ImageView because it doesn't have a fixed
|
||||
// size. Maybe we should limit the size of CustomTarget
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
.into(object : CustomTarget<Drawable>() {
|
||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun Activity.showFailedCaptionMessage() {
|
||||
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
/* Copyright 2018 Conny Duck
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.LinearLayout
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import kotlinx.android.synthetic.main.view_compose_options.view.*
|
||||
|
||||
|
||||
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
var listener: ComposeOptionsListener? = null
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.view_compose_options, this)
|
||||
|
||||
publicRadioButton.setButtonDrawable(R.drawable.ic_public_24dp)
|
||||
unlistedRadioButton.setButtonDrawable(R.drawable.ic_lock_open_24dp)
|
||||
privateRadioButton.setButtonDrawable(R.drawable.ic_lock_outline_24dp)
|
||||
directRadioButton.setButtonDrawable(R.drawable.ic_email_24dp)
|
||||
|
||||
visibilityRadioGroup.setOnCheckedChangeListener { _, checkedId ->
|
||||
val visibility = when (checkedId) {
|
||||
R.id.publicRadioButton ->
|
||||
Status.Visibility.PUBLIC
|
||||
R.id.unlistedRadioButton ->
|
||||
Status.Visibility.UNLISTED
|
||||
R.id.privateRadioButton ->
|
||||
Status.Visibility.PRIVATE
|
||||
R.id.directRadioButton ->
|
||||
Status.Visibility.DIRECT
|
||||
else ->
|
||||
Status.Visibility.PUBLIC
|
||||
}
|
||||
listener?.onVisibilityChanged(visibility)
|
||||
}
|
||||
}
|
||||
|
||||
fun setStatusVisibility(visibility: Status.Visibility) {
|
||||
val selectedButton = when (visibility) {
|
||||
Status.Visibility.PUBLIC ->
|
||||
R.id.publicRadioButton
|
||||
Status.Visibility.UNLISTED ->
|
||||
R.id.unlistedRadioButton
|
||||
Status.Visibility.PRIVATE ->
|
||||
R.id.privateRadioButton
|
||||
Status.Visibility.DIRECT ->
|
||||
R.id.directRadioButton
|
||||
else ->
|
||||
R.id.directRadioButton
|
||||
|
||||
}
|
||||
|
||||
visibilityRadioGroup.check(selectedButton)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface ComposeOptionsListener {
|
||||
fun onVisibilityChanged(visibility: Status.Visibility)
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
/* Copyright 2019 kyori19
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
||||
import com.google.android.material.datepicker.CalendarConstraints;
|
||||
import com.google.android.material.datepicker.DateValidatorPointForward;
|
||||
import com.google.android.material.datepicker.MaterialDatePicker;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.fragment.TimePickerFragment;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
|
||||
public class ComposeScheduleView extends ConstraintLayout {
|
||||
|
||||
private DateFormat dateFormat;
|
||||
private DateFormat timeFormat;
|
||||
private SimpleDateFormat iso8601;
|
||||
|
||||
private Button resetScheduleButton;
|
||||
private TextView scheduledDateTimeView;
|
||||
|
||||
private Calendar scheduleDateTime;
|
||||
|
||||
public ComposeScheduleView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public ComposeScheduleView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public ComposeScheduleView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
inflate(getContext(), R.layout.view_compose_schedule, this);
|
||||
|
||||
dateFormat = SimpleDateFormat.getDateInstance();
|
||||
timeFormat = SimpleDateFormat.getTimeInstance();
|
||||
iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault());
|
||||
iso8601.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
|
||||
resetScheduleButton = findViewById(R.id.resetScheduleButton);
|
||||
scheduledDateTimeView = findViewById(R.id.scheduledDateTime);
|
||||
|
||||
scheduledDateTimeView.setOnClickListener(v -> openPickDateDialog());
|
||||
|
||||
scheduleDateTime = null;
|
||||
|
||||
setScheduledDateTime();
|
||||
|
||||
setEditIcons();
|
||||
}
|
||||
|
||||
private void setScheduledDateTime() {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduledDateTimeView.setText("");
|
||||
} else {
|
||||
scheduledDateTimeView.setText(String.format("%s %s",
|
||||
dateFormat.format(scheduleDateTime.getTime()),
|
||||
timeFormat.format(scheduleDateTime.getTime())));
|
||||
}
|
||||
}
|
||||
|
||||
private void setEditIcons() {
|
||||
Drawable icon = ThemeUtils.getTintedDrawable(getContext(), R.drawable.ic_create_24dp, android.R.attr.textColorTertiary);
|
||||
if (icon == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int size = scheduledDateTimeView.getLineHeight();
|
||||
|
||||
icon.setBounds(0, 0, size, size);
|
||||
|
||||
scheduledDateTimeView.setCompoundDrawables(null, null, icon, null);
|
||||
}
|
||||
|
||||
public void setResetOnClickListener(OnClickListener listener) {
|
||||
resetScheduleButton.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
public void resetSchedule() {
|
||||
scheduleDateTime = null;
|
||||
setScheduledDateTime();
|
||||
}
|
||||
|
||||
public void openPickDateDialog() {
|
||||
long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000;
|
||||
CalendarConstraints calendarConstraints = new CalendarConstraints.Builder()
|
||||
.setValidator(
|
||||
DateValidatorPointForward.from(yesterday))
|
||||
.build();
|
||||
if (scheduleDateTime == null) {
|
||||
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
|
||||
}
|
||||
MaterialDatePicker<Long> picker = MaterialDatePicker.Builder
|
||||
.datePicker()
|
||||
.setSelection(scheduleDateTime.getTimeInMillis())
|
||||
.setCalendarConstraints(calendarConstraints)
|
||||
.build();
|
||||
picker.addOnPositiveButtonClickListener(this::onDateSet);
|
||||
picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "date_picker");
|
||||
}
|
||||
|
||||
private void openPickTimeDialog() {
|
||||
TimePickerFragment picker = new TimePickerFragment();
|
||||
if (scheduleDateTime != null) {
|
||||
Bundle args = new Bundle();
|
||||
args.putInt(TimePickerFragment.PICKER_TIME_HOUR, scheduleDateTime.get(Calendar.HOUR_OF_DAY));
|
||||
args.putInt(TimePickerFragment.PICKER_TIME_MINUTE, scheduleDateTime.get(Calendar.MINUTE));
|
||||
picker.setArguments(args);
|
||||
}
|
||||
picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "time_picker");
|
||||
}
|
||||
|
||||
public void setDateTime(String scheduledAt) {
|
||||
Date date;
|
||||
try {
|
||||
date = iso8601.parse(scheduledAt);
|
||||
} catch (ParseException e) {
|
||||
return;
|
||||
}
|
||||
if (scheduleDateTime == null) {
|
||||
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
|
||||
}
|
||||
scheduleDateTime.setTime(date);
|
||||
setScheduledDateTime();
|
||||
}
|
||||
|
||||
private void onDateSet(long selection) {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
|
||||
}
|
||||
Calendar newDate = Calendar.getInstance(TimeZone.getDefault());
|
||||
newDate.setTimeInMillis(selection);
|
||||
scheduleDateTime.set(newDate.get(Calendar.YEAR), newDate.get(Calendar.MONTH), newDate.get(Calendar.DATE));
|
||||
openPickTimeDialog();
|
||||
}
|
||||
|
||||
public void onTimeSet(int hourOfDay, int minute) {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduleDateTime = Calendar.getInstance(TimeZone.getDefault());
|
||||
}
|
||||
scheduleDateTime.set(Calendar.HOUR_OF_DAY, hourOfDay);
|
||||
scheduleDateTime.set(Calendar.MINUTE, minute);
|
||||
setScheduledDateTime();
|
||||
}
|
||||
|
||||
public String getTime() {
|
||||
if (scheduleDateTime == null) {
|
||||
return null;
|
||||
}
|
||||
return iso8601.format(scheduleDateTime.getTime());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/* Copyright 2018 Conny Duck
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import androidx.emoji.widget.EmojiEditTextHelper
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
|
||||
import android.text.InputType
|
||||
import android.text.method.KeyListener
|
||||
import android.util.AttributeSet
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
|
||||
class EditTextTyped @JvmOverloads constructor(context: Context,
|
||||
attributeSet: AttributeSet? = null)
|
||||
: AppCompatMultiAutoCompleteTextView(context, attributeSet) {
|
||||
|
||||
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
|
||||
private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this)
|
||||
|
||||
init {
|
||||
//fix a bug with autocomplete and some keyboards
|
||||
val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)
|
||||
inputType = newInputType
|
||||
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener))
|
||||
}
|
||||
|
||||
override fun setKeyListener(input: KeyListener) {
|
||||
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(input))
|
||||
}
|
||||
|
||||
fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) {
|
||||
onCommitContentListener = listener
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
return if (onCommitContentListener != null) {
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
getEmojiEditTextHelper().onCreateInputConnection(InputConnectionCompat.createWrapper(connection, editorInfo,
|
||||
onCommitContentListener!!), editorInfo)!!
|
||||
} else {
|
||||
connection
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmojiEditTextHelper(): EmojiEditTextHelper {
|
||||
return emojiEditTextHelper
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/* Copyright 2019 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.LinearLayout
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import kotlinx.android.synthetic.main.view_poll_preview.view.*
|
||||
|
||||
class PollPreviewView @JvmOverloads constructor(
|
||||
context: Context?,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0)
|
||||
: LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
val adapter = PreviewPollOptionsAdapter()
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.view_poll_preview, this)
|
||||
|
||||
orientation = VERTICAL
|
||||
|
||||
setBackgroundResource(R.drawable.card_frame)
|
||||
|
||||
val padding = resources.getDimensionPixelSize(R.dimen.poll_preview_padding)
|
||||
|
||||
setPadding(padding, padding, padding, padding)
|
||||
|
||||
pollPreviewOptions.adapter = adapter
|
||||
|
||||
}
|
||||
|
||||
fun setPoll(poll: NewPoll){
|
||||
adapter.update(poll.options, poll.multiple)
|
||||
|
||||
val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast {
|
||||
it <= poll.expiresIn
|
||||
}
|
||||
pollDurationPreview.text = resources.getStringArray(R.array.poll_duration_names)[pollDurationId]
|
||||
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
super.setOnClickListener(l)
|
||||
adapter.setOnClickListener(l)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffXfermode;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
||||
public final class ProgressImageView extends AppCompatImageView {
|
||||
|
||||
private int progress = -1;
|
||||
private final RectF progressRect = new RectF();
|
||||
private final RectF biggerRect = new RectF();
|
||||
private final Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint clearPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint markBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private Drawable captionDrawable;
|
||||
|
||||
public ProgressImageView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public ProgressImageView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public ProgressImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
circlePaint.setColor(ContextCompat.getColor(getContext(), R.color.tusky_blue));
|
||||
circlePaint.setStrokeWidth(Utils.dpToPx(getContext(), 4));
|
||||
circlePaint.setStyle(Paint.Style.STROKE);
|
||||
|
||||
clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
|
||||
|
||||
markBgPaint.setStyle(Paint.Style.FILL);
|
||||
markBgPaint.setColor(ContextCompat.getColor(getContext(),
|
||||
R.color.description_marker_unselected));
|
||||
captionDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.spellcheck);
|
||||
}
|
||||
|
||||
public void setProgress(int progress) {
|
||||
this.progress = progress;
|
||||
if (progress != -1) {
|
||||
setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY);
|
||||
} else {
|
||||
clearColorFilter();
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setChecked(boolean checked) {
|
||||
this.markBgPaint.setColor(ContextCompat.getColor(getContext(),
|
||||
checked ? R.color.tusky_blue : R.color.description_marker_unselected));
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
|
||||
float angle = (progress / 100f) * 360 - 90;
|
||||
float halfWidth = getWidth() / 2;
|
||||
float halfHeight = getHeight() / 2;
|
||||
progressRect.set(halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f, halfHeight * 1.25f);
|
||||
biggerRect.set(progressRect);
|
||||
int margin = 8;
|
||||
biggerRect.set(progressRect.left - margin, progressRect.top - margin, progressRect.right + margin, progressRect.bottom + margin);
|
||||
canvas.saveLayer(biggerRect, null, Canvas.ALL_SAVE_FLAG);
|
||||
if (progress != -1) {
|
||||
canvas.drawOval(progressRect, circlePaint);
|
||||
canvas.drawArc(biggerRect, angle, 360 - angle - 90, true, clearPaint);
|
||||
}
|
||||
canvas.restore();
|
||||
|
||||
int circleRadius = Utils.dpToPx(getContext(), 14);
|
||||
int circleMargin = Utils.dpToPx(getContext(), 14);
|
||||
|
||||
int circleY = getHeight() - circleMargin - circleRadius / 2;
|
||||
int circleX = getWidth() - circleMargin - circleRadius / 2;
|
||||
|
||||
canvas.drawCircle(circleX, circleY, circleRadius, markBgPaint);
|
||||
|
||||
captionDrawable.setBounds(getWidth() - circleMargin - circleRadius,
|
||||
getHeight() - circleMargin - circleRadius,
|
||||
getWidth() - circleMargin,
|
||||
getHeight() - circleMargin);
|
||||
captionDrawable.setTint(Color.WHITE);
|
||||
captionDrawable.draw(canvas);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/* Copyright 2018 Conny Duck
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
|
||||
class TootButton
|
||||
@JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : MaterialButton(context, attrs, defStyleAttr) {
|
||||
|
||||
private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button)
|
||||
|
||||
init {
|
||||
if(smallStyle) {
|
||||
setIconResource(R.drawable.ic_send_24dp)
|
||||
} else {
|
||||
setText(R.string.action_send)
|
||||
iconGravity = ICON_GRAVITY_TEXT_START
|
||||
}
|
||||
val padding = resources.getDimensionPixelSize(R.dimen.toot_button_horizontal_padding)
|
||||
setPadding(padding, 0, padding, 0)
|
||||
}
|
||||
|
||||
fun setStatusVisibility(visibility: Status.Visibility) {
|
||||
if(!smallStyle) {
|
||||
|
||||
icon = when (visibility) {
|
||||
Status.Visibility.PUBLIC -> {
|
||||
setText(R.string.action_send_public)
|
||||
null
|
||||
}
|
||||
Status.Visibility.UNLISTED -> {
|
||||
setText(R.string.action_send)
|
||||
null
|
||||
}
|
||||
Status.Visibility.PRIVATE,
|
||||
Status.Visibility.DIRECT -> {
|
||||
setText(R.string.action_send)
|
||||
IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).sizeDp(18).color(Color.WHITE)
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -38,7 +38,12 @@ import androidx.paging.PagedListAdapter
|
|||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.keylesspalace.tusky.*
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
|
@ -195,14 +200,14 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
mentionedUsernames.add(username)
|
||||
}
|
||||
mentionedUsernames.remove(loggedInUsername)
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(inReplyToId)
|
||||
.replyVisibility(replyVisibility)
|
||||
.contentWarning(contentWarning)
|
||||
.mentionedUsernames(mentionedUsernames)
|
||||
.replyingStatusAuthor(actionableStatus.account.localUsername)
|
||||
.replyingStatusContent(actionableStatus.content.toString())
|
||||
.build(context)
|
||||
val intent = ComposeActivity.startIntent(context!!, ComposeOptions(
|
||||
inReplyToId = inReplyToId,
|
||||
replyVisibility = replyVisibility,
|
||||
contentWarning = contentWarning,
|
||||
mentionedUsernames = mentionedUsernames,
|
||||
replyingStatusAuthor = actionableStatus.account.localUsername,
|
||||
replyingStatusContent = actionableStatus.content.toString()
|
||||
))
|
||||
requireActivity().startActivity(intent)
|
||||
}
|
||||
|
||||
|
@ -398,24 +403,24 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
viewModel.deleteStatus(id)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe ({ deletedStatus ->
|
||||
.subscribe({ deletedStatus ->
|
||||
removeItem(position)
|
||||
|
||||
val redraftStatus = if(deletedStatus.isEmpty()) {
|
||||
val redraftStatus = if (deletedStatus.isEmpty()) {
|
||||
status.toDeletedStatus()
|
||||
} else {
|
||||
deletedStatus
|
||||
}
|
||||
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.tootText(redraftStatus.text)
|
||||
.inReplyToId(redraftStatus.inReplyToId)
|
||||
.visibility(redraftStatus.visibility)
|
||||
.contentWarning(redraftStatus.spoilerText)
|
||||
.mediaAttachments(redraftStatus.attachments)
|
||||
.sensitive(redraftStatus.sensitive)
|
||||
.poll(redraftStatus.poll?.toNewPoll(status.createdAt))
|
||||
.build(context)
|
||||
val intent = ComposeActivity.startIntent(context!!, ComposeOptions(
|
||||
tootText = redraftStatus.text ?: "",
|
||||
inReplyToId = redraftStatus.inReplyToId,
|
||||
visibility = redraftStatus.visibility,
|
||||
contentWarning = redraftStatus.spoilerText,
|
||||
mediaAttachments = redraftStatus.attachments,
|
||||
sensitive = redraftStatus.sensitive,
|
||||
poll = redraftStatus.poll?.toNewPoll(status.createdAt)
|
||||
))
|
||||
startActivity(intent)
|
||||
}, { error ->
|
||||
Log.w("SearchStatusesFragment", "error deleting status", error)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue