diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json index f879ddd9..65039688 100644 --- a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt index 8a77e792..b4244449 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt @@ -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 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(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(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 { + 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?) { + 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() { + override fun onLoadCleared(placeholder: Drawable?) { + displayTransientError(R.string.error_sticker_fetch) + } + + override fun onResourceReady(resource: File, transition: Transition?) { + 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, + 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) { @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt new file mode 100644 index 00000000..1e6f650a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt @@ -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) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt new file mode 100644 index 00000000..6c3e7501 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt @@ -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 . */ +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 = MutableLiveData(null) + protected val nodeinfo: MutableLiveData = MutableLiveData(null) + protected val stickers: MutableLiveData> = MutableLiveData(emptyArray()) + val haveStickers: MutableLiveData = MutableLiveData(false) + var tryFetchStickers = false + var hasNoAttachmentLimits = false + + val instanceParams: LiveData = 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 = 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> = stickers // .map { stickers -> HashMap(stickers) } + + val emoji: MutableLiveData?> = MutableLiveData() + + val media = mutableLiveData>(listOf()) + val uploadError = MutableLiveData() + + protected val mediaToDisposable = mutableMapOf() + + 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> { + // 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>() + 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 { + 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() + media.observeForever(object : Observer> { + override fun onChanged(mediaItems: List) { + 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 { + 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() + val resultsInside = ArrayList() + 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>>() + + for(entry in stickers) { + val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json"; + singles += api.getStickerPack(url) + } + + Single.zip(singles) { + it.map { + it as Response + 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 mutableLiveData(default: T) = MutableLiveData().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 +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java rename to app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java index b6007b96..c42a5d34 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.components.compose; +package com.keylesspalace.tusky.components.common; import android.content.ContentResolver; import android.graphics.Bitmap; diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt similarity index 88% rename from app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt rename to app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt index 214dd02b..d86050b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt @@ -13,11 +13,13 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 6c7c62f8..6e869ee5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -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() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index f0b0a8ed..1eed870b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -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 = MutableLiveData(null) - private val nodeinfo: MutableLiveData = MutableLiveData(null) - private val stickers: MutableLiveData> = MutableLiveData(emptyArray()) - public val haveStickers: MutableLiveData = MutableLiveData(false) - public var tryFetchStickers = false public var formattingSyntax: String = "" - public var hasNoAttachmentLimits = false - val instanceParams: LiveData = 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 = 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> = stickers // .map { stickers -> HashMap(stickers) } - - val emoji: MutableLiveData?> = MutableLiveData() val markMediaAsSensitive = mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) @@ -129,125 +76,6 @@ class ComposeViewModel val setupComplete = mutableLiveData(false) val poll: MutableLiveData = mutableLiveData(null) val scheduledAt: MutableLiveData = mutableLiveData(null) - - val media = mutableLiveData>(listOf()) - val uploadError = MutableLiveData() - - private val mediaToDisposable = mutableMapOf() - - - 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> { - // 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>() - 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 { - 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() - media.observeForever(object : Observer> { - override fun onChanged(mediaItems: List) { - 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 { - 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() - val resultsInside = ArrayList() - 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>>() - - for(entry in stickers) { - val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json"; - singles += api.getStickerPack(url) - } - - Single.zip(singles) { - it.map { - it as Response - 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 mutableLiveData(default: T) = MutableLiveData().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 -) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index c22858c3..d0910ee5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -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"); } }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index 1e2adaf0..0d90c296 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -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? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt index 66dc2711..641bab5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 6d81b2ff..64611d45 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -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 }) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index f4929463..107a9b54 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 0f973c46..417129a3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -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 { diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index cb09652f..1dad537b 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -1,8 +1,9 @@ - @@ -54,10 +55,7 @@ android:textColor="?android:textColorSecondary" android:textSize="?attr/status_text_large" tools:text="\@Entenhausen@birbsarecooooooooooool.site" /> - - - - + - - - + + + + + + + + + + + + - - - - - - - - - - + + - - - - - \ No newline at end of file + 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" /> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index 095e2631..42c18ac6 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -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.