refactor: Use Date type for scheduled post date / times (#1032)

Previous code accepted the `scheduledAt` value as a String, and kept it
as a String (including when serialising as part of a draft). Then it was
converted to an actual Date for display.

Refactor to keep it as a Date for as long as possible. Moshi decodes
Dates correctly over the network, and the database is configured to
serialise Dates as Longs.

This necessitates two migration steps to preserve any existing
`scheduledAt` values for drafts. The first step adds a new column to
store the date as a Long and copies over existing data. The second step
replaces the old column with the new column.
This commit is contained in:
Nik Clayton 2024-10-20 16:29:32 +02:00 committed by GitHub
parent e68ab54f95
commit 0fe84f1611
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2511 additions and 62 deletions

View File

@ -121,6 +121,7 @@ import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Date
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.max import kotlin.math.max
@ -292,8 +293,8 @@ class ComposeActivity :
binding.composeEditField.setText(statusContent) binding.composeEditField.setText(statusContent)
} }
if (!composeOptions?.scheduledAt.isNullOrEmpty()) { composeOptions?.scheduledAt?.let {
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) binding.composeScheduleView.setDateTime(it)
} }
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
@ -314,7 +315,7 @@ class ComposeActivity :
viewModel.showContentWarningChanged(this) viewModel.showContentWarningChanged(this)
} }
it.getString(KEY_SCHEDULED_TIME)?.let { time -> (it.getSerializable(KEY_SCHEDULED_TIME) as? Date)?.let { time ->
viewModel.updateScheduledAt(time) viewModel.updateScheduledAt(time)
} }
} }
@ -717,7 +718,7 @@ class ComposeActivity :
outState.putParcelable(KEY_PHOTO_UPLOAD_URI, photoUploadUri) outState.putParcelable(KEY_PHOTO_UPLOAD_URI, photoUploadUri)
outState.putSerializable(KEY_VISIBILITY, viewModel.statusVisibility.value) outState.putSerializable(KEY_VISIBILITY, viewModel.statusVisibility.value)
outState.putBoolean(KEY_CONTENT_WARNING_VISIBLE, viewModel.showContentWarning.value) outState.putBoolean(KEY_CONTENT_WARNING_VISIBLE, viewModel.showContentWarning.value)
outState.putString(KEY_SCHEDULED_TIME, viewModel.scheduledAt.value) outState.putSerializable(KEY_SCHEDULED_TIME, viewModel.scheduledAt.value)
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
@ -783,7 +784,7 @@ class ComposeActivity :
// Can't reschedule a published status // Can't reschedule a published status
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false) enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
} else { } else {
val attr = if (binding.composeScheduleView.time == null) { val attr = if (viewModel.scheduledAt.value == null) {
android.R.attr.colorControlNormal android.R.attr.colorControlNormal
} else { } else {
android.R.attr.colorPrimary android.R.attr.colorPrimary
@ -970,7 +971,7 @@ class ComposeActivity :
} }
private fun verifyScheduledTime(): Boolean { private fun verifyScheduledTime(): Boolean {
return binding.composeScheduleView.verifyScheduledTime(binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value)) return binding.composeScheduleView.verifyScheduledTime(viewModel.scheduledAt.value)
} }
private fun onSendClicked() = lifecycleScope.launch { private fun onSendClicked() = lifecycleScope.launch {
@ -1410,7 +1411,7 @@ class ComposeActivity :
} }
} }
override fun onTimeSet(time: String?) { override fun onTimeSet(time: Date?) {
viewModel.updateScheduledAt(time) viewModel.updateScheduledAt(time)
if (verifyScheduledTime()) { if (verifyScheduledTime()) {
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN

View File

@ -58,6 +58,7 @@ import com.github.michaelbull.result.mapBoth
import com.github.michaelbull.result.mapError import com.github.michaelbull.result.mapError
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import io.github.z4kn4fein.semver.constraints.toConstraint import io.github.z4kn4fein.semver.constraints.toConstraint
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@ -138,7 +139,7 @@ class ComposeViewModel @Inject constructor(
val showContentWarning = _showContentWarning.asStateFlow() val showContentWarning = _showContentWarning.asStateFlow()
private val _poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null) private val _poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val poll = _poll.asStateFlow() val poll = _poll.asStateFlow()
private val _scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null) private val _scheduledAt: MutableStateFlow<Date?> = MutableStateFlow(null)
val scheduledAt = _scheduledAt.asStateFlow() val scheduledAt = _scheduledAt.asStateFlow()
private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList()) private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
@ -637,7 +638,7 @@ class ComposeViewModel @Inject constructor(
setupComplete = true setupComplete = true
} }
fun updateScheduledAt(newScheduledAt: String?) { fun updateScheduledAt(newScheduledAt: Date?) {
if (newScheduledAt != scheduledAt.value) { if (newScheduledAt != scheduledAt.value) {
scheduledTimeChanged = true scheduledTimeChanged = true
} }

View File

@ -33,11 +33,9 @@ import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.timepicker.MaterialTimePicker import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat import com.google.android.material.timepicker.TimeFormat
import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
class ComposeScheduleView class ComposeScheduleView
@ -47,7 +45,7 @@ class ComposeScheduleView
defStyleAttr: Int = 0, defStyleAttr: Int = 0,
) : ConstraintLayout(context, attrs, defStyleAttr) { ) : ConstraintLayout(context, attrs, defStyleAttr) {
interface OnTimeSetListener { interface OnTimeSetListener {
fun onTimeSet(time: String?) fun onTimeSet(time: Date?)
} }
private var binding = ViewComposeScheduleBinding.inflate( private var binding = ViewComposeScheduleBinding.inflate(
@ -57,12 +55,6 @@ class ComposeScheduleView
private var listener: OnTimeSetListener? = null private var listener: OnTimeSetListener? = null
private var dateFormat = SimpleDateFormat.getDateInstance() private var dateFormat = SimpleDateFormat.getDateInstance()
private var timeFormat = SimpleDateFormat.getTimeInstance() private var timeFormat = SimpleDateFormat.getTimeInstance()
private var iso8601 = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
Locale.getDefault(),
).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
/** The date/time the user has chosen to schedule the status, in UTC */ /** The date/time the user has chosen to schedule the status, in UTC */
private var scheduleDateTimeUtc: Calendar? = null private var scheduleDateTimeUtc: Calendar? = null
@ -190,20 +182,9 @@ class ComposeScheduleView
picker.show((context as AppCompatActivity).supportFragmentManager, "time_picker") picker.show((context as AppCompatActivity).supportFragmentManager, "time_picker")
} }
fun getDateTime(scheduledAt: String?): Date? { fun setDateTime(scheduledAt: Date) {
scheduledAt?.let {
try {
return iso8601.parse(it)
} catch (_: ParseException) {
}
}
return null
}
fun setDateTime(scheduledAt: String?) {
val date = getDateTime(scheduledAt) ?: return
initializeSuggestedTime() initializeSuggestedTime()
scheduleDateTimeUtc!!.time = date scheduleDateTimeUtc!!.time = scheduledAt
updateScheduleUi() updateScheduleUi()
} }
@ -238,12 +219,9 @@ class ComposeScheduleView
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay) scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute) scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
updateScheduleUi() updateScheduleUi()
listener?.onTimeSet(time) listener?.onTimeSet(scheduleDateTimeUtc?.time)
} }
val time: String?
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
private fun initializeSuggestedTime() { private fun initializeSuggestedTime() {
if (scheduleDateTimeUtc == null) { if (scheduleDateTimeUtc == null) {
scheduleDateTimeUtc = calendar().apply { scheduleDateTimeUtc = calendar().apply {

View File

@ -63,7 +63,7 @@ class DraftHelper @Inject constructor(
poll: NewPoll?, poll: NewPoll?,
failedToSend: Boolean, failedToSend: Boolean,
failedToSendAlert: Boolean, failedToSendAlert: Boolean,
scheduledAt: String?, scheduledAt: Date?,
language: String?, language: String?,
statusId: String?, statusId: String?,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {

View File

@ -40,6 +40,7 @@ import at.connyduck.calladapter.networkresult.fold
import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.getOrElse
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException import java.io.IOException
import java.util.Date
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -247,30 +248,33 @@ class SendStatusService : Service() {
) )
} }
sendResult.fold({ sentStatus -> sendResult.fold(
statusesToSend.remove(statusId) { sentStatus ->
// If the status was loaded from a draft, delete the draft and associated media files. statusesToSend.remove(statusId)
if (statusToSend.draftId != 0) { // If the status was loaded from a draft, delete the draft and associated media files.
draftHelper.deleteDraftAndAttachments(statusToSend.draftId) if (statusToSend.draftId != 0) {
} draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
}
mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray()) mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray())
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() val scheduled = statusToSend.scheduledAt != null
if (scheduled) { if (scheduled) {
eventHub.dispatch(StatusScheduledEvent) eventHub.dispatch(StatusScheduledEvent)
} else if (!isNew) { } else if (!isNew) {
eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus as Status)) eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus as Status))
} else { } else {
eventHub.dispatch(StatusComposedEvent(sentStatus as Status)) eventHub.dispatch(StatusComposedEvent(sentStatus as Status))
} }
notificationManager.cancel(statusId) notificationManager.cancel(statusId)
}, { throwable -> },
Timber.w(throwable, "failed sending status") { throwable ->
failOrRetry(throwable, statusId) Timber.w(throwable, "failed sending status")
}) failOrRetry(throwable, statusId)
},
)
stopSelfWhenDone() stopSelfWhenDone()
} }
} }
@ -475,7 +479,7 @@ data class StatusToSend(
val visibility: String, val visibility: String,
val sensitive: Boolean, val sensitive: Boolean,
val media: List<MediaToSend>, val media: List<MediaToSend>,
val scheduledAt: String?, val scheduledAt: Date?,
val inReplyToId: String?, val inReplyToId: String?,
val poll: NewPoll?, val poll: NewPoll?,
val replyingStatusContent: String?, val replyingStatusContent: String?,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -17,11 +17,17 @@
package app.pachli.core.database package app.pachli.core.database
import android.annotation.SuppressLint
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT
import androidx.core.database.getStringOrNull
import androidx.room.AutoMigration import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.DeleteColumn import androidx.room.DeleteColumn
import androidx.room.RenameColumn
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import app.pachli.core.database.dao.AccountDao import app.pachli.core.database.dao.AccountDao
import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.dao.ConversationsDao
import app.pachli.core.database.dao.DraftDao import app.pachli.core.database.dao.DraftDao
@ -40,6 +46,9 @@ import app.pachli.core.database.model.StatusViewDataEntity
import app.pachli.core.database.model.TimelineAccountEntity import app.pachli.core.database.model.TimelineAccountEntity
import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusEntity
import app.pachli.core.database.model.TranslatedStatusEntity import app.pachli.core.database.model.TranslatedStatusEntity
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@Suppress("ClassName") @Suppress("ClassName")
@Database( @Database(
@ -55,13 +64,15 @@ import app.pachli.core.database.model.TranslatedStatusEntity
TranslatedStatusEntity::class, TranslatedStatusEntity::class,
LogEntryEntity::class, LogEntryEntity::class,
], ],
version = 6, version = 8,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class),
AutoMigration(from = 2, to = 3), AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4), AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5), AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6), AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7, spec = AppDatabase.MIGRATE_6_7::class),
AutoMigration(from = 7, to = 8, spec = AppDatabase.MIGRATE_7_8::class),
], ],
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -78,4 +89,54 @@ abstract class AppDatabase : RoomDatabase() {
@DeleteColumn("TimelineStatusEntity", "contentCollapsed") @DeleteColumn("TimelineStatusEntity", "contentCollapsed")
@DeleteColumn("TimelineStatusEntity", "contentShowing") @DeleteColumn("TimelineStatusEntity", "contentShowing")
class MIGRATE_1_2 : AutoMigrationSpec class MIGRATE_1_2 : AutoMigrationSpec
/**
* Part one of migrating [DraftEntity.scheduledAt] from String to Long.
*
* Copies existing data from `scheduledAt` into `scheduledAtLong`.
*/
class MIGRATE_6_7 : AutoMigrationSpec {
@SuppressLint("ConstantLocale")
private val iso8601 = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
Locale.getDefault(),
).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.beginTransaction()
val draftCursor = db.query("SELECT id, scheduledAt FROM DraftEntity")
with(draftCursor) {
while (moveToNext()) {
val scheduledAt = getStringOrNull(1) ?: continue
// Parse the string representation to a Date. Ignore errors, they
// shouldn't be possible.
val scheduledDate = runCatching { iso8601.parse(scheduledAt) }.getOrNull()
?: continue
// Dates are stored as Long, see Converters.dateToLong.
val values = ContentValues().apply {
put("scheduledAtLong", scheduledDate.time)
}
val draftId = getInt(0)
db.update("DraftEntity", CONFLICT_ABORT, values, "id = ?", arrayOf(draftId))
}
}
db.setTransactionSuccessful()
db.endTransaction()
}
}
/**
* Completes the migration started in [MIGRATE_6_7]. SQLite on Android can't
* drop/rename columns, so use Room's annotations to generate the code to do this.
*/
@DeleteColumn("DraftEntity", "scheduledAt")
@RenameColumn("DraftEntity", "scheduledAtLong", "scheduledAt")
class MIGRATE_7_8 : AutoMigrationSpec
} }

View File

@ -29,6 +29,7 @@ import app.pachli.core.network.model.NewPoll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import java.util.Date
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Entity @Entity
@ -45,7 +46,7 @@ data class DraftEntity(
val poll: NewPoll?, val poll: NewPoll?,
val failedToSend: Boolean, val failedToSend: Boolean,
val failedToSendNew: Boolean, val failedToSendNew: Boolean,
val scheduledAt: String?, val scheduledAt: Date?,
val language: String?, val language: String?,
val statusId: String?, val statusId: String?,
) )

View File

@ -38,6 +38,7 @@ import app.pachli.core.network.model.NewPoll
import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Notification
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import com.gaelmarhic.quadrant.QuadrantConstants import com.gaelmarhic.quadrant.QuadrantConstants
import java.util.Date
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
private const val EXTRA_PACHLI_ACCOUNT_ID = "app.pachli.EXTRA_PACHLI_ACCOUNT_ID" private const val EXTRA_PACHLI_ACCOUNT_ID = "app.pachli.EXTRA_PACHLI_ACCOUNT_ID"
@ -147,7 +148,7 @@ class ComposeActivityIntent(context: Context) : Intent() {
val replyingStatusContent: String? = null, val replyingStatusContent: String? = null,
val mediaAttachments: List<Attachment>? = null, val mediaAttachments: List<Attachment>? = null,
val draftAttachments: List<DraftAttachment>? = null, val draftAttachments: List<DraftAttachment>? = null,
val scheduledAt: String? = null, val scheduledAt: Date? = null,
val sensitive: Boolean? = null, val sensitive: Boolean? = null,
val poll: NewPoll? = null, val poll: NewPoll? = null,
val modifiedInitialState: Boolean? = null, val modifiedInitialState: Boolean? = null,

View File

@ -19,6 +19,7 @@ package app.pachli.core.network.model
import android.os.Parcelable import android.os.Parcelable
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import java.util.Date
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -30,7 +31,7 @@ data class NewStatus(
val sensitive: Boolean, val sensitive: Boolean,
@Json(name = "media_ids") val mediaIds: List<String>?, @Json(name = "media_ids") val mediaIds: List<String>?,
@Json(name = "media_attributes") val mediaAttributes: List<MediaAttribute>?, @Json(name = "media_attributes") val mediaAttributes: List<MediaAttribute>?,
@Json(name = "scheduled_at") val scheduledAt: String?, @Json(name = "scheduled_at") val scheduledAt: Date?,
val poll: NewPoll?, val poll: NewPoll?,
val language: String?, val language: String?,
) )

View File

@ -18,11 +18,12 @@ package app.pachli.core.network.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ScheduledStatus( data class ScheduledStatus(
val id: String, val id: String,
@Json(name = "scheduled_at") val scheduledAt: String, @Json(name = "scheduled_at") val scheduledAt: Date,
val params: StatusParams, val params: StatusParams,
@Json(name = "media_attachments") val mediaAttachments: List<Attachment>, @Json(name = "media_attachments") val mediaAttachments: List<Attachment>,
) )