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
|
@ -124,7 +124,6 @@ dependencies {
|
|||
implementation "androidx.work:work-runtime:2.7.1"
|
||||
implementation "androidx.room:room-ktx:$roomVersion"
|
||||
implementation "androidx.room:room-paging:$roomVersion"
|
||||
implementation "androidx.room:room-rxjava3:$roomVersion"
|
||||
kapt "androidx.room:room-compiler:$roomVersion"
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
|
||||
|
||||
|
|
|
@ -779,18 +779,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun fetchAnnouncements() {
|
||||
mastodonApi.listAnnouncements(false)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe(
|
||||
{ announcements ->
|
||||
unreadAnnouncementsCount = announcements.count { !it.read }
|
||||
updateAnnouncementsBadge()
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to fetch announcements.", it)
|
||||
}
|
||||
)
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.listAnnouncements(false)
|
||||
.fold(
|
||||
{ announcements ->
|
||||
unreadAnnouncementsCount = announcements.count { !it.read }
|
||||
updateAnnouncementsBadge()
|
||||
},
|
||||
{ throwable ->
|
||||
Log.w(TAG, "Failed to fetch announcements.", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAnnouncementsBadge() {
|
||||
|
|
|
@ -18,32 +18,26 @@ package com.keylesspalace.tusky.components.announcements
|
|||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.InstanceEntity
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class AnnouncementsViewModel @Inject constructor(
|
||||
accountManager: AccountManager,
|
||||
private val appDatabase: AppDatabase,
|
||||
private val instanceInfoRepo: InstanceInfoRepository,
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub
|
||||
) : RxAwareViewModel() {
|
||||
) : ViewModel() {
|
||||
|
||||
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
|
||||
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
|
||||
|
@ -52,156 +46,130 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
val emojis: LiveData<List<Emoji>> = emojisMutable
|
||||
|
||||
init {
|
||||
Single.zip(
|
||||
mastodonApi.getCustomEmojis(),
|
||||
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
|
||||
)
|
||||
viewModelScope.launch {
|
||||
emojisMutable.postValue(instanceInfoRepo.getEmojis())
|
||||
}
|
||||
.doOnSuccess {
|
||||
appDatabase.instanceDao().insertOrReplace(it)
|
||||
}
|
||||
.subscribe(
|
||||
{
|
||||
emojisMutable.postValue(it.emojiList.orEmpty())
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to get custom emojis.", it)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
announcementsMutable.postValue(Loading())
|
||||
mastodonApi.listAnnouncements()
|
||||
.subscribe(
|
||||
{
|
||||
announcementsMutable.postValue(Success(it))
|
||||
it.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.subscribe(
|
||||
{
|
||||
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
|
||||
},
|
||||
{ throwable ->
|
||||
Log.d(TAG, "Failed to mark announcement as read.", throwable)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
},
|
||||
{
|
||||
announcementsMutable.postValue(Error(cause = it))
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
viewModelScope.launch {
|
||||
announcementsMutable.postValue(Loading())
|
||||
mastodonApi.listAnnouncements()
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(Success(it))
|
||||
it.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.fold(
|
||||
{
|
||||
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
|
||||
},
|
||||
{ throwable ->
|
||||
Log.d(
|
||||
TAG,
|
||||
"Failed to mark announcement as read.",
|
||||
throwable
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
announcementsMutable.postValue(Error(cause = it))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(announcementId: String, name: String) {
|
||||
mastodonApi.addAnnouncementReaction(announcementId, name)
|
||||
.subscribe(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
|
||||
announcement.reactions.map { reaction ->
|
||||
viewModelScope.launch {
|
||||
mastodonApi.addAnnouncementReaction(announcementId, name)
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
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) {
|
||||
reaction.copy(
|
||||
count = reaction.count + 1,
|
||||
me = true
|
||||
)
|
||||
if (reaction.count > 1) {
|
||||
reaction.copy(
|
||||
count = reaction.count - 1,
|
||||
me = false
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
reaction
|
||||
}
|
||||
}
|
||||
} else {
|
||||
listOf(
|
||||
*announcement.reactions.toTypedArray(),
|
||||
emojis.value!!.find { emoji -> emoji.shortcode == name }
|
||||
!!.run {
|
||||
Announcement.Reaction(
|
||||
name,
|
||||
1,
|
||||
true,
|
||||
url,
|
||||
staticUrl
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
announcement
|
||||
)
|
||||
} else {
|
||||
announcement
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
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()
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -51,6 +51,7 @@ import androidx.core.view.ContentInfoCompat
|
|||
import androidx.core.view.OnReceiveContentListener
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.view.ComposeOptionsListener
|
||||
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.db.AccountEntity
|
||||
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.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
@ -123,8 +126,8 @@ class ComposeActivity :
|
|||
private var photoUploadUri: Uri? = null
|
||||
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
|
||||
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH
|
||||
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
|
||||
var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
|
||||
|
||||
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
|
||||
|
||||
|
@ -328,7 +331,7 @@ class ComposeActivity :
|
|||
|
||||
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
|
||||
withLifecycleContext {
|
||||
viewModel.instanceParams.observe { instanceData ->
|
||||
viewModel.instanceInfo.observe { instanceData ->
|
||||
maximumTootCharacters = instanceData.maxChars
|
||||
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
|
||||
updateVisibleCharactersLeft()
|
||||
|
@ -666,7 +669,7 @@ class ComposeActivity :
|
|||
|
||||
private fun openPollDialog() {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
val instanceParams = viewModel.instanceParams.value!!
|
||||
val instanceParams = viewModel.instanceInfo.value!!
|
||||
showAddPollDialog(
|
||||
this, viewModel.poll.value, instanceParams.pollMaxOptions,
|
||||
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
|
||||
|
@ -866,25 +869,15 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun pickMedia(uri: Uri) {
|
||||
withLifecycleContext {
|
||||
viewModel.pickMedia(uri).observe { exceptionOrItem ->
|
||||
exceptionOrItem.asLeftOrNull()?.let {
|
||||
val errorId = when (it) {
|
||||
is VideoSizeException -> {
|
||||
R.string.error_video_upload_size
|
||||
}
|
||||
is 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)
|
||||
lifecycleScope.launch {
|
||||
viewModel.pickMedia(uri).onFailure { throwable ->
|
||||
val errorId = when (throwable) {
|
||||
is VideoSizeException -> R.string.error_video_upload_size
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,14 +20,14 @@ import android.util.Log
|
|||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
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.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.Emoji
|
||||
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.service.ServiceClient
|
||||
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.filter
|
||||
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.withoutFirstWhich
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -58,8 +57,8 @@ class ComposeViewModel @Inject constructor(
|
|||
private val mediaUploader: MediaUploader,
|
||||
private val serviceClient: ServiceClient,
|
||||
private val draftHelper: DraftHelper,
|
||||
private val db: AppDatabase
|
||||
) : RxAwareViewModel() {
|
||||
private val instanceInfoRepo: InstanceInfoRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
private var replyingStatusContent: String? = null
|
||||
|
@ -73,19 +72,8 @@ class ComposeViewModel @Inject constructor(
|
|||
private var contentWarningStateChanged: 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 markMediaAsSensitive =
|
||||
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
|
@ -99,75 +87,35 @@ class ComposeViewModel @Inject constructor(
|
|||
val media = mutableLiveData<List<QueuedMedia>>(listOf())
|
||||
val uploadError = MutableLiveData<Throwable>()
|
||||
|
||||
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
|
||||
private val mediaToJob = mutableMapOf<Long, Job>()
|
||||
|
||||
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
|
||||
|
||||
init {
|
||||
|
||||
Single.zip(
|
||||
api.getCustomEmojis(),
|
||||
rxSingle {
|
||||
api.getInstance().getOrThrow()
|
||||
}
|
||||
) { 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
|
||||
)
|
||||
viewModelScope.launch {
|
||||
emoji.postValue(instanceInfoRepo.getEmojis())
|
||||
}
|
||||
viewModelScope.launch {
|
||||
instanceInfo.postValue(instanceInfoRepo.getInstanceInfo())
|
||||
}
|
||||
.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>> {
|
||||
// We are not calling .toLiveData() here because we don't want to stop the process when
|
||||
// the Activity goes away temporarily (like on screen rotation).
|
||||
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
|
||||
mediaUploader.prepareMedia(uri)
|
||||
.map { (type, uri, size) ->
|
||||
val mediaItems = media.value!!
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
mediaItems[0].type == QueuedMedia.Type.IMAGE
|
||||
) {
|
||||
throw VideoOrImageException()
|
||||
} else {
|
||||
addMediaToQueue(type, uri, size, description)
|
||||
}
|
||||
suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
|
||||
val mediaItems = media.value!!
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
mediaItems[0].type == QueuedMedia.Type.IMAGE
|
||||
) {
|
||||
Result.failure(VideoOrImageException())
|
||||
} else {
|
||||
val queuedMedia = addMediaToQueue(type, uri, size, description)
|
||||
Result.success(queuedMedia)
|
||||
}
|
||||
.subscribe(
|
||||
{ queuedMedia ->
|
||||
liveData.postValue(Either.Right(queuedMedia))
|
||||
},
|
||||
{ error ->
|
||||
liveData.postValue(Either.Left(error))
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
return liveData
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMediaToQueue(
|
||||
|
@ -183,13 +131,17 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaSize = mediaSize,
|
||||
description = description
|
||||
)
|
||||
media.value = media.value!! + mediaItem
|
||||
mediaToDisposable[mediaItem.localId] = mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.subscribe(
|
||||
{ event ->
|
||||
media.postValue(media.value!! + mediaItem)
|
||||
mediaToJob[mediaItem.localId] = viewModelScope.launch {
|
||||
mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.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 }
|
||||
?: return@subscribe
|
||||
?: return@collect
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -222,7 +170,7 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToDisposable[item.localId]?.dispose()
|
||||
mediaToJob[item.localId]?.cancel()
|
||||
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
|
||||
}
|
||||
|
||||
|
@ -337,35 +285,24 @@ class ComposeViewModel @Inject constructor(
|
|||
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 index = newList.indexOfFirst { it.localId == localId }
|
||||
if (index != -1) {
|
||||
newList[index] = newList[index].copy(description = description)
|
||||
}
|
||||
media.value = newList
|
||||
val completedCaptioningLiveData = MutableLiveData<Boolean>()
|
||||
media.observeForever(object : Observer<List<QueuedMedia>> {
|
||||
override fun onChanged(mediaItems: List<QueuedMedia>) {
|
||||
val updatedItem = mediaItems.find { it.localId == localId }
|
||||
if (updatedItem == null) {
|
||||
media.removeObserver(this)
|
||||
} else if (updatedItem.id != null) {
|
||||
api.updateMedia(updatedItem.id, description)
|
||||
.subscribe(
|
||||
{
|
||||
completedCaptioningLiveData.postValue(true)
|
||||
},
|
||||
{
|
||||
completedCaptioningLiveData.postValue(false)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
media.removeObserver(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
return completedCaptioningLiveData
|
||||
val updatedItem = newList.find { it.localId == localId }
|
||||
if (updatedItem?.id != null) {
|
||||
return api.updateMedia(updatedItem.id, description)
|
||||
.fold({
|
||||
true
|
||||
}, { throwable ->
|
||||
Log.w(TAG, "failed to update media", throwable)
|
||||
false
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
|
@ -447,7 +384,11 @@ class ComposeViewModel @Inject constructor(
|
|||
val draftAttachments = composeOptions?.draftAttachments
|
||||
if (draftAttachments != null) {
|
||||
// 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 ->
|
||||
// when coming from redraft or ScheduledTootActivity
|
||||
val mediaType = when (a.type) {
|
||||
|
@ -498,13 +439,6 @@ class ComposeViewModel @Inject constructor(
|
|||
scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (uploadDisposable in mediaToDisposable.values) {
|
||||
uploadDisposable.dispose()
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
@ -512,25 +446,6 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
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
|
||||
*/
|
||||
|
|
|
@ -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.getMediaSize
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
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.MultipartBody
|
||||
import java.io.File
|
||||
|
@ -72,61 +77,40 @@ class MediaUploader @Inject constructor(
|
|||
private val context: Context,
|
||||
private val mastodonApi: MastodonApi
|
||||
) {
|
||||
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable
|
||||
.fromCallable {
|
||||
if (shouldResizeMedia(media)) {
|
||||
downsize(media)
|
||||
} else media
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
|
||||
return flow {
|
||||
if (shouldResizeMedia(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> {
|
||||
return Single.fromCallable {
|
||||
var mediaSize = MEDIA_SIZE_UNKNOWN
|
||||
var uri = inUri
|
||||
var mimeType: String? = null
|
||||
fun prepareMedia(inUri: Uri): PreparedMedia {
|
||||
var mediaSize = MEDIA_SIZE_UNKNOWN
|
||||
var uri = inUri
|
||||
val mimeType: String?
|
||||
|
||||
try {
|
||||
when (inUri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> {
|
||||
try {
|
||||
when (inUri.scheme) {
|
||||
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 ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
|
@ -137,53 +121,74 @@ class MediaUploader @Inject constructor(
|
|||
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()
|
||||
}
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
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)
|
||||
|
||||
if (mimeType != null) {
|
||||
val topLevelType = mimeType.substring(0, mimeType.indexOf('/'))
|
||||
when (topLevelType) {
|
||||
"video" -> {
|
||||
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
}
|
||||
"audio" -> {
|
||||
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
|
||||
throw AudioSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
throw MediaTypeException()
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Could not determine mime type of upload")
|
||||
throw MediaTypeException()
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown uri scheme $uri")
|
||||
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 fun upload(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable.create { emitter ->
|
||||
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
|
||||
return callbackFlow {
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
|
@ -200,11 +205,11 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(
|
||||
stream, media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()
|
||||
stream!!, media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()!!
|
||||
) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
emitter.onNext(UploadEvent.ProgressEvent(percentage))
|
||||
trySend(UploadEvent.ProgressEvent(percentage))
|
||||
}
|
||||
lastProgress = percentage
|
||||
}
|
||||
|
@ -217,28 +222,15 @@ class MediaUploader @Inject constructor(
|
|||
null
|
||||
}
|
||||
|
||||
val uploadDisposable = mastodonApi.uploadMedia(body, description)
|
||||
.subscribe(
|
||||
{ result ->
|
||||
emitter.onNext(UploadEvent.FinishedEvent(result.id))
|
||||
emitter.onComplete()
|
||||
},
|
||||
{ e ->
|
||||
emitter.onError(e)
|
||||
}
|
||||
)
|
||||
|
||||
// Cancel the request when our observable is cancelled
|
||||
emitter.setDisposable(uploadDisposable)
|
||||
val result = mastodonApi.uploadMedia(body, description).getOrThrow()
|
||||
send(UploadEvent.FinishedEvent(result.id))
|
||||
awaitClose()
|
||||
}
|
||||
}
|
||||
|
||||
private fun downsize(media: QueuedMedia): QueuedMedia {
|
||||
val file = createNewImageFile(context)
|
||||
DownsizeImageTask.resize(
|
||||
arrayOf(media.uri),
|
||||
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file
|
||||
)
|
||||
downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
|
||||
return media.copy(uri = file.toUri(), mediaSize = file.length())
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ import android.widget.LinearLayout
|
|||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
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.github.chrisbanes.photoview.PhotoView
|
||||
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
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
||||
|
@ -43,7 +43,7 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
|||
fun <T> T.makeCaptionDialog(
|
||||
existingDescription: String?,
|
||||
previewUri: Uri,
|
||||
onUpdateDescription: (String) -> LiveData<Boolean>
|
||||
onUpdateDescription: suspend (String) -> Boolean
|
||||
) where T : Activity, T : LifecycleOwner {
|
||||
val dialogLayout = LinearLayout(this)
|
||||
val padding = Utils.dpToPx(this, 8)
|
||||
|
@ -77,12 +77,11 @@ fun <T> T.makeCaptionDialog(
|
|||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
|
||||
val okListener = { dialog: DialogInterface, _: Int ->
|
||||
onUpdateDescription(input.text.toString())
|
||||
withLifecycleContext {
|
||||
onUpdateDescription(input.text.toString())
|
||||
.observe { success -> if (!success) showFailedCaptionMessage() }
|
||||
lifecycleScope.launch {
|
||||
if (!onUpdateDescription(input.text.toString())) {
|
||||
showFailedCaptionMessage()
|
||||
}
|
||||
}
|
||||
|
||||
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.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
|
||||
@Dao
|
||||
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")
|
||||
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
|
||||
@TypeConverters(Converters::class)
|
||||
data class InstanceEntity(
|
||||
@field:PrimaryKey var instance: String,
|
||||
@PrimaryKey val instance: String,
|
||||
val emojiList: List<Emoji>?,
|
||||
val maximumTootCharacters: Int?,
|
||||
val maxPollOptions: Int?,
|
||||
|
@ -33,3 +33,20 @@ data class InstanceEntity(
|
|||
val charactersReservedPerUrl: Int?,
|
||||
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>>
|
||||
|
||||
@GET("/api/v1/custom_emojis")
|
||||
fun getCustomEmojis(): Single<List<Emoji>>
|
||||
suspend fun getCustomEmojis(): Result<List<Emoji>>
|
||||
|
||||
@GET("api/v1/instance")
|
||||
suspend fun getInstance(): Result<Instance>
|
||||
|
@ -145,17 +145,17 @@ interface MastodonApi {
|
|||
|
||||
@Multipart
|
||||
@POST("api/v2/media")
|
||||
fun uploadMedia(
|
||||
suspend fun uploadMedia(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part description: MultipartBody.Part? = null
|
||||
): Single<MediaUploadResult>
|
||||
): Result<MediaUploadResult>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/media/{mediaId}")
|
||||
fun updateMedia(
|
||||
suspend fun updateMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
@Field("description") description: String
|
||||
): Single<Attachment>
|
||||
): Result<Attachment>
|
||||
|
||||
@POST("api/v1/statuses")
|
||||
fun createStatus(
|
||||
|
@ -544,26 +544,26 @@ interface MastodonApi {
|
|||
): Single<Poll>
|
||||
|
||||
@GET("api/v1/announcements")
|
||||
fun listAnnouncements(
|
||||
suspend fun listAnnouncements(
|
||||
@Query("with_dismissed") withDismissed: Boolean = true
|
||||
): Single<List<Announcement>>
|
||||
): Result<List<Announcement>>
|
||||
|
||||
@POST("api/v1/announcements/{id}/dismiss")
|
||||
fun dismissAnnouncement(
|
||||
suspend fun dismissAnnouncement(
|
||||
@Path("id") announcementId: String
|
||||
): Single<ResponseBody>
|
||||
): Result<ResponseBody>
|
||||
|
||||
@PUT("api/v1/announcements/{id}/reactions/{name}")
|
||||
fun addAnnouncementReaction(
|
||||
suspend fun addAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Single<ResponseBody>
|
||||
): Result<ResponseBody>
|
||||
|
||||
@DELETE("api/v1/announcements/{id}/reactions/{name}")
|
||||
fun removeAnnouncementReaction(
|
||||
suspend fun removeAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Single<ResponseBody>
|
||||
): Result<ResponseBody>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/reports")
|
||||
|
|
|
@ -21,20 +21,19 @@ import android.widget.EditText
|
|||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
||||
import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT
|
||||
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.EmojisEntity
|
||||
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.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.entity.InstanceConfiguration
|
||||
import com.keylesspalace.tusky.entity.StatusConfiguration
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
|
@ -94,7 +93,7 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
apiMock = mock {
|
||||
on { getCustomEmojis() } doReturn Single.just(emptyList())
|
||||
onBlocking { getCustomEmojis() } doReturn Result.success(emptyList())
|
||||
onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance ->
|
||||
if (instance == null) {
|
||||
Result.failure(Throwable())
|
||||
|
@ -105,23 +104,25 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
val instanceDaoMock: InstanceDao = mock {
|
||||
on { loadMetadataForInstance(any()) } doReturn
|
||||
Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null))
|
||||
on { loadMetadataForInstance(any()) } doReturn
|
||||
Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null))
|
||||
onBlocking { getInstanceInfo(any()) } doReturn
|
||||
InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null)
|
||||
onBlocking { getEmojiInfo(any()) } doReturn
|
||||
EmojisEntity(instanceDomain, emptyList())
|
||||
}
|
||||
|
||||
val dbMock: AppDatabase = mock {
|
||||
on { instanceDao() } doReturn instanceDaoMock
|
||||
}
|
||||
|
||||
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock)
|
||||
|
||||
val viewModel = ComposeViewModel(
|
||||
apiMock,
|
||||
accountManagerMock,
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
dbMock
|
||||
instanceInfoRepo
|
||||
)
|
||||
activity.intent = Intent(activity, ComposeActivity::class.java).apply {
|
||||
putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions)
|
||||
|
@ -135,6 +136,7 @@ class ComposeActivityTest {
|
|||
activity.viewModelFactory = viewModelFactoryMock
|
||||
|
||||
controller.create().start()
|
||||
shadowOf(getMainLooper()).idle()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -185,7 +187,7 @@ class ComposeActivityTest {
|
|||
fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() {
|
||||
instanceResponseCallback = { getInstanceWithCustomConfiguration(null) }
|
||||
setupActivity()
|
||||
assertEquals(DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters)
|
||||
assertEquals(InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters)
|
||||
}
|
||||
|
||||
@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 additionalContent = "Check out this @image #search result: "
|
||||
insertSomeTextInContent(additionalContent + url)
|
||||
assertEquals(activity.calculateTextLength(), additionalContent.length + DEFAULT_MAXIMUM_URL_LENGTH)
|
||||
assertEquals(activity.calculateTextLength(), additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL)
|
||||
}
|
||||
|
||||
@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 additionalContent = " Check out this @image #search result: "
|
||||
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
|
||||
|
@ -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 additionalContent = " Check out this @image #search result: "
|
||||
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
|
||||
|
|
Loading…
Reference in New Issue