chats: add media, stickers, emojis (wip)

main
Alibek Omarov 4 years ago
parent c2439405be
commit 159f0f0615
  1. 12
      app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json
  2. 549
      app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt
  3. 22
      app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt
  4. 377
      app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt
  5. 2
      app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java
  6. 36
      app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt
  7. 35
      app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
  8. 328
      app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
  9. 1
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  10. 3
      app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt
  11. 4
      app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt
  12. 4
      app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
  13. 6
      app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
  14. 5
      app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt
  15. 250
      app/src/main/res/layout/activity_chat.xml
  16. 3
      app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 25,
"identityHash": "8c97dfd1b3d04602e25139ec97d2a282",
"identityHash": "ee8ddca7a73aef753951c2e2522cbb28",
"entities": [
{
"tableName": "TootEntity",
@ -296,7 +296,7 @@
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, `chatLimit` INTEGER, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
@ -333,6 +333,12 @@
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatLimit",
"columnName": "chatLimit",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
@ -867,7 +873,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c97dfd1b3d04602e25139ec97d2a282')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee8ddca7a73aef753951c2e2522cbb28')"
]
}
}

@ -1,18 +1,31 @@
package com.keylesspalace.tusky.components.chat
import android.Manifest
import android.app.Activity
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.Toast
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.adapter.ChatMessagesAdapter
import com.keylesspalace.tusky.adapter.ChatMessagesViewHolder
import com.keylesspalace.tusky.adapter.TimelineAdapter
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Chat
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.interfaces.ChatActionListener
@ -20,30 +33,55 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.ChatMesssageOrPlaceholder
import com.keylesspalace.tusky.repository.ChatRepository
import com.keylesspalace.tusky.viewdata.ChatMessageViewData
import kotlinx.android.synthetic.main.activity_chat.*
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
import androidx.arch.core.util.Function
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.view.inputmethod.InputContentInfoCompat
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.*
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.*
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.common.*
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.repository.Placeholder
import com.keylesspalace.tusky.repository.TimelineRequestMode
import com.keylesspalace.tusky.service.MessageToSend
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.view.EmojiKeyboard
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.activity_chat.progressBar
import kotlinx.android.synthetic.main.fragment_timeline.*
import kotlinx.android.synthetic.main.activity_chat.*
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
import java.io.File
import java.io.IOException
import java.lang.Exception
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ChatActivity: BottomSheetActivity(),
Injectable, ChatActionListener {
Injectable,
ChatActionListener,
ComposeAutoCompleteAdapter.AutocompletionProvider,
EmojiKeyboard.OnEmojiSelectedListener,
OnEmojiSelectedListener,
InputConnectionCompat.OnCommitContentListener {
private val TAG = "ChatsActivity" // logging tag
private val LOAD_AT_ONCE = 30
@ -55,6 +93,13 @@ class ChatActivity: BottomSheetActivity(),
lateinit var chatsRepo: ChatRepository
@Inject
lateinit var serviceClient: ServiceClient
@Inject
lateinit var viewModelFactory: ViewModelFactory
@VisibleForTesting
val viewModel: ChatViewModel by viewModels { viewModelFactory }
@VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
lateinit var adapter: ChatMessagesAdapter
@ -64,10 +109,17 @@ class ChatActivity: BottomSheetActivity(),
})
private var bottomLoading = false
private var eventRegistered = false
private var isNeedRefresh = false
private var didLoadEverythingBottom = false
private var initialUpdateFailed = false
private var haveStickers = false
private lateinit var addMediaBehavior : BottomSheetBehavior<*>
private lateinit var emojiBehavior: BottomSheetBehavior<*>
private lateinit var stickerBehavior: BottomSheetBehavior<*>
private var finishingUploadDialog: ProgressDialog? = null
private var photoUploadUri: Uri? = null
private enum class FetchEnd {
TOP, BOTTOM, MIDDLE
@ -130,46 +182,70 @@ class ChatActivity: BottomSheetActivity(),
}
private lateinit var chatId : String
private lateinit var avatarUrl : String
private lateinit var displayName : String
private lateinit var username : String
private lateinit var emojis : ArrayList<Emoji>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val chatId = intent.getStringExtra(ID)
val avatarUrl = intent.getStringExtra(AVATAR_URL)
val displayName = intent.getStringExtra(DISPLAY_NAME)
val username = intent.getStringExtra(USERNAME)
val emojis = intent.getParcelableArrayListExtra<Emoji>(EMOJIS)
if(chatId == null || avatarUrl == null || displayName == null || username == null || emojis == null) {
throw IllegalArgumentException("Can't open ChatActivity without chat id")
}
this.chatId = chatId
if(accountManager.activeAccount == null) {
throw Exception("No active account!")
}
chatId = intent.getStringExtra(ID) ?: throw IllegalArgumentException("Can't open ChatActivity without chatId")
avatarUrl = intent.getStringExtra(AVATAR_URL) ?: throw IllegalArgumentException("Can't open ChatActivity without avatarUrl")
displayName = intent.getStringExtra(DISPLAY_NAME) ?: throw IllegalArgumentException("Can't open ChatActivity without displayName")
username = intent.getStringExtra(USERNAME) ?: throw IllegalArgumentException("Can't open ChatActivity without username")
emojis = intent.getParcelableArrayListExtra<Emoji>(EMOJIS) ?: throw IllegalArgumentException("Can't open ChatActivity without emojis")
setContentView(R.layout.activity_chat)
setSupportActionBar(toolbar)
subscribeToUpdates()
setupHeader()
setupChat()
setupAttachment()
setupComposeField(savedInstanceState?.getString(MESSAGE_KEY))
setupButtons()
photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY)
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? ->
when(event) {
is ChatMessageDeliveredEvent -> {
onRefresh()
enableButton(sendButton, true, true)
enableButton(attachmentButton, true, true)
enableButton(stickerButton, haveStickers, haveStickers)
editText.text.clear()
viewModel.media.value = listOf()
}
}
}
tryCache()
}
private fun setupHeader() {
supportActionBar?.run {
title = ""
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
loadAvatar(avatarUrl, chatAvatar,
resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),true)
loadAvatar(avatarUrl, chatAvatar, resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),true)
chatTitle.text = displayName.emojify(emojis, chatTitle, true)
chatUsername.text = username
}
val statusDisplayOptions = StatusDisplayOptions(
false,
false,
true,
false,
false,
CardViewMode.NONE,
private fun setupChat() {
val statusDisplayOptions = StatusDisplayOptions(false,false,
true, false, false, CardViewMode.NONE,
false)
adapter = ChatMessagesAdapter(dataSource, this, statusDisplayOptions, accountManager.activeAccount!!.accountId)
@ -181,34 +257,399 @@ class ChatActivity: BottomSheetActivity(),
recycler.layoutManager = layoutManager
// recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
recycler.adapter = adapter
}
private fun setupAttachment() {
val onMediaPick = View.OnClickListener { view ->
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 ->
viewModel.media.value?.get(0)?.let {
when (menuItem.itemId) {
addCaptionId -> {
makeCaptionDialog(it.description, it.uri) { newDescription ->
viewModel.updateDescription(it.localId, newDescription)
}
}
removeId -> viewModel.removeMediaFromQueue(it)
}
}
true
}
}
imageAttachment.setOnClickListener(onMediaPick)
textAttachment.setOnClickListener(onMediaPick)
}
private fun setupComposeField(startingText: String?) {
editText.setOnCommitContentListener(this)
editText.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
editText.setAdapter(
ComposeAutoCompleteAdapter(this))
editText.setTokenizer(ComposeTokenizer())
editText.setText(startingText)
editText.setSelection(editText.length())
val mentionColour = editText.linkTextColors.defaultColor
highlightSpans(editText.text, mentionColour)
editText.afterTextChanged { editable ->
highlightSpans(editable, mentionColour)
}
// 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) {
editText.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
}
}
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
return viewModel.searchAutocompleteSuggestions(token)
}
/** This is for the fancy keyboards which can insert images and stuff. */
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean {
// Verify the returned content's type is of the correct MIME type
val supported = inputContentInfo.description.hasMimeType("image/*")
if(supported) {
val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
if(lacksPermission) {
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
return false
}
}
pickMedia(inputContentInfo.contentUri, inputContentInfo)
return true
}
return false
}
private fun subscribeToUpdates() {
withLifecycleContext {
viewModel.instanceParams.observe { instanceData ->
maximumTootCharacters = instanceData.chatLimit
}
viewModel.haveStickers.observe { haveStickers ->
if (haveStickers) {
stickerButton.visibility = View.VISIBLE
}
}
viewModel.instanceStickers.observe { stickers ->
if(stickers.isNotEmpty()) {
haveStickers = true
stickerButton.visibility = View.VISIBLE
enableButton(stickerButton, true, true)
stickerKeyboard.setupStickerKeyboard(this@ChatActivity, stickers)
}
}
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
viewModel.media.observe {
if(it.isNotEmpty()) {
val media = it[0]
when(media.type) {
ComposeActivity.QueuedMedia.UNKNOWN -> {
textAttachment.visibility = View.VISIBLE
imageAttachment.visibility = View.GONE
textAttachment.text = media.originalFileName
textAttachment.setChecked(!media.description.isNullOrEmpty())
textAttachment.setProgress(media.uploadPercent)
}
ComposeActivity.QueuedMedia.AUDIO -> {
imageAttachment.visibility = View.VISIBLE
textAttachment.visibility = View.GONE
imageAttachment.setChecked(!media.description.isNullOrEmpty())
imageAttachment.setProgress(media.uploadPercent)
imageAttachment.setImageResource(R.drawable.ic_music_box_preview_24dp)
}
else -> {
imageAttachment.visibility = View.VISIBLE
textAttachment.visibility = View.GONE
imageAttachment.setChecked(!media.description.isNullOrEmpty())
imageAttachment.setProgress(media.uploadPercent)
Glide.with(imageAttachment.context)
.load(media.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(imageAttachment)
}
}
} else {
imageAttachment.visibility = View.GONE
textAttachment.visibility = View.GONE
}
}
viewModel.uploadError.observe {
displayTransientError(R.string.error_media_upload_sending)
}
}
}
private fun setEmojiList(emojiList: List<Emoji>?) {
if (emojiList != null) {
emojiView.adapter = EmojiAdapter(emojiList, this@ChatActivity)
enableButton(emojiButton, true, emojiList.isNotEmpty())
}
}
private fun replaceTextAtCaret(text: CharSequence) {
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
val start = editText.selectionStart.coerceAtMost(editText.selectionEnd)
val end = editText.selectionStart.coerceAtLeast(editText.selectionEnd)
val textToInsert = if (start > 0 && !editText.text[start - 1].isWhitespace()) {
" $text"
} else {
text
}
editText.text.replace(start, end, textToInsert)
// Set the cursor after the inserted text
editText.setSelection(start + text.length)
}
override fun onEmojiSelected(shortcode: String) {
replaceTextAtCaret(":$shortcode: ")
}
override fun onEmojiSelected(id: String, shortcode: String) {
Glide.with(this).asFile().load(shortcode).into( object : CustomTarget<File>() {
override fun onLoadCleared(placeholder: Drawable?) {
displayTransientError(R.string.error_sticker_fetch)
}
override fun onResourceReady(resource: File, transition: Transition<in File>?) {
val cut = shortcode.lastIndexOf('/')
val filename = if(cut != -1) shortcode.substring(cut + 1) else "unknown.png"
pickMedia(resource.toUri(), null, filename)
}
})
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
private fun setupButtons() {
addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet)
emojiBehavior = BottomSheetBehavior.from(emojiView)
stickerBehavior = BottomSheetBehavior.from(stickerKeyboard)
sendButton.setOnClickListener {
val media = viewModel.media.value?.get(0)
serviceClient.sendChatMessage( MessageToSend(
editText.text.toString(),
null,
null,
media?.id,
media?.uri?.toString(),
accountManager.activeAccount!!.id,
this.chatId,
0
))
enableButton(sendButton, false, false)
enableButton(attachmentButton, false, false)
enableButton(stickerButton, false, false)
}
if (!eventRegistered) {
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? ->
when(event) {
is ChatMessageDeliveredEvent -> {
onRefresh()
editText.text.clear()
}
attachmentButton.setOnClickListener { openPickDialog() }
emojiButton.setOnClickListener { showEmojis() }
stickerButton.setOnClickListener { showStickers() }
enableButton(stickerButton, false, false)
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 }
actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 }
actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
actionPhotoTake.setOnClickListener { initiateCameraApp() }
actionPhotoPick.setOnClickListener { onMediaPick() }
}
private fun openPickDialog() {
if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
} else {
addMediaBehavior.state = 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
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
} else {
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
}
}
}
private fun showStickers() {
if (stickerBehavior.state == BottomSheetBehavior.STATE_HIDDEN || stickerBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
stickerBehavior.state = BottomSheetBehavior.STATE_EXPANDED
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
} else {
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
}
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 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@ChatActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this@ChatActivity,
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
}
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(activityChat, 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 initiateMediaPicking() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*" // Pleroma allows anything
startActivityForResult(intent, MEDIA_PICK_RESULT)
}
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 enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
button.isEnabled = clickable
ThemeUtils.setDrawableTint(this, button.drawable,
if (colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled)
}
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) {
withLifecycleContext {
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()
if(exceptionOrItem.isLeft()) {
val errorId = when (val exception = exceptionOrItem.asLeft()) {
is VideoSizeException -> {
R.string.error_video_upload_size
}
is MediaSizeException -> {
R.string.error_media_upload_size
}
is AudioSizeException -> {
R.string.error_audio_upload_size
}
is VideoOrImageException -> {
R.string.error_media_upload_image_or_video
}
else -> {
Log.d(TAG, "That file could not be opened", exception)
R.string.error_media_upload_opening
}
}
eventRegistered = true
displayTransientError(errorId)
} else {
enableButton(attachmentButton, false, false)
}
}
}
}
tryCache()
override fun onSaveInstanceState(outState: Bundle) {
outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri)
outState.putString(MESSAGE_KEY, editText.text.toString())
super.onSaveInstanceState(outState)
}
private fun displayTransientError(@StringRes stringId: Int) {
val bar = Snackbar.make(activityChat, 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 clearPlaceholdersForResponse(msgs: MutableList<ChatMesssageOrPlaceholder>) {
@ -449,7 +890,6 @@ class ChatActivity: BottomSheetActivity(),
}
private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) {
topProgressBar.hide()
if (fetchEnd == FetchEnd.MIDDLE && !msgs[position].isRight()) {
var placeholder = msgs[position].asLeftOrNull()
val newViewData: ChatMessageViewData
@ -509,6 +949,19 @@ class ChatActivity: BottomSheetActivity(),
}
}
override fun onBackPressed() {
// Acting like a teen: deliberately ignoring parent.
if (addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
stickerBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
stickerBehavior.state = BottomSheetBehavior.STATE_HIDDEN
return
}
super.onBackPressed()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
@ -556,6 +1009,12 @@ class ChatActivity: BottomSheetActivity(),
}
companion object {
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 PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
private const val MESSAGE_KEY = "MESSAGE"
fun getIntent(context: Context, chat: Chat) : Intent {
val intent = Intent(context, ChatActivity::class.java)
intent.putExtra(ID, chat.id)

@ -0,0 +1,22 @@
package com.keylesspalace.tusky.components.chat
import com.keylesspalace.tusky.components.common.CommonComposeViewModel
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.util.*
import javax.inject.Inject
open class ChatViewModel
@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
) : CommonComposeViewModel(api, accountManager, mediaUploader, db) {
}

@ -0,0 +1,377 @@
/* 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.common
import android.net.Uri
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
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.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import io.reactivex.rxkotlin.Singles
import retrofit2.Response
import java.util.*
import javax.inject.Inject
/**
* Throw when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()
open class CommonComposeViewModel(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val db: AppDatabase
) : RxAwareViewModel() {
protected val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
protected val nodeinfo: MutableLiveData<NodeInfo?> = MutableLiveData(null)
protected val stickers: MutableLiveData<Array<StickerPack>> = MutableLiveData(emptyArray())
val haveStickers: MutableLiveData<Boolean> = MutableLiveData(false)
var tryFetchStickers = false
var hasNoAttachmentLimits = false
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
ComposeInstanceParams(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
chatLimit = instance?.chatLimit ?: 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 instanceMetadata: LiveData<ComposeInstanceMetadata> = nodeinfo.map { nodeinfo ->
val software = nodeinfo?.software?.name ?: "mastodon"
if(software.equals("pleroma")) {
hasNoAttachmentLimits = true
ComposeInstanceMetadata(
software = "pleroma",
supportsMarkdown = nodeinfo?.metadata?.postFormats?.contains("text/markdown") ?: false,
supportsBBcode = nodeinfo?.metadata?.postFormats?.contains("text/bbcode") ?: false,
supportsHTML = nodeinfo?.metadata?.postFormats?.contains("text/html") ?: false,
videoLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_IMAGE_SIZE_LIMIT
)
} else if(software.equals("pixelfed")) {
ComposeInstanceMetadata(
software = "pixelfed",
supportsMarkdown = false,
supportsBBcode = false,
supportsHTML = false,
videoLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_IMAGE_SIZE_LIMIT
)
} else {
ComposeInstanceMetadata(
software = "mastodon",
supportsMarkdown = nodeinfo?.software?.version?.contains("+glitch") ?: false,
supportsBBcode = false,
supportsHTML = nodeinfo?.software?.version?.contains("+glitch") ?: false,
videoLimit = STATUS_VIDEO_SIZE_LIMIT,
imageLimit = STATUS_IMAGE_SIZE_LIMIT
)
}
}
val instanceStickers: LiveData<Array<StickerPack>> = stickers // .map { stickers -> HashMap<String,String>(stickers) }
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val media = mutableLiveData<List<QueuedMedia>>(listOf())
val uploadError = MutableLiveData<Throwable>()
protected 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,
chatLimit = instance.chatLimit
)
}
.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()
api.getNodeinfoLinks().subscribe({
links -> if(links.links.isNotEmpty()) {
api.getNodeinfo(links.links[0].href).subscribe({
ni -> nodeinfo.postValue(ni)
}, {
err -> Log.d(TAG, "Failed to get nodeinfo", err)
}).autoDispose()
}
}, { err ->
Log.d(TAG, "Failed to get nodeinfo links", err)
}).autoDispose()
}
fun pickMedia(uri: Uri, filename: String?): 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>>()
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
mediaUploader.prepareMedia(uri, videoLimit, imageLimit, filename)
.map { (type, uri, size) ->
val mediaItems = media.value!!
if (!hasNoAttachmentLimits
&& type != QueuedMedia.Type.IMAGE
&& mediaItems.isNotEmpty()
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
throw VideoOrImageException()
} else {
addMediaToQueue(type, uri, size, filename ?: "unknown")
}
}
.subscribe({ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia))
}, { error ->
liveData.postValue(Either.Left(error))
})
.autoDispose()
return liveData
}
private fun addMediaToQueue(type: Int, uri: Uri, mediaSize: Long, filename: String): QueuedMedia {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize, filename,
hasNoAttachmentLimits)
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem, videoLimit, imageLimit )
.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
}
protected fun addUploadedMedia(id: String, type: Int, uri: Uri, description: String?) {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, "unknown",
hasNoAttachmentLimits, -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 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()
}
private fun getStickers() {
if(!tryFetchStickers)
return
api.getStickers().subscribe({ stickers ->
if (stickers.isNotEmpty()) {
haveStickers.postValue(true)
val singles = mutableListOf<Single<Response<StickerPack>>>()
for(entry in stickers) {
val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json";
singles += api.getStickerPack(url)
}
Single.zip(singles) {
it.map {
it as Response<StickerPack>
it.body()!!.internal_url = it.raw().request.url.toString().removeSuffix("pack.json")
it.body()!!
}
}.onErrorReturn {
Log.d(TAG, "Failed to get sticker pack.json", it)
emptyList()
}.subscribe() { pack ->
if(pack.isNotEmpty()) {
val array = pack.toTypedArray()
array.sort()
this.stickers.postValue(array)
}
}.autoDispose()
}
}, {
err -> Log.d(TAG, "Failed to get sticker.json", err)
}).autoDispose()
}
fun setup() {
getStickers() // early as possible
}
private companion object {
const val TAG = "CCVM"
}
}
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
const val DEFAULT_CHARACTER_LIMIT = 500
const val DEFAULT_MAX_OPTION_COUNT = 4
const val DEFAULT_MAX_OPTION_LENGTH = 25
const val STATUS_VIDEO_SIZE_LIMIT : Long = 41943040 // 40MiB
const val STATUS_IMAGE_SIZE_LIMIT : Long = 8388608 // 8MiB
data class ComposeInstanceParams(
val maxChars: Int,
val chatLimit: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val supportsScheduled: Boolean
)
data class ComposeInstanceMetadata(
val software: String,
val supportsMarkdown: Boolean,
val supportsBBcode: Boolean,
val supportsHTML: Boolean,
val videoLimit: Long,
val imageLimit: Long
)

@ -13,7 +13,7 @@
* 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;
package com.keylesspalace.tusky.components.common;
import android.content.ContentResolver;
import android.graphics.Bitmap;

@ -13,11 +13,13 @@
* 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
package com.keylesspalace.tusky.components.common
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
@ -132,22 +134,22 @@ class MediaUploaderImpl(
if (mediaSize > videoLimit) {
throw VideoSizeException()
}
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
PreparedMedia(QueuedMedia.VIDEO, uri, mediaSize)
}
"image" -> {
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
PreparedMedia(QueuedMedia.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > videoLimit) { // TODO: CHANGE!!11
throw AudioSizeException()
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
PreparedMedia(QueuedMedia.AUDIO, uri, mediaSize)
}
else -> {
if (mediaSize > videoLimit) {
throw MediaSizeException()
}
PreparedMedia(QueuedMedia.Type.UNKNOWN, uri, mediaSize)
PreparedMedia(QueuedMedia.UNKNOWN, uri, mediaSize)
// throw MediaTypeException()
}
}
@ -226,3 +228,27 @@ class MediaUploaderImpl(
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
}
}
fun Uri.toFileName(contentResolver: ContentResolver? = null): String {
var result: String = "unknown"
if(scheme.equals("content") && contentResolver != null) {
val cursor = contentResolver.query(this, null, null, null, null)
cursor?.use{
if(it.moveToFirst()) {
result = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
}
if(result.equals("unknown")) {
path?.let {
result = it
val cut = result.lastIndexOf('/')
if (cut != -1) {
result = result.substring(cut + 1)
}
}
}
return result
}

@ -23,7 +23,6 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.ContentResolver
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
@ -53,7 +52,6 @@ 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.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
@ -61,7 +59,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
@ -72,6 +69,7 @@ import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.common.*
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
@ -210,35 +208,6 @@ class ComposeActivity : BaseActivity(),
}
}
private fun uriToFilename(uri: Uri): String {
var result: String = "unknown"
if(uri.scheme.equals("content")) {
val cursor = contentResolver.query(uri, null, null, null, null)
cursor?.let {
try {
if(cursor.moveToFirst()) {
result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
finally {
cursor.close()
}
}
}
if(result.equals("unknown")) {
val path = uri.getPath()
path?.let {
result = path
val cut = result.lastIndexOf('/')
if (cut != -1) {
result = result.substring(cut + 1)
}
}
}
return result
}
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
@ -1129,7 +1098,7 @@ class ComposeActivity : BaseActivity(),
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) {
withLifecycleContext {
viewModel.pickMedia(uri, filename ?: uriToFilename(uri)).observe { exceptionOrItem ->
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()

@ -22,6 +22,10 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.components.common.CommonComposeViewModel
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.components.common.UploadEvent
import com.keylesspalace.tusky.components.common.mutableLiveData
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
@ -36,17 +40,10 @@ import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import io.reactivex.rxkotlin.Singles
import io.reactivex.schedulers.Schedulers
import retrofit2.Response
import java.util.*
import javax.inject.Inject
/**
* 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,
@ -55,7 +52,7 @@ class ComposeViewModel
private val serviceClient: ServiceClient,
private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase
) : RxAwareViewModel() {
) : CommonComposeViewModel(api, accountManager, mediaUploader, db) {
private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null
@ -65,58 +62,8 @@ class ComposeViewModel
private var inReplyToId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
private var contentWarningStateChanged: Boolean = false
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
private val nodeinfo: MutableLiveData<NodeInfo?> = MutableLiveData(null)
private val stickers: MutableLiveData<Array<StickerPack>> = MutableLiveData(emptyArray())
public val haveStickers: MutableLiveData<Boolean> = MutableLiveData(false)
public var tryFetchStickers = false
public var formattingSyntax: String = ""
public var hasNoAttachmentLimits = false
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 instanceMetadata: LiveData<ComposeInstanceMetadata> = nodeinfo.map { nodeinfo ->
val software = nodeinfo?.software?.name ?: "mastodon"
if(software.equals("pleroma")) {
hasNoAttachmentLimits = true
ComposeInstanceMetadata(
software = "pleroma",
supportsMarkdown = nodeinfo?.metadata?.postFormats?.contains("text/markdown") ?: false,
supportsBBcode = nodeinfo?.metadata?.postFormats?.contains("text/bbcode") ?: false,
supportsHTML = nodeinfo?.metadata?.postFormats?.contains("text/html") ?: false,
videoLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_IMAGE_SIZE_LIMIT
)
} else if(software.equals("pixelfed")) {
ComposeInstanceMetadata(
software = "pixelfed",
supportsMarkdown = false,
supportsBBcode = false,
supportsHTML = false,
videoLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_IMAGE_SIZE_LIMIT
)
} else {
ComposeInstanceMetadata(
software = "mastodon",
supportsMarkdown = nodeinfo?.software?.version?.contains("+glitch") ?: false,
supportsBBcode = false,
supportsHTML = nodeinfo?.software?.version?.contains("+glitch") ?: false,
videoLimit = STATUS_VIDEO_SIZE_LIMIT,
imageLimit = STATUS_IMAGE_SIZE_LIMIT
)
}
}
val instanceStickers: LiveData<Array<StickerPack>> = stickers // .map { stickers -> HashMap<String,String>(stickers) }
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
@ -129,125 +76,6 @@ class ComposeViewModel
val setupComplete = 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()
api.getNodeinfoLinks().subscribe({
links -> if(links.links.isNotEmpty()) {
api.getNodeinfo(links.links[0].href).subscribe({
ni -> nodeinfo.postValue(ni)
}, {
err -> Log.d(TAG, "Failed to get nodeinfo", err)
}).autoDispose()
}
}, { err ->
Log.d(TAG, "Failed to get nodeinfo links", err)
}).autoDispose()
}
fun pickMedia(uri: Uri, filename: String?): 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>>()
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
mediaUploader.prepareMedia(uri, videoLimit, imageLimit, filename)
.map { (type, uri, size) ->
val mediaItems = media.value!!
if (!hasNoAttachmentLimits
&& type != QueuedMedia.Type.IMAGE
&& mediaItems.isNotEmpty()
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
throw VideoOrImageException()
} else {
addMediaToQueue(type, uri, size, filename ?: "unknown")
}
}
.subscribe({ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia))
}, { error ->
liveData.postValue(Either.Left(error))
})
.autoDispose()
return liveData
}
private fun addMediaToQueue(type: Int, uri: Uri, mediaSize: Long, filename: String): QueuedMedia {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize, filename,
hasNoAttachmentLimits)
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem, videoLimit, imageLimit )
.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: Int, uri: Uri, description: String?) {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, "unknown",
hasNoAttachmentLimits, -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 {
@ -342,85 +170,6 @@ class ComposeViewModel
}
}
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()
@ -428,45 +177,8 @@ class ComposeViewModel
super.onCleared()
}
private fun getStickers() {
if(!tryFetchStickers)
return
api.getStickers().subscribe({ stickers ->
if (stickers.isNotEmpty()) {
haveStickers.postValue(true)
val singles = mutableListOf<Single<Response<StickerPack>>>()
for(entry in stickers) {
val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json";
singles += api.getStickerPack(url)
}
Single.zip(singles) {
it.map {
it as Response<StickerPack>
it.body()!!.internal_url = it.raw().request.url.toString().removeSuffix("pack.json")
it.body()!!
}
}.onErrorReturn {
Log.d(TAG, "Failed to get sticker pack.json", it)
emptyList()
}.subscribe() { pack ->
if(pack.isNotEmpty()) {
val array = pack.toTypedArray()
array.sort()
this.stickers.postValue(array)
}
}.autoDispose()
}
}, {
err -> Log.d(TAG, "Failed to get sticker.json", err)
}).autoDispose()
}
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
getStickers() // early as possible
super.setup()
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
@ -554,30 +266,4 @@ class ComposeViewModel
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
private const val STATUS_VIDEO_SIZE_LIMIT : Long = 41943040 // 40MiB
private const val STATUS_IMAGE_SIZE_LIMIT : Long = 8388608 // 8MiB
data class ComposeInstanceParams(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val supportsScheduled: Boolean
)
data class ComposeInstanceMetadata(
val software: String,
val supportsMarkdown: Boolean,
val supportsBBcode: Boolean,
val supportsHTML: Boolean,
val videoLimit: Long,
val imageLimit: Long
)
}

@ -370,6 +370,7 @@ public abstract class AppDatabase extends RoomDatabase {
"`attachment` TEXT," +
"`emojis` TEXT NOT NULL," +
"PRIMARY KEY (`localId`, `messageId`))");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `chatLimit` INTEGER");
}
};
}

@ -28,5 +28,6 @@ data class InstanceEntity(
val maximumTootCharacters: Int?,
val maxPollOptions: Int?,
val maxPollOptionLength: Int?,
val version: String?
val version: String?,
val chatLimit: Int?
)

@ -16,8 +16,8 @@
package com.keylesspalace.tusky.di
import android.content.Context
import com.keylesspalace.tusky.components.compose.MediaUploader
import com.keylesspalace.tusky.components.compose.MediaUploaderImpl
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.components.common.MediaUploaderImpl
import com.keylesspalace.tusky.network.MastodonApi
import dagger.Module
import dagger.Provides

@ -60,9 +60,9 @@ class NetworkModule {
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
//level = HttpLoggingInterceptor.Level.BASIC
level = HttpLoggingInterceptor.Level.BASIC
//level = HttpLoggingInterceptor.Level.HEADERS
level = HttpLoggingInterceptor.Level.BODY
//level = HttpLoggingInterceptor.Level.BODY
})
}
}

@ -4,6 +4,7 @@ package com.keylesspalace.tusky.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.chat.ChatViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
@ -85,5 +86,10 @@ abstract class ViewModelModule {
@ViewModelKey(ScheduledTootViewModel::class)
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(ChatViewModel::class)
internal abstract fun chatViewModel(viewModel: ChatViewModel) : ViewModel
//Add more ViewModels here
}

@ -31,6 +31,11 @@ data class Instance (
@SerializedName("max_toot_chars") val maxTootChars: Int?,
@SerializedName("max_bio_chars") val maxBioChars: Int?,
@SerializedName("poll_limits") val pollLimits: PollLimits?,
@SerializedName("chat_limit") val chatLimit: Int?,
@SerializedName("avatar_upload_limit") val avatarUploadLimit: Long?,
@SerializedName("banner_upload_limit") val bannerUploadLimit: Long?,
@SerializedName("description_limit") val descriptionLimit: Int?,
@SerializedName("upload_limit") val uploadLimit: Long?,
val pleroma: InstancePleroma?
) {
override fun hashCode(): Int {

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activityChat"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -54,10 +55,7 @@
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_large"
tools:text="\@Entenhausen@birbsarecooooooooooool.site" />
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
@ -91,7 +89,7 @@
app:layout_constraintTop_toBottomOf="@id/appbar"
tools:visibility="visible"
app:layout_constrainedHeight="true" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/composeLayout"
android:layout_width="match_parent"
@ -105,98 +103,170 @@
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<FrameLayout
android:id="@+id/attachmentLayout"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_margin="8dp"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
tools:visibility="visible">
<com.keylesspalace.tusky.components.compose.view.ProgressImageView
android:id="@+id/imageAttachment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/attachmentLayout"
android:layout_width="@dimen/compose_media_preview_size"
android:layout_height="@dimen/compose_media_preview_size"
android:layout_margin="@dimen/compose_media_preview_margin_bottom"
android:visibility="gone"
tools:visibility="visible"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
tools:visibility="visible">
<com.keylesspalace.tusky.components.compose.view.ProgressImageView
android:id="@+id/imageAttachment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible"
/>
<com.keylesspalace.tusky.components.compose.view.ProgressTextView
android:id="@+id/textAttachment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:ellipsize="marquee"
android:marqueeRepeatLimit="-1"
android:singleLine="true"
android:textSize="?attr/status_text_small"
tools:visibility="visible"
/>
</FrameLayout>
<ImageButton
android:id="@+id/attachmentButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_add_media"
android:tooltipText="@string/action_add_media"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:srcCompat="@drawable/ic_attach_file_24dp" />
<com.keylesspalace.tusky.components.compose.view.EditTextTyped
android:id="@+id/editText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_large"
android:singleLine="false"
android:background="@null"
android:completionThreshold="2"
android:dropDownWidth="wrap_content"
android:hint="@string/hint_compose"
android:inputType="text|textMultiLine|textCapSentences"
android:lineSpacingMultiplier="1.1"
android:textColorHint="?android:attr/textColorTertiary"
app:layout_constraintEnd_toStartOf="@+id/emojiButton"
app:layout_constraintRight_toLeftOf="@id/emojiButton"
app:layout_constraintStart_toEndOf="@+id/attachmentButton"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
tools:text="Just landed in L.A." />
<ImageButton
android:id="@+id/emojiButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_emoji_keyboard"
android:tooltipText="@string/action_emoji_keyboard"
app:srcCompat="@drawable/ic_emoji_24dp"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toLeftOf="@id/stickerButton"
/>
<com.keylesspalace.tusky.components.compose.view.ProgressTextView
android:id="@+id/textAttachment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:ellipsize="marquee"
android:marqueeRepeatLimit="-1"
android:singleLine="true"
android:textSize="?attr/status_text_small"
tools:visibility="visible"
<ImageButton
android:id="@+id/stickerButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_sticker"
android:tooltipText="@string/action_sticker"
app:srcCompat="@drawable/ic_sticker"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toLeftOf="@id/sendButton"
/>
</FrameLayout>
<ImageButton
android:id="@+id/attachmentButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_add_media"
android:tooltipText="@string/action_add_media"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:srcCompat="@drawable/ic_attach_file_24dp" />
<androidx.emoji.widget.EmojiEditText
android:id="@+id/editText"
android:layout_width="0dp"
android:layout_height="48dp"
android:textSize="?attr/status_text_large"
android:singleLine="false"
android:background="@null"
android:hint="@string/hint_compose"
app:layout_constraintEnd_toStartOf="@+id/emojiButton"
app:layout_constraintRight_toLeftOf="@id/emojiButton"
app:layout_constraintStart_toEndOf="@+id/attachmentButton"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
tools:text="Just landed in L.A." />
<ImageButton
android:id="@+id/emojiButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_emoji_keyboard"
android:tooltipText="@string/action_emoji_keyboard"
app:srcCompat="@drawable/ic_emoji_24dp"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toLeftOf="@id/sendButton"
/>
<ImageButton
android:id="@+id/sendButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_send"
android:tooltipText="@string/action_send"
app:srcCompat="@drawable/ic_send_24dp"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toRightOf="parent"
/>
<ImageButton
android:id="@+id/sendButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_send"
android:tooltipText="@string/action_send"
app:srcCompat="@drawable/ic_send_24dp"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toRightOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/emojiView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:clipToPadding="false"
android:elevation="12dp"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="@dimen/compose_activity_bottom_bar_height"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
<com.keylesspalace.tusky.view.EmojiKeyboard
android:id="@+id/stickerKeyboard"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="?attr/colorSurface"
android:elevation="12dp"
android:paddingBottom="@dimen/compose_activity_bottom_bar_height"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
<LinearLayout
android:id="@+id/addMediaBottomSheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<include layout="@layout/item_status_bottom_sheet" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
android:background="?attr/colorSurface"
android:elevation="12dp"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="52dp"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<TextView
android:id="@+id/actionPhotoTake"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
android:padding="8dp"
android:text="@string/action_photo_take"
android:textSize="?attr/status_text_medium" />
<TextView
android:id="@+id/actionPhotoPick"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
android:padding="8dp"
android:text="@string/action_add_media"
android:textSize="?attr/status_text_medium" />
</LinearLayout>
<include layout="@layout/item_status_bottom_sheet" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -23,7 +23,7 @@ import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT
import com.keylesspalace.tusky.components.compose.MediaUploader
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.db.*
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.*
@ -42,7 +42,6 @@ import org.mockito.Mockito.mock
import org.robolectric.Robolectric
import org.robolectric.annotation.Config
import org.robolectric.fakes.RoboMenuItem
import java.lang.Math.pow
/**
* Created by charlag on 3/7/18.

Loading…
Cancel
Save