diff --git a/app/build.gradle b/app/build.gradle index 6ed56f42c..02d7907d8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index a934ff9a0..3f559399e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -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() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index 10dc303f6..0934c48fc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -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>>() val announcements: LiveData>> = announcementsMutable @@ -52,156 +46,130 @@ class AnnouncementsViewModel @Inject constructor( val emojis: LiveData> = emojisMutable init { - Single.zip( - mastodonApi.getCustomEmojis(), - appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - .map> { 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 { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index ed590cd15..b7a65a7f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -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) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 08df6dc91..fce3d0bd2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -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 = MutableLiveData(null) + val instanceInfo: MutableLiveData = MutableLiveData() - val instanceParams: LiveData = instance.map { instance -> - ComposeInstanceParams( - maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, - pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, - pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, - 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?> = MutableLiveData() val markMediaAsSensitive = mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) @@ -99,75 +87,35 @@ class ComposeViewModel @Inject constructor( val media = mutableLiveData>(listOf()) val uploadError = MutableLiveData() - private val mediaToDisposable = mutableMapOf() + private val mediaToJob = mutableMapOf() 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> { - // 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>() - 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 = 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 { + 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() - media.observeForever(object : Observer> { - override fun onChanged(mediaItems: List) { - val updatedItem = mediaItems.find { it.localId == localId } - if (updatedItem == null) { - media.removeObserver(this) - } else if (updatedItem.id != null) { - api.updateMedia(updatedItem.id, description) - .subscribe( - { - completedCaptioningLiveData.postValue(true) - }, - { - completedCaptioningLiveData.postValue(false) - } - ) - .autoDispose() - media.removeObserver(this) - } - } - }) - return completedCaptioningLiveData + 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 { @@ -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 mutableLiveData(default: T) = MutableLiveData().apply { value = default } -const val DEFAULT_CHARACTER_LIMIT = 500 -private const val DEFAULT_MAX_OPTION_COUNT = 4 -private const val DEFAULT_MAX_OPTION_LENGTH = 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 */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java deleted file mode 100644 index 880a41679..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java +++ /dev/null @@ -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 . */ - -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 { - 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(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt new file mode 100644 index 000000000..a0215847e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -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 . */ + +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 +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 0e3ac9e84..85be146c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -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 { - return Observable - .fromCallable { - if (shouldResizeMedia(media)) { - downsize(media) - } else media + + @OptIn(ExperimentalCoroutinesApi::class) + fun uploadMedia(media: QueuedMedia): Flow { + 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 { - 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 { - return Observable.create { emitter -> + private suspend fun upload(media: QueuedMedia): Flow { + 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()) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index 0c15eff0d..71789611b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -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.makeCaptionDialog( existingDescription: String?, previewUri: Uri, - onUpdateDescription: (String) -> LiveData + 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.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() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt new file mode 100644 index 000000000..db6ec0e1f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -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 . */ + +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 +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt new file mode 100644 index 000000000..287d54995 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -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 . */ + +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 = 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 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index 52fc3aa86..9b190bc7f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -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 + suspend fun getInstanceInfo(instance: String): InstanceInfoEntity? + + @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") + suspend fun getEmojiInfo(instance: String): EmojisEntity? } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index dd8e85d07..01767f321 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -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?, 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? +) + +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? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 111cad56c..2340c5dd8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -77,7 +77,7 @@ interface MastodonApi { fun getLists(): Single> @GET("/api/v1/custom_emojis") - fun getCustomEmojis(): Single> + suspend fun getCustomEmojis(): Result> @GET("api/v1/instance") suspend fun getInstance(): Result @@ -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 + ): Result @FormUrlEncoded @PUT("api/v1/media/{mediaId}") - fun updateMedia( + suspend fun updateMedia( @Path("mediaId") mediaId: String, @Field("description") description: String - ): Single + ): Result @POST("api/v1/statuses") fun createStatus( @@ -544,26 +544,26 @@ interface MastodonApi { ): Single @GET("api/v1/announcements") - fun listAnnouncements( + suspend fun listAnnouncements( @Query("with_dismissed") withDismissed: Boolean = true - ): Single> + ): Result> @POST("api/v1/announcements/{id}/dismiss") - fun dismissAnnouncement( + suspend fun dismissAnnouncement( @Path("id") announcementId: String - ): Single + ): Result @PUT("api/v1/announcements/{id}/reactions/{name}") - fun addAnnouncementReaction( + suspend fun addAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Single + ): Result @DELETE("api/v1/announcements/{id}/reactions/{name}") - fun removeAnnouncementReaction( + suspend fun removeAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Single + ): Result @FormUrlEncoded @POST("api/v1/reports") diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index 5396a21ec..3a8f2f23e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -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