* cleanup warnings, reorganize some code

* move ComposeAutoCompleteAdapter to compose package

* composeOptions doesn't need to be a class member

* add DraftsActivity and DraftsViewModel

* drafts

* remove unnecessary Unit in ComposeViewModel

* add schema/25.json

* fix db migration

* drafts

* cleanup code

* fix compose activity rotation bug

* fix media descriptions getting lost when restoring a draft

* improve deleting drafts

* fix ComposeActivityTest

* improve draft layout for almost empty drafts

* reformat code

* show toast when opening reply to deleted toot

* improve item_draft layout
main
Konrad Pozniak 3 years ago committed by Alibek Omarov
parent 72865a0138
commit e952b6c627
  1. 3
      app/build.gradle
  2. 1
      app/src/main/AndroidManifest.xml
  3. 32
      app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
  4. 4
      app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java
  5. 10
      app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt
  6. 8
      app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt
  7. 83
      app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
  8. 2
      app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java
  9. 192
      app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
  10. 161
      app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt
  11. 81
      app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt
  12. 197
      app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt
  13. 92
      app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt
  14. 69
      app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt
  15. 2
      app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt
  16. 27
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  17. 24
      app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
  18. 40
      app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt
  19. 55
      app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt
  20. 2
      app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt
  21. 11
      app/src/main/java/com/keylesspalace/tusky/db/TootDao.java
  22. 4
      app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
  23. 2
      app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
  24. 6
      app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
  25. 8
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  26. 38
      app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt
  27. 38
      app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt
  28. 8
      app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt
  29. 2
      app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt
  30. 149
      app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java
  31. 8
      app/src/main/res/drawable/ic_alert_circle.xml
  32. 2
      app/src/main/res/drawable/ic_notebook.xml
  33. 4
      app/src/main/res/layout-sw640dp/fragment_view_thread.xml
  34. 2
      app/src/main/res/layout/activity_compose.xml
  35. 34
      app/src/main/res/layout/activity_drafts.xml
  36. 4
      app/src/main/res/layout/fragment_view_thread.xml
  37. 95
      app/src/main/res/layout/item_draft.xml
  38. 27
      app/src/main/res/layout/toolbar_basic.xml
  39. 10
      app/src/main/res/menu/drafts.xml
  40. 1
      app/src/main/res/values/dimens.xml
  41. 3
      app/src/main/res/values/strings.xml
  42. 3
      app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt

@ -82,6 +82,9 @@ android {
androidExtensions {
experimental = true
}
buildFeatures {
viewBinding true
}
testOptions {
unitTests {
returnDefaultValues = true

@ -149,6 +149,7 @@
<activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".components.scheduled.ScheduledTootActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver

@ -31,6 +31,7 @@ import android.widget.ImageView
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback
@ -53,11 +54,13 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.fragment.SFragment
@ -105,6 +108,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject
lateinit var conversationRepository: ConversationsRepository
@Inject
lateinit var appDb: AppDatabase
private lateinit var header: AccountHeaderView
private var notificationTabPosition = 0
@ -253,6 +259,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
StreamingService.stopStreaming(this)
NotificationHelper.disablePullNotifications(this)
}
draftWarning()
}
override fun onResume() {
@ -421,7 +428,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
nameRes = R.string.action_access_saved_toot
iconRes = R.drawable.ic_notebook
onClick = {
val intent = Intent(context, SavedTootActivity::class.java)
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
@ -765,6 +772,29 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.setActiveProfile(accountManager.activeAccount!!.id)
}
private fun draftWarning() {
val sharedPrefsKey = "show_draft_warning"
appDb.tootDao().savedTootCount()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { draftCount ->
val showDraftWarning = preferences.getBoolean(sharedPrefsKey, true)
if (draftCount > 0 && showDraftWarning) {
AlertDialog.Builder(this)
.setMessage(R.string.new_drafts_warning)
.setNegativeButton("Don't show again") { _, _ ->
preferences.edit(commit = true) {
putBoolean(sharedPrefsKey, false)
}
}
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
override fun getActionButton(): FloatingActionButton? = composeButton
override fun androidInjector() = androidInjector

@ -89,7 +89,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
bar.setTitle(getString(R.string.title_saved_toot));
bar.setTitle(getString(R.string.title_drafts));
bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true);
}
@ -166,6 +166,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
ComposeOptions composeOptions = new ComposeOptions(
/*scheduledTootUid*/null,
item.getUid(),
/*drafId*/null,
item.getText(),
jsonUrls,
descriptions,
@ -177,6 +178,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
item.getInReplyToUsername(),
item.getInReplyToText(),
/*mediaAttachments*/null,
/*draftAttachments*/null,
/*scheduledAt*/null,
/*sensitive*/null,
/*poll*/null,

@ -35,11 +35,6 @@ import retrofit2.Response
import java.util.*
import javax.inject.Inject
/**
* Throw when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()
open class CommonComposeViewModel(
private val api: MastodonApi,
private val accountManager: AccountManager,
@ -380,3 +375,8 @@ data class ComposeInstanceMetadata(
val videoLimit: Long,
val imageLimit: Long
)
/**
* Throw when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()

@ -186,7 +186,13 @@ class MediaUploaderImpl(
val body = MultipartBody.Part.createFormData("file", filename, fileBody)
val uploadDisposable = mastodonApi.uploadMedia(body)
val description = if (media.description != null) {
MultipartBody.Part.createFormData("description", media.description)
} else {
null
}
val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe({ attachment ->
emitter.onNext(UploadEvent.FinishedEvent(attachment))
emitter.onComplete()

@ -64,7 +64,6 @@ import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.*
@ -73,6 +72,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.db.AccountEntity
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment
@ -92,7 +92,6 @@ import java.io.File
import java.io.IOException
import java.util.*
import javax.inject.Inject
import kotlin.collections.ArrayList
import kotlin.math.max
import kotlin.math.min
import me.thanel.markdownedit.MarkdownEdit
@ -124,10 +123,10 @@ class ComposeActivity : BaseActivity(),
// this only exists when a status is trying to be sent, but uploads are still occurring
private var finishingUploadDialog: ProgressDialog? = null
private var photoUploadUri: Uri? = null
@VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
private var composeOptions: ComposeOptions? = null
@VisibleForTesting
val viewModel: ComposeViewModel by viewModels { viewModelFactory }
private var suggestFormattingSyntax: String = "text/markdown"
@ -173,14 +172,14 @@ class ComposeActivity : BaseActivity(),
/* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */
if (intent != null) {
this.composeOptions = intent.getParcelableExtra<ComposeOptions?>(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
composeEditField.setText(tootText)
}
val composeOptions = intent.getParcelableExtra<ComposeOptions?>(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
composeEditField.setText(tootText)
}
if(viewModel.formattingSyntax.length == 0) {
@ -189,7 +188,7 @@ class ComposeActivity : BaseActivity(),
suggestFormattingSyntax = viewModel.formattingSyntax
}
if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) {
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
@ -210,39 +209,25 @@ class ComposeActivity : BaseActivity(),
}
}
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
if (intent != null && savedInstanceState == null) {
private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
/* Get incoming images being sent through a share action from another app. Only do this
* when savedInstanceState is null, otherwise both the images from the intent and the
* instance state will be re-queued. */
val type = intent.type
if (type != null) {
intent.type?.also { type ->
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
val uriList = ArrayList<Uri>()
if (intent.action != null) {
when (intent.action) {
Intent.ACTION_SEND -> {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
if (uri != null) {
uriList.add(uri)
}
when (intent.action) {
Intent.ACTION_SEND -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
pickMedia(uri)
}
Intent.ACTION_SEND_MULTIPLE -> {
val list = intent.getParcelableArrayListExtra<Uri>(
Intent.EXTRA_STREAM)
if (list != null) {
for (uri in list) {
if (uri != null) {
uriList.add(uri)
}
}
}
}
Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
pickMedia(uri)
}
}
}
for (uri in uriList) {
pickMedia(uri)
}
} else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) {
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
@ -265,7 +250,7 @@ class ComposeActivity : BaseActivity(),
}
}
private fun setupReplyViews(replyingStatusAuthor: String?) {
private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) {
if (replyingStatusAuthor != null) {
composeReplyView.show()
composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
@ -289,7 +274,7 @@ class ComposeActivity : BaseActivity(),
}
}
}
composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it }
replyingStatusContent?.let { composeReplyContentView.text = it }
}
private fun setupContentWarningField(startingContentWarning: String?) {
@ -899,7 +884,6 @@ class ComposeActivity : BaseActivity(),
}
}
private fun removePoll() {
viewModel.poll.value = null
pollPreview.hide()
@ -1102,22 +1086,22 @@ class ComposeActivity : BaseActivity(),
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) {
if(intent.data != null){
if (intent.data != null) {
// Single media, upload it and done.
pickMedia(intent.data!!)
}else if(intent.clipData != null){
} else if (intent.clipData != null) {
val clipData = intent.clipData!!
val count = clipData.itemCount
if(mediaCount + count > maxUploadMediaNumber){
if (mediaCount + count > maxUploadMediaNumber) {
// check if exist media + upcoming media > 4, then prob error message.
Toast.makeText(this, getString(R.string.error_upload_max_media_reached, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
}else{
} else {
// if not grater then 4, upload all multiple media.
for (i in 0 until count) {
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
}
}
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
pickMedia(photoUploadUri!!)
@ -1329,8 +1313,9 @@ class ComposeActivity : BaseActivity(),
@Parcelize
data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin
var scheduledTootUid: String? = null,
var scheduledTootId: String? = null,
var savedTootUid: Int? = null,
var draftId: Int? = null,
var tootText: String? = null,
var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null,
@ -1342,6 +1327,7 @@ class ComposeActivity : BaseActivity(),
var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
@ -1369,7 +1355,6 @@ class ComposeActivity : BaseActivity(),
}
}
@JvmStatic
fun canHandleMimeType(mimeType: String?): Boolean {
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
}

@ -13,7 +13,7 @@
* 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.adapter;
package com.keylesspalace.tusky.components.compose;
import android.content.Context;
import android.preference.PreferenceManager;

@ -27,6 +27,7 @@ import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.components.common.UploadEvent
import com.keylesspalace.tusky.components.common.mutableLiveData
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
@ -46,12 +47,12 @@ import retrofit2.Response
import java.util.*
import javax.inject.Inject
class ComposeViewModel
@Inject constructor(
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase
) : CommonComposeViewModel(api, accountManager, mediaUploader, db) {
@ -60,7 +61,8 @@ class ComposeViewModel
private var replyingStatusContent: String? = null
internal var startingText: String? = null
private var savedTootUid: Int = 0
private var scheduledTootUid: String? = null
private var draftId: Int = 0
private var scheduledTootId: String? = null
private var startingContentWarning: String = ""
private var inReplyToId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
@ -71,17 +73,17 @@ class ComposeViewModel
val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = !this.markMediaAsSensitive.value!!
}
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
val showContentWarning = mutableLiveData(false)
val setupComplete = mutableLiveData(false)
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
private val isEditingScheduledToot get() = !scheduledTootUid.isNullOrEmpty()
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
}
fun didChange(content: String?, contentWarning: String?): Boolean {
@ -103,30 +105,37 @@ class ComposeViewModel
}
fun deleteDraft() {
saveTootHelper.deleteDraft(this.savedTootUid)
if (savedTootUid != 0) {
saveTootHelper.deleteDraft(savedTootUid)
}
if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId)
.subscribe()
}
}
fun saveDraft(content: String, contentWarning: String) {
val mediaUris = mutableListOf<String>()
val mediaDescriptions = mutableListOf<String?>()
for (item in media.value!!) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
saveTootHelper.saveToot(
content,
contentWarning,
null,
mediaUris,
mediaDescriptions,
savedTootUid,
inReplyToId,
replyingStatusContent,
replyingStatusAuthor,
statusVisibility.value!!,
poll.value,
formattingSyntax
)
draftHelper.saveDraft(
draftId = draftId,
accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value!!,
visibility = statusVisibility.value!!,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
poll = poll.value,
formattingSyntax = formattingSyntax,
failedToSend = false
).subscribe()
}
/**
@ -141,7 +150,7 @@ class ComposeViewModel
): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootUid.toString()).toObservable().map { Unit }
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
} else {
just(Unit)
}.toLiveData()
</