refactor compose & announcements to coroutines (#2446)

* refactor compose & announcements to coroutines

* fix code formatting

* add javadoc to InstanceInfoRepository

* fix comments in ImageDownsizer

* remove unused Either extensions

* add explicit return type for InstanceInfoRepository.getEmojis

* make ComposeViewModel.pickMedia return Result

* cleanup code in ImageDownsizer
This commit is contained in:
Konrad Pozniak 2022-04-21 18:46:21 +02:00 committed by GitHub
parent d6e9fd48c0
commit d2bfceae7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 596 additions and 628 deletions

View File

@ -124,7 +124,6 @@ dependencies {
implementation "androidx.work:work-runtime:2.7.1" implementation "androidx.work:work-runtime:2.7.1"
implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-paging:$roomVersion" implementation "androidx.room:room-paging:$roomVersion"
implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta02' implementation 'androidx.core:core-splashscreen:1.0.0-beta02'

View File

@ -779,18 +779,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
private fun fetchAnnouncements() { private fun fetchAnnouncements() {
mastodonApi.listAnnouncements(false) lifecycleScope.launch {
.observeOn(AndroidSchedulers.mainThread()) mastodonApi.listAnnouncements(false)
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .fold(
.subscribe( { announcements ->
{ announcements -> unreadAnnouncementsCount = announcements.count { !it.read }
unreadAnnouncementsCount = announcements.count { !it.read } updateAnnouncementsBadge()
updateAnnouncementsBadge() },
}, { throwable ->
{ Log.w(TAG, "Failed to fetch announcements.", throwable)
Log.w(TAG, "Failed to fetch announcements.", it) }
} )
) }
} }
private fun updateAnnouncementsBadge() { private fun updateAnnouncementsBadge() {

View File

@ -18,32 +18,26 @@ package com.keylesspalace.tusky.components.announcements
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import javax.inject.Inject import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor( class AnnouncementsViewModel @Inject constructor(
accountManager: AccountManager, private val instanceInfoRepo: InstanceInfoRepository,
private val appDatabase: AppDatabase,
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val eventHub: EventHub private val eventHub: EventHub
) : RxAwareViewModel() { ) : ViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>() private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
@ -52,156 +46,130 @@ class AnnouncementsViewModel @Inject constructor(
val emojis: LiveData<List<Emoji>> = emojisMutable val emojis: LiveData<List<Emoji>> = emojisMutable
init { init {
Single.zip( viewModelScope.launch {
mastodonApi.getCustomEmojis(), emojisMutable.postValue(instanceInfoRepo.getEmojis())
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext {
rxSingle {
mastodonApi.getInstance().getOrThrow()
}.map { Either.Right(it) }
}
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().configuration?.statuses?.maxCharacters ?: either.asRight().maxTootChars,
either.asRight().configuration?.polls?.maxOptions ?: either.asRight().pollConfiguration?.maxOptions,
either.asRight().configuration?.polls?.maxCharactersPerOption ?: either.asRight().pollConfiguration?.maxOptionChars,
either.asRight().configuration?.polls?.minExpiration ?: either.asRight().pollConfiguration?.minExpiration,
either.asRight().configuration?.polls?.maxExpiration ?: either.asRight().pollConfiguration?.maxExpiration,
either.asRight().configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
either.asRight().version
)
} }
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}
.subscribe(
{
emojisMutable.postValue(it.emojiList.orEmpty())
},
{
Log.w(TAG, "Failed to get custom emojis.", it)
}
)
.autoDispose()
} }
fun load() { fun load() {
announcementsMutable.postValue(Loading()) viewModelScope.launch {
mastodonApi.listAnnouncements() announcementsMutable.postValue(Loading())
.subscribe( mastodonApi.listAnnouncements()
{ .fold(
announcementsMutable.postValue(Success(it)) {
it.filter { announcement -> !announcement.read } announcementsMutable.postValue(Success(it))
.forEach { announcement -> it.filter { announcement -> !announcement.read }
mastodonApi.dismissAnnouncement(announcement.id) .forEach { announcement ->
.subscribe( mastodonApi.dismissAnnouncement(announcement.id)
{ .fold(
eventHub.dispatch(AnnouncementReadEvent(announcement.id)) {
}, eventHub.dispatch(AnnouncementReadEvent(announcement.id))
{ throwable -> },
Log.d(TAG, "Failed to mark announcement as read.", throwable) { throwable ->
} Log.d(
) TAG,
.autoDispose() "Failed to mark announcement as read.",
} throwable
}, )
{ }
announcementsMutable.postValue(Error(cause = it)) )
} }
) },
.autoDispose() {
announcementsMutable.postValue(Error(cause = it))
}
)
}
} }
fun addReaction(announcementId: String, name: String) { fun addReaction(announcementId: String, name: String) {
mastodonApi.addAnnouncementReaction(announcementId, name) viewModelScope.launch {
.subscribe( mastodonApi.addAnnouncementReaction(announcementId, name)
{ .fold(
announcementsMutable.postValue( {
Success( announcementsMutable.postValue(
announcements.value!!.data!!.map { announcement -> Success(
if (announcement.id == announcementId) { announcements.value!!.data!!.map { announcement ->
announcement.copy( if (announcement.id == announcementId) {
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { announcement.copy(
announcement.reactions.map { reaction -> reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
announcement.reactions.map { reaction ->
if (reaction.name == name) {
reaction.copy(
count = reaction.count + 1,
me = true
)
} else {
reaction
}
}
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
} else {
announcement
}
}
)
)
},
{
Log.w(TAG, "Failed to add reaction to the announcement.", it)
}
)
}
}
fun removeReaction(announcementId: String, name: String) {
viewModelScope.launch {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.fold(
{
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) { if (reaction.name == name) {
reaction.copy( if (reaction.count > 1) {
count = reaction.count + 1, reaction.copy(
me = true count = reaction.count - 1,
) me = false
)
} else {
null
}
} else { } else {
reaction reaction
} }
} }
} else { )
listOf( } else {
*announcement.reactions.toTypedArray(), announcement
emojis.value!!.find { emoji -> emoji.shortcode == name } }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
} else {
announcement
} }
} )
) )
) },
}, {
{ Log.w(TAG, "Failed to remove reaction from the announcement.", it)
Log.w(TAG, "Failed to add reaction to the announcement.", it) }
} )
) }
.autoDispose()
}
fun removeReaction(announcementId: String, name: String) {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe(
{
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) {
if (reaction.count > 1) {
reaction.copy(
count = reaction.count - 1,
me = false
)
} else {
null
}
} else {
reaction
}
}
)
} else {
announcement
}
}
)
)
},
{
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
}
)
.autoDispose()
} }
companion object { companion object {

View File

@ -51,6 +51,7 @@ import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
@ -65,6 +66,7 @@ import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityComposeBinding import com.keylesspalace.tusky.databinding.ActivityComposeBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftAttachment import com.keylesspalace.tusky.db.DraftAttachment
@ -93,6 +95,7 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -123,8 +126,8 @@ class ComposeActivity :
private var photoUploadUri: Uri? = null private var photoUploadUri: Uri? = null
@VisibleForTesting @VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
private val viewModel: ComposeViewModel by viewModels { viewModelFactory } private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
@ -328,7 +331,7 @@ class ComposeActivity :
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
withLifecycleContext { withLifecycleContext {
viewModel.instanceParams.observe { instanceData -> viewModel.instanceInfo.observe { instanceData ->
maximumTootCharacters = instanceData.maxChars maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl charactersReservedPerUrl = instanceData.charactersReservedPerUrl
updateVisibleCharactersLeft() updateVisibleCharactersLeft()
@ -666,7 +669,7 @@ class ComposeActivity :
private fun openPollDialog() { private fun openPollDialog() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceParams.value!! val instanceParams = viewModel.instanceInfo.value!!
showAddPollDialog( showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions, this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration, instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
@ -866,25 +869,15 @@ class ComposeActivity :
} }
private fun pickMedia(uri: Uri) { private fun pickMedia(uri: Uri) {
withLifecycleContext { lifecycleScope.launch {
viewModel.pickMedia(uri).observe { exceptionOrItem -> viewModel.pickMedia(uri).onFailure { throwable ->
exceptionOrItem.asLeftOrNull()?.let { val errorId = when (throwable) {
val errorId = when (it) { is VideoSizeException -> R.string.error_video_upload_size
is VideoSizeException -> { is AudioSizeException -> R.string.error_audio_upload_size
R.string.error_video_upload_size is VideoOrImageException -> R.string.error_media_upload_image_or_video
} else -> R.string.error_media_upload_opening
is AudioSizeException -> {
R.string.error_audio_upload_size
}
is VideoOrImageException -> {
R.string.error_media_upload_image_or_video
}
else -> {
R.string.error_media_upload_opening
}
}
displayTransientError(errorId)
} }
displayTransientError(errorId)
} }
} }
} }

View File

@ -20,14 +20,14 @@ import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
@ -35,9 +35,6 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.VersionUtils
import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.filter import com.keylesspalace.tusky.util.filter
import com.keylesspalace.tusky.util.map import com.keylesspalace.tusky.util.map
@ -45,10 +42,12 @@ import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.toLiveData import com.keylesspalace.tusky.util.toLiveData
import com.keylesspalace.tusky.util.withoutFirstWhich import com.keylesspalace.tusky.util.withoutFirstWhich
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.Dispatchers
import io.reactivex.rxjava3.disposables.Disposable import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle import kotlinx.coroutines.rx3.rxSingle
import kotlinx.coroutines.withContext
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@ -58,8 +57,8 @@ class ComposeViewModel @Inject constructor(
private val mediaUploader: MediaUploader, private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient, private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper, private val draftHelper: DraftHelper,
private val db: AppDatabase private val instanceInfoRepo: InstanceInfoRepository
) : RxAwareViewModel() { ) : ViewModel() {
private var replyingStatusAuthor: String? = null private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null private var replyingStatusContent: String? = null
@ -73,19 +72,8 @@ class ComposeViewModel @Inject constructor(
private var contentWarningStateChanged: Boolean = false private var contentWarningStateChanged: Boolean = false
private var modifiedInitialState: Boolean = false private var modifiedInitialState: Boolean = false
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null) val instanceInfo: MutableLiveData<InstanceInfo> = MutableLiveData()
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
ComposeInstanceParams(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = instance?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = instance?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instance?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData() val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val markMediaAsSensitive = val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
@ -99,75 +87,35 @@ class ComposeViewModel @Inject constructor(
val media = mutableLiveData<List<QueuedMedia>>(listOf()) val media = mutableLiveData<List<QueuedMedia>>(listOf())
val uploadError = MutableLiveData<Throwable>() val uploadError = MutableLiveData<Throwable>()
private val mediaToDisposable = mutableMapOf<Long, Disposable>() private val mediaToJob = mutableMapOf<Long, Job>()
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
init { init {
viewModelScope.launch {
Single.zip( emoji.postValue(instanceInfoRepo.getEmojis())
api.getCustomEmojis(), }
rxSingle { viewModelScope.launch {
api.getInstance().getOrThrow() instanceInfo.postValue(instanceInfoRepo.getInstanceInfo())
}
) { emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version
)
} }
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
.onErrorResumeNext {
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
}
.subscribe(
{ instanceEntity ->
emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity)
},
{ throwable ->
// this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable)
}
)
.autoDispose()
} }
fun pickMedia(uri: Uri, description: String? = null): LiveData<Either<Throwable, QueuedMedia>> { suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
// We are not calling .toLiveData() here because we don't want to stop the process when try {
// the Activity goes away temporarily (like on screen rotation). val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>() val mediaItems = media.value!!
mediaUploader.prepareMedia(uri) if (type != QueuedMedia.Type.IMAGE &&
.map { (type, uri, size) -> mediaItems.isNotEmpty() &&
val mediaItems = media.value!! mediaItems[0].type == QueuedMedia.Type.IMAGE
if (type != QueuedMedia.Type.IMAGE && ) {
mediaItems.isNotEmpty() && Result.failure(VideoOrImageException())
mediaItems[0].type == QueuedMedia.Type.IMAGE } else {
) { val queuedMedia = addMediaToQueue(type, uri, size, description)
throw VideoOrImageException() Result.success(queuedMedia)
} else {
addMediaToQueue(type, uri, size, description)
}
} }
.subscribe( } catch (e: Exception) {
{ queuedMedia -> Result.failure(e)
liveData.postValue(Either.Right(queuedMedia)) }
},
{ error ->
liveData.postValue(Either.Left(error))
}
)
.autoDispose()
return liveData
} }
private fun addMediaToQueue( private fun addMediaToQueue(
@ -183,13 +131,17 @@ class ComposeViewModel @Inject constructor(
mediaSize = mediaSize, mediaSize = mediaSize,
description = description description = description
) )
media.value = media.value!! + mediaItem media.postValue(media.value!! + mediaItem)
mediaToDisposable[mediaItem.localId] = mediaUploader mediaToJob[mediaItem.localId] = viewModelScope.launch {
.uploadMedia(mediaItem) mediaUploader
.subscribe( .uploadMedia(mediaItem)
{ event -> .catch { error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error)
}
.collect { event ->
val item = media.value?.find { it.localId == mediaItem.localId } val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe ?: return@collect
val newMediaItem = when (event) { val newMediaItem = when (event) {
is UploadEvent.ProgressEvent -> is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage) item.copy(uploadPercent = event.percentage)
@ -207,12 +159,8 @@ class ComposeViewModel @Inject constructor(
} }
) )
} }
},
{ error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error)
} }
) }
return mediaItem return mediaItem
} }
@ -222,7 +170,7 @@ class ComposeViewModel @Inject constructor(
} }
fun removeMediaFromQueue(item: QueuedMedia) { fun removeMediaFromQueue(item: QueuedMedia) {
mediaToDisposable[item.localId]?.dispose() mediaToJob[item.localId]?.cancel()
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
} }
@ -337,35 +285,24 @@ class ComposeViewModel @Inject constructor(
return combineLiveData(deletionObservable, sendObservable) { _, _ -> } return combineLiveData(deletionObservable, sendObservable) { _, _ -> }
} }
fun updateDescription(localId: Long, description: String): LiveData<Boolean> { suspend fun updateDescription(localId: Long, description: String): Boolean {
val newList = media.value!!.toMutableList() val newList = media.value!!.toMutableList()
val index = newList.indexOfFirst { it.localId == localId } val index = newList.indexOfFirst { it.localId == localId }
if (index != -1) { if (index != -1) {
newList[index] = newList[index].copy(description = description) newList[index] = newList[index].copy(description = description)
} }
media.value = newList media.value = newList
val completedCaptioningLiveData = MutableLiveData<Boolean>() val updatedItem = newList.find { it.localId == localId }
media.observeForever(object : Observer<List<QueuedMedia>> { if (updatedItem?.id != null) {
override fun onChanged(mediaItems: List<QueuedMedia>) { return api.updateMedia(updatedItem.id, description)
val updatedItem = mediaItems.find { it.localId == localId } .fold({
if (updatedItem == null) { true
media.removeObserver(this) }, { throwable ->
} else if (updatedItem.id != null) { Log.w(TAG, "failed to update media", throwable)
api.updateMedia(updatedItem.id, description) false
.subscribe( })
{ }
completedCaptioningLiveData.postValue(true) return true
},
{
completedCaptioningLiveData.postValue(false)
}
)
.autoDispose()
media.removeObserver(this)
}
}
})
return completedCaptioningLiveData
} }
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> { fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
@ -447,7 +384,11 @@ class ComposeViewModel @Inject constructor(
val draftAttachments = composeOptions?.draftAttachments val draftAttachments = composeOptions?.draftAttachments
if (draftAttachments != null) { if (draftAttachments != null) {
// when coming from DraftActivity // when coming from DraftActivity
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } draftAttachments.forEach { attachment ->
viewModelScope.launch {
pickMedia(attachment.uri, attachment.description)
}
}
} else composeOptions?.mediaAttachments?.forEach { a -> } else composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft or ScheduledTootActivity // when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) { val mediaType = when (a.type) {
@ -498,13 +439,6 @@ class ComposeViewModel @Inject constructor(
scheduledAt.value = newScheduledAt scheduledAt.value = newScheduledAt
} }
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }
@ -512,25 +446,6 @@ class ComposeViewModel @Inject constructor(
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default } 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 = 50
private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800
// Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_MAXIMUM_URL_LENGTH = 23
data class ComposeInstanceParams(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int,
val supportsScheduled: Boolean
)
/** /**
* Thrown when trying to add an image when video is already present or the other way around * Thrown when trying to add an image when video is already present or the other way around
*/ */

View File

@ -1,154 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.AsyncTask;
import com.keylesspalace.tusky.util.IOUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize;
import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation;
import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap;
/**
* Reduces the file size of images to fit under a given limit by resizing them, maintaining both
* aspect ratio and orientation.
*/
public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
private int sizeLimit;
private ContentResolver contentResolver;
private Listener listener;
private File tempFile;
/**
* @param sizeLimit the maximum number of bytes each image can take
* @param contentResolver to resolve the specified images' URIs
* @param tempFile the file where the result will be stored
* @param listener to whom the results are given
*/
public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) {
this.sizeLimit = sizeLimit;
this.contentResolver = contentResolver;
this.tempFile = tempFile;
this.listener = listener;
}
@Override
protected Boolean doInBackground(Uri... uris) {
boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile);
if (isCancelled()) {
return false;
}
return result;
}
@Override
protected void onPostExecute(Boolean successful) {
if (successful) {
listener.onSuccess(tempFile);
} else {
listener.onFailure();
}
super.onPostExecute(successful);
}
public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver,
File tempFile) {
for (Uri uri : uris) {
InputStream inputStream;
try {
inputStream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
return false;
}
// Initially, just get the image dimensions.
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(inputStream, null, options);
IOUtils.closeQuietly(inputStream);
// Get EXIF data, for orientation info.
int orientation = getImageOrientation(uri, contentResolver);
/* Unfortunately, there isn't a determined worst case compression ratio for image
* formats. So, the only way to tell if they're too big is to compress them and
* test, and keep trying at smaller sizes. The initial estimate should be good for
* many cases, so it should only iterate once, but the loop is used to be absolutely
* sure it gets downsized to below the limit. */
int scaledImageSize = 1024;
do {
OutputStream stream;
try {
stream = new FileOutputStream(tempFile);
} catch (FileNotFoundException e) {
return false;
}
try {
inputStream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
return false;
}
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize);
options.inJustDecodeBounds = false;
Bitmap scaledBitmap;
try {
scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options);
} catch (OutOfMemoryError error) {
return false;
} finally {
IOUtils.closeQuietly(inputStream);
}
if (scaledBitmap == null) {
return false;
}
Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation);
if (reorientedBitmap == null) {
scaledBitmap.recycle();
return false;
}
Bitmap.CompressFormat format;
/* It's not likely the user will give transparent images over the upload limit, but
* if they do, make sure the transparency is retained. */
if (!reorientedBitmap.hasAlpha()) {
format = Bitmap.CompressFormat.JPEG;
} else {
format = Bitmap.CompressFormat.PNG;
}
reorientedBitmap.compress(format, 85, stream);
reorientedBitmap.recycle();
scaledImageSize /= 2;
} while (tempFile.length() > sizeLimit);
}
return true;
}
/**
* Used to communicate the results of the task.
*/
public interface Listener {
void onSuccess(File file);
void onFailure();
}
}

View File

@ -0,0 +1,101 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.graphics.BitmapFactory
import android.net.Uri
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.calculateInSampleSize
import com.keylesspalace.tusky.util.getImageOrientation
import com.keylesspalace.tusky.util.reorientBitmap
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
/**
* @param uri the uri pointing to the input file
* @param sizeLimit the maximum number of bytes the output image is allowed to have
* @param contentResolver to resolve the specified input uri
* @param tempFile the file where the result will be stored
* @return true when the image was successfully resized, false otherwise
*/
fun downsizeImage(
uri: Uri,
sizeLimit: Int,
contentResolver: ContentResolver,
tempFile: File
): Boolean {
val decodeBoundsInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
// Initially, just get the image dimensions.
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(decodeBoundsInputStream, null, options)
IOUtils.closeQuietly(decodeBoundsInputStream)
// Get EXIF data, for orientation info.
val orientation = getImageOrientation(uri, contentResolver)
/* Unfortunately, there isn't a determined worst case compression ratio for image
* formats. So, the only way to tell if they're too big is to compress them and
* test, and keep trying at smaller sizes. The initial estimate should be good for
* many cases, so it should only iterate once, but the loop is used to be absolutely
* sure it gets downsized to below the limit. */
var scaledImageSize = 1024
do {
val outputStream = try {
FileOutputStream(tempFile)
} catch (e: FileNotFoundException) {
return false
}
val decodeBitmapInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize)
options.inJustDecodeBounds = false
val scaledBitmap: Bitmap = try {
BitmapFactory.decodeStream(decodeBitmapInputStream, null, options)
} catch (error: OutOfMemoryError) {
return false
} finally {
IOUtils.closeQuietly(decodeBitmapInputStream)
} ?: return false
val reorientedBitmap = reorientBitmap(scaledBitmap, orientation)
if (reorientedBitmap == null) {
scaledBitmap.recycle()
return false
}
/* Retain transparency if there is any by encoding as png */
val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) {
CompressFormat.JPEG
} else {
CompressFormat.PNG
}
reorientedBitmap.compress(format, 85, outputStream)
reorientedBitmap.recycle()
scaledImageSize /= 2
} while (tempFile.length() > sizeLimit)
return true
}

View File

@ -32,9 +32,14 @@ import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.Dispatchers
import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.ExperimentalCoroutinesApi
import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import java.io.File import java.io.File
@ -72,61 +77,40 @@ class MediaUploader @Inject constructor(
private val context: Context, private val context: Context,
private val mastodonApi: MastodonApi private val mastodonApi: MastodonApi
) { ) {
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
return Observable @OptIn(ExperimentalCoroutinesApi::class)
.fromCallable { fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
if (shouldResizeMedia(media)) { return flow {
downsize(media) if (shouldResizeMedia(media)) {
} else media emit(downsize(media))
} else {
emit(media)
} }
.switchMap { upload(it) } }
.subscribeOn(Schedulers.io()) .flatMapLatest { upload(it) }
.flowOn(Dispatchers.IO)
} }
fun prepareMedia(inUri: Uri): Single<PreparedMedia> { fun prepareMedia(inUri: Uri): PreparedMedia {
return Single.fromCallable { var mediaSize = MEDIA_SIZE_UNKNOWN
var mediaSize = MEDIA_SIZE_UNKNOWN var uri = inUri
var uri = inUri val mimeType: String?
var mimeType: String? = null
try { try {
when (inUri.scheme) { when (inUri.scheme) {
ContentResolver.SCHEME_CONTENT -> { ContentResolver.SCHEME_CONTENT -> {
mimeType = contentResolver.getType(uri) mimeType = contentResolver.getType(uri)
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input -> contentResolver.openInputStream(inUri).use { input ->
if (input == null) { if (input == null) {
Log.w(TAG, "Media input is null") Log.w(TAG, "Media input is null")
uri = inUri uri = inUri
return@use return@use
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
} }
} val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
ContentResolver.SCHEME_FILE -> {
val path = uri.path
if (path == null) {
Log.w(TAG, "empty uri path $uri")
throw CouldNotOpenFileException()
}
val inputFile = File(path)
val suffix = inputFile.name.substringAfterLast('.', "tmp")
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out -> FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out) input.copyTo(out)
uri = FileProvider.getUriForFile( uri = FileProvider.getUriForFile(
@ -137,53 +121,74 @@ class MediaUploader @Inject constructor(
mediaSize = getMediaSize(contentResolver, uri) mediaSize = getMediaSize(contentResolver, uri)
} }
} }
else -> { }
Log.w(TAG, "Unknown uri scheme $uri") ContentResolver.SCHEME_FILE -> {
val path = uri.path
if (path == null) {
Log.w(TAG, "empty uri path $uri")
throw CouldNotOpenFileException() throw CouldNotOpenFileException()
} }
} val inputFile = File(path)
} catch (e: IOException) { val suffix = inputFile.name.substringAfterLast('.', "tmp")
Log.w(TAG, e) mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
throw CouldNotOpenFileException() val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
} val input = FileInputStream(inputFile)
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
}
if (mimeType != null) { FileOutputStream(file.absoluteFile).use { out ->
val topLevelType = mimeType.substring(0, mimeType.indexOf('/')) input.copyTo(out)
when (topLevelType) { uri = FileProvider.getUriForFile(
"video" -> { context,
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { BuildConfig.APPLICATION_ID + ".fileprovider",
throw VideoSizeException() file
} )
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) mediaSize = getMediaSize(contentResolver, uri)
}
"image" -> {
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
throw AudioSizeException()
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
}
else -> {
throw MediaTypeException()
} }
} }
} else { else -> {
Log.w(TAG, "Could not determine mime type of upload") Log.w(TAG, "Unknown uri scheme $uri")
throw MediaTypeException() throw CouldNotOpenFileException()
}
} }
} catch (e: IOException) {
Log.w(TAG, e)
throw CouldNotOpenFileException()
}
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
}
if (mimeType != null) {
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
"video" -> {
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
throw VideoSizeException()
}
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
}
"image" -> {
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
throw AudioSizeException()
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
}
else -> {
throw MediaTypeException()
}
}
} else {
Log.w(TAG, "Could not determine mime type of upload")
throw MediaTypeException()
} }
} }
private val contentResolver = context.contentResolver private val contentResolver = context.contentResolver
private fun upload(media: QueuedMedia): Observable<UploadEvent> { private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
return Observable.create { emitter -> return callbackFlow {
var mimeType = contentResolver.getType(media.uri) var mimeType = contentResolver.getType(media.uri)
val map = MimeTypeMap.getSingleton() val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType) val fileExtension = map.getExtensionFromMimeType(mimeType)
@ -200,11 +205,11 @@ class MediaUploader @Inject constructor(
var lastProgress = -1 var lastProgress = -1
val fileBody = ProgressRequestBody( val fileBody = ProgressRequestBody(
stream, media.mediaSize, stream!!, media.mediaSize,
mimeType.toMediaTypeOrNull() mimeType.toMediaTypeOrNull()!!
) { percentage -> ) { percentage ->
if (percentage != lastProgress) { if (percentage != lastProgress) {
emitter.onNext(UploadEvent.ProgressEvent(percentage)) trySend(UploadEvent.ProgressEvent(percentage))
} }
lastProgress = percentage lastProgress = percentage
} }
@ -217,28 +222,15 @@ class MediaUploader @Inject constructor(
null null
} }
val uploadDisposable = mastodonApi.uploadMedia(body, description) val result = mastodonApi.uploadMedia(body, description).getOrThrow()
.subscribe( send(UploadEvent.FinishedEvent(result.id))
{ result -> awaitClose()
emitter.onNext(UploadEvent.FinishedEvent(result.id))
emitter.onComplete()
},
{ e ->
emitter.onError(e)
}
)
// Cancel the request when our observable is cancelled
emitter.setDisposable(uploadDisposable)
} }
} }
private fun downsize(media: QueuedMedia): QueuedMedia { private fun downsize(media: QueuedMedia): QueuedMedia {
val file = createNewImageFile(context) val file = createNewImageFile(context)
DownsizeImageTask.resize( downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
arrayOf(media.uri),
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file
)
return media.copy(uri = file.toUri(), mediaSize = file.length()) return media.copy(uri = file.toUri(), mediaSize = file.length())
} }

View File

@ -27,7 +27,7 @@ import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
@ -35,7 +35,7 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.github.chrisbanes.photoview.PhotoView import com.github.chrisbanes.photoview.PhotoView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.withLifecycleContext import kotlinx.coroutines.launch
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
@ -43,7 +43,7 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog( fun <T> T.makeCaptionDialog(
existingDescription: String?, existingDescription: String?,
previewUri: Uri, previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean> onUpdateDescription: suspend (String) -> Boolean
) where T : Activity, T : LifecycleOwner { ) where T : Activity, T : LifecycleOwner {
val dialogLayout = LinearLayout(this) val dialogLayout = LinearLayout(this)
val padding = Utils.dpToPx(this, 8) val padding = Utils.dpToPx(this, 8)
@ -77,12 +77,11 @@ fun <T> T.makeCaptionDialog(
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
val okListener = { dialog: DialogInterface, _: Int -> val okListener = { dialog: DialogInterface, _: Int ->
onUpdateDescription(input.text.toString()) lifecycleScope.launch {
withLifecycleContext { if (!onUpdateDescription(input.text.toString())) {
onUpdateDescription(input.text.toString()) showFailedCaptionMessage()
.observe { success -> if (!success) showFailedCaptionMessage() } }
} }
dialog.dismiss() dialog.dismiss()
} }

View File

@ -0,0 +1,26 @@
/* Copyright 2022 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.instanceinfo
data class InstanceInfo(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int,
val supportsScheduled: Boolean
)

View File

@ -0,0 +1,104 @@
/* Copyright 2022 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.instanceinfo
import android.util.Log
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.VersionUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class InstanceInfoRepository @Inject constructor(
private val api: MastodonApi,
db: AppDatabase,
accountManager: AccountManager
) {
private val dao = db.instanceDao()
private val instanceName = accountManager.activeAccount!!.domain
/**
* Returns the custom emojis of the instance.
* Will always try to fetch them from the api, falls back to cached Emojis in case it is not available.
* Never throws, returns empty list in case of error.
*/
suspend fun getEmojis(): List<Emoji> = withContext(Dispatchers.IO) {
api.getCustomEmojis()
.onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) }
.getOrElse { throwable ->
Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable)
dao.getEmojiInfo(instanceName)?.emojiList.orEmpty()
}
}
/**
* Returns information about the instance.
* Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available.
* Never throws, returns defaults of vanilla Mastodon in case of error.
*/
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
api.getInstance()
.fold(
{ instance ->
val instanceEntity = InstanceInfoEntity(
instance = instanceName,
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version
)
dao.insertOrReplace(instanceEntity)
instanceEntity
},
{ throwable ->
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
dao.getInstanceInfo(instanceName)
}
).let { instanceInfo: InstanceInfoEntity? ->
InstanceInfo(
maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
supportsScheduled = instanceInfo?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
}
companion object {
private const val TAG = "InstanceInfoRepo"
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 50
private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800
// Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23
}
}

View File

@ -19,13 +19,19 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import io.reactivex.rxjava3.core.Single
@Dao @Dao
interface InstanceDao { interface InstanceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(instance: InstanceEntity) @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
suspend fun insertOrReplace(instance: InstanceInfoEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
suspend fun insertOrReplace(emojis: EmojisEntity)
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
fun loadMetadataForInstance(instance: String): Single<InstanceEntity> suspend fun getInstanceInfo(instance: String): InstanceInfoEntity?
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
suspend fun getEmojiInfo(instance: String): EmojisEntity?
} }

View File

@ -23,7 +23,7 @@ import com.keylesspalace.tusky.entity.Emoji
@Entity @Entity
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
data class InstanceEntity( data class InstanceEntity(
@field:PrimaryKey var instance: String, @PrimaryKey val instance: String,
val emojiList: List<Emoji>?, val emojiList: List<Emoji>?,
val maximumTootCharacters: Int?, val maximumTootCharacters: Int?,
val maxPollOptions: Int?, val maxPollOptions: Int?,
@ -33,3 +33,20 @@ data class InstanceEntity(
val charactersReservedPerUrl: Int?, val charactersReservedPerUrl: Int?,
val version: String? val version: String?
) )
@TypeConverters(Converters::class)
data class EmojisEntity(
@PrimaryKey val instance: String,
val emojiList: List<Emoji>?
)
data class InstanceInfoEntity(
@PrimaryKey val instance: String,
val maximumTootCharacters: Int?,
val maxPollOptions: Int?,
val maxPollOptionLength: Int?,
val minPollDuration: Int?,
val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?,
val version: String?
)

View File

@ -77,7 +77,7 @@ interface MastodonApi {
fun getLists(): Single<List<MastoList>> fun getLists(): Single<List<MastoList>>
@GET("/api/v1/custom_emojis") @GET("/api/v1/custom_emojis")
fun getCustomEmojis(): Single<List<Emoji>> suspend fun getCustomEmojis(): Result<List<Emoji>>
@GET("api/v1/instance") @GET("api/v1/instance")
suspend fun getInstance(): Result<Instance> suspend fun getInstance(): Result<Instance>
@ -145,17 +145,17 @@ interface MastodonApi {
@Multipart @Multipart
@POST("api/v2/media") @POST("api/v2/media")
fun uploadMedia( suspend fun uploadMedia(
@Part file: MultipartBody.Part, @Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null @Part description: MultipartBody.Part? = null
): Single<MediaUploadResult> ): Result<MediaUploadResult>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/media/{mediaId}") @PUT("api/v1/media/{mediaId}")
fun updateMedia( suspend fun updateMedia(
@Path("mediaId") mediaId: String, @Path("mediaId") mediaId: String,
@Field("description") description: String @Field("description") description: String
): Single<Attachment> ): Result<Attachment>
@POST("api/v1/statuses") @POST("api/v1/statuses")
fun createStatus( fun createStatus(
@ -544,26 +544,26 @@ interface MastodonApi {
): Single<Poll> ): Single<Poll>
@GET("api/v1/announcements") @GET("api/v1/announcements")
fun listAnnouncements( suspend fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true @Query("with_dismissed") withDismissed: Boolean = true
): Single<List<Announcement>> ): Result<List<Announcement>>
@POST("api/v1/announcements/{id}/dismiss") @POST("api/v1/announcements/{id}/dismiss")
fun dismissAnnouncement( suspend fun dismissAnnouncement(
@Path("id") announcementId: String @Path("id") announcementId: String
): Single<ResponseBody> ): Result<ResponseBody>
@PUT("api/v1/announcements/{id}/reactions/{name}") @PUT("api/v1/announcements/{id}/reactions/{name}")
fun addAnnouncementReaction( suspend fun addAnnouncementReaction(
@Path("id") announcementId: String, @Path("id") announcementId: String,
@Path("name") name: String @Path("name") name: String
): Single<ResponseBody> ): Result<ResponseBody>
@DELETE("api/v1/announcements/{id}/reactions/{name}") @DELETE("api/v1/announcements/{id}/reactions/{name}")
fun removeAnnouncementReaction( suspend fun removeAnnouncementReaction(
@Path("id") announcementId: String, @Path("id") announcementId: String,
@Path("name") name: String @Path("name") name: String
): Single<ResponseBody> ): Result<ResponseBody>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/reports") @POST("api/v1/reports")

View File

@ -21,20 +21,19 @@ import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceDao import com.keylesspalace.tusky.db.InstanceDao
import com.keylesspalace.tusky.db.InstanceEntity import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.InstanceConfiguration import com.keylesspalace.tusky.entity.InstanceConfiguration
import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.entity.StatusConfiguration
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.core.Single
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -94,7 +93,7 @@ class ComposeActivityTest {
} }
apiMock = mock { apiMock = mock {
on { getCustomEmojis() } doReturn Single.just(emptyList()) onBlocking { getCustomEmojis() } doReturn Result.success(emptyList())
onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance ->
if (instance == null) { if (instance == null) {
Result.failure(Throwable()) Result.failure(Throwable())
@ -105,23 +104,25 @@ class ComposeActivityTest {
} }
val instanceDaoMock: InstanceDao = mock { val instanceDaoMock: InstanceDao = mock {
on { loadMetadataForInstance(any()) } doReturn onBlocking { getInstanceInfo(any()) } doReturn
Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null)
on { loadMetadataForInstance(any()) } doReturn onBlocking { getEmojiInfo(any()) } doReturn
Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) EmojisEntity(instanceDomain, emptyList())
} }
val dbMock: AppDatabase = mock { val dbMock: AppDatabase = mock {
on { instanceDao() } doReturn instanceDaoMock on { instanceDao() } doReturn instanceDaoMock
} }
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock)
val viewModel = ComposeViewModel( val viewModel = ComposeViewModel(
apiMock, apiMock,
accountManagerMock, accountManagerMock,
mock(), mock(),
mock(), mock(),
mock(), mock(),
dbMock instanceInfoRepo
) )
activity.intent = Intent(activity, ComposeActivity::class.java).apply { activity.intent = Intent(activity, ComposeActivity::class.java).apply {
putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions) putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions)
@ -135,6 +136,7 @@ class ComposeActivityTest {
activity.viewModelFactory = viewModelFactoryMock activity.viewModelFactory = viewModelFactoryMock
controller.create().start() controller.create().start()
shadowOf(getMainLooper()).idle()
} }
@Test @Test
@ -185,7 +187,7 @@ class ComposeActivityTest {
fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() { fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() {
instanceResponseCallback = { getInstanceWithCustomConfiguration(null) } instanceResponseCallback = { getInstanceWithCustomConfiguration(null) }
setupActivity() setupActivity()
assertEquals(DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) assertEquals(InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters)
} }
@Test @Test
@ -236,7 +238,7 @@ class ComposeActivityTest {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val additionalContent = "Check out this @image #search result: " val additionalContent = "Check out this @image #search result: "
insertSomeTextInContent(additionalContent + url) insertSomeTextInContent(additionalContent + url)
assertEquals(activity.calculateTextLength(), additionalContent.length + DEFAULT_MAXIMUM_URL_LENGTH) assertEquals(activity.calculateTextLength(), additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL)
} }
@Test @Test
@ -245,7 +247,7 @@ class ComposeActivityTest {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val additionalContent = " Check out this @image #search result: " val additionalContent = " Check out this @image #search result: "
insertSomeTextInContent(shortUrl + additionalContent + url) insertSomeTextInContent(shortUrl + additionalContent + url)
assertEquals(activity.calculateTextLength(), additionalContent.length + (DEFAULT_MAXIMUM_URL_LENGTH * 2)) assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
} }
@Test @Test
@ -253,7 +255,7 @@ class ComposeActivityTest {
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
val additionalContent = " Check out this @image #search result: " val additionalContent = " Check out this @image #search result: "
insertSomeTextInContent(url + additionalContent + url) insertSomeTextInContent(url + additionalContent + url)
assertEquals(activity.calculateTextLength(), additionalContent.length + (DEFAULT_MAXIMUM_URL_LENGTH * 2)) assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2))
} }
@Test @Test