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:
parent
d6e9fd48c0
commit
d2bfceae7b
15 changed files with 596 additions and 628 deletions
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?
|
||||||
|
)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue