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:
parent
e68ab54f95
commit
0fe84f1611
|
@ -121,6 +121,7 @@ import com.mikepenz.iconics.utils.sizeDp
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
|
@ -292,8 +293,8 @@ class ComposeActivity :
|
|||
binding.composeEditField.setText(statusContent)
|
||||
}
|
||||
|
||||
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
|
||||
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
composeOptions?.scheduledAt?.let {
|
||||
binding.composeScheduleView.setDateTime(it)
|
||||
}
|
||||
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
|
||||
|
@ -314,7 +315,7 @@ class ComposeActivity :
|
|||
viewModel.showContentWarningChanged(this)
|
||||
}
|
||||
|
||||
it.getString(KEY_SCHEDULED_TIME)?.let { time ->
|
||||
(it.getSerializable(KEY_SCHEDULED_TIME) as? Date)?.let { time ->
|
||||
viewModel.updateScheduledAt(time)
|
||||
}
|
||||
}
|
||||
|
@ -717,7 +718,7 @@ class ComposeActivity :
|
|||
outState.putParcelable(KEY_PHOTO_UPLOAD_URI, photoUploadUri)
|
||||
outState.putSerializable(KEY_VISIBILITY, viewModel.statusVisibility.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)
|
||||
}
|
||||
|
||||
|
@ -783,7 +784,7 @@ class ComposeActivity :
|
|||
// Can't reschedule a published status
|
||||
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
|
||||
} else {
|
||||
val attr = if (binding.composeScheduleView.time == null) {
|
||||
val attr = if (viewModel.scheduledAt.value == null) {
|
||||
android.R.attr.colorControlNormal
|
||||
} else {
|
||||
android.R.attr.colorPrimary
|
||||
|
@ -970,7 +971,7 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
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 {
|
||||
|
@ -1410,7 +1411,7 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onTimeSet(time: String?) {
|
||||
override fun onTimeSet(time: Date?) {
|
||||
viewModel.updateScheduledAt(time)
|
||||
if (verifyScheduledTime()) {
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
|
|
@ -58,6 +58,7 @@ import com.github.michaelbull.result.mapBoth
|
|||
import com.github.michaelbull.result.mapError
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
|
@ -138,7 +139,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val showContentWarning = _showContentWarning.asStateFlow()
|
||||
private val _poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
|
||||
val poll = _poll.asStateFlow()
|
||||
private val _scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
private val _scheduledAt: MutableStateFlow<Date?> = MutableStateFlow(null)
|
||||
val scheduledAt = _scheduledAt.asStateFlow()
|
||||
|
||||
private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
|
@ -637,7 +638,7 @@ class ComposeViewModel @Inject constructor(
|
|||
setupComplete = true
|
||||
}
|
||||
|
||||
fun updateScheduledAt(newScheduledAt: String?) {
|
||||
fun updateScheduledAt(newScheduledAt: Date?) {
|
||||
if (newScheduledAt != scheduledAt.value) {
|
||||
scheduledTimeChanged = true
|
||||
}
|
||||
|
|
|
@ -33,11 +33,9 @@ import com.google.android.material.datepicker.DateValidatorPointForward
|
|||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class ComposeScheduleView
|
||||
|
@ -47,7 +45,7 @@ class ComposeScheduleView
|
|||
defStyleAttr: Int = 0,
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
interface OnTimeSetListener {
|
||||
fun onTimeSet(time: String?)
|
||||
fun onTimeSet(time: Date?)
|
||||
}
|
||||
|
||||
private var binding = ViewComposeScheduleBinding.inflate(
|
||||
|
@ -57,12 +55,6 @@ class ComposeScheduleView
|
|||
private var listener: OnTimeSetListener? = null
|
||||
private var dateFormat = SimpleDateFormat.getDateInstance()
|
||||
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 */
|
||||
private var scheduleDateTimeUtc: Calendar? = null
|
||||
|
@ -190,20 +182,9 @@ class ComposeScheduleView
|
|||
picker.show((context as AppCompatActivity).supportFragmentManager, "time_picker")
|
||||
}
|
||||
|
||||
fun getDateTime(scheduledAt: String?): Date? {
|
||||
scheduledAt?.let {
|
||||
try {
|
||||
return iso8601.parse(it)
|
||||
} catch (_: ParseException) {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun setDateTime(scheduledAt: String?) {
|
||||
val date = getDateTime(scheduledAt) ?: return
|
||||
fun setDateTime(scheduledAt: Date) {
|
||||
initializeSuggestedTime()
|
||||
scheduleDateTimeUtc!!.time = date
|
||||
scheduleDateTimeUtc!!.time = scheduledAt
|
||||
updateScheduleUi()
|
||||
}
|
||||
|
||||
|
@ -238,12 +219,9 @@ class ComposeScheduleView
|
|||
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
|
||||
updateScheduleUi()
|
||||
listener?.onTimeSet(time)
|
||||
listener?.onTimeSet(scheduleDateTimeUtc?.time)
|
||||
}
|
||||
|
||||
val time: String?
|
||||
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
|
||||
|
||||
private fun initializeSuggestedTime() {
|
||||
if (scheduleDateTimeUtc == null) {
|
||||
scheduleDateTimeUtc = calendar().apply {
|
||||
|
|
|
@ -63,7 +63,7 @@ class DraftHelper @Inject constructor(
|
|||
poll: NewPoll?,
|
||||
failedToSend: Boolean,
|
||||
failedToSendAlert: Boolean,
|
||||
scheduledAt: String?,
|
||||
scheduledAt: Date?,
|
||||
language: String?,
|
||||
statusId: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
|
|
|
@ -40,6 +40,7 @@ import at.connyduck.calladapter.networkresult.fold
|
|||
import com.github.michaelbull.result.getOrElse
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
@ -247,30 +248,33 @@ class SendStatusService : Service() {
|
|||
)
|
||||
}
|
||||
|
||||
sendResult.fold({ sentStatus ->
|
||||
statusesToSend.remove(statusId)
|
||||
// If the status was loaded from a draft, delete the draft and associated media files.
|
||||
if (statusToSend.draftId != 0) {
|
||||
draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
|
||||
}
|
||||
sendResult.fold(
|
||||
{ sentStatus ->
|
||||
statusesToSend.remove(statusId)
|
||||
// If the status was loaded from a draft, delete the draft and associated media files.
|
||||
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) {
|
||||
eventHub.dispatch(StatusScheduledEvent)
|
||||
} else if (!isNew) {
|
||||
eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus as Status))
|
||||
} else {
|
||||
eventHub.dispatch(StatusComposedEvent(sentStatus as Status))
|
||||
}
|
||||
if (scheduled) {
|
||||
eventHub.dispatch(StatusScheduledEvent)
|
||||
} else if (!isNew) {
|
||||
eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus as Status))
|
||||
} else {
|
||||
eventHub.dispatch(StatusComposedEvent(sentStatus as Status))
|
||||
}
|
||||
|
||||
notificationManager.cancel(statusId)
|
||||
}, { throwable ->
|
||||
Timber.w(throwable, "failed sending status")
|
||||
failOrRetry(throwable, statusId)
|
||||
})
|
||||
notificationManager.cancel(statusId)
|
||||
},
|
||||
{ throwable ->
|
||||
Timber.w(throwable, "failed sending status")
|
||||
failOrRetry(throwable, statusId)
|
||||
},
|
||||
)
|
||||
stopSelfWhenDone()
|
||||
}
|
||||
}
|
||||
|
@ -475,7 +479,7 @@ data class StatusToSend(
|
|||
val visibility: String,
|
||||
val sensitive: Boolean,
|
||||
val media: List<MediaToSend>,
|
||||
val scheduledAt: String?,
|
||||
val scheduledAt: Date?,
|
||||
val inReplyToId: String?,
|
||||
val poll: NewPoll?,
|
||||
val replyingStatusContent: String?,
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -17,11 +17,17 @@
|
|||
|
||||
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.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.RenameColumn
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import app.pachli.core.database.dao.AccountDao
|
||||
import app.pachli.core.database.dao.ConversationsDao
|
||||
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.TimelineStatusEntity
|
||||
import app.pachli.core.database.model.TranslatedStatusEntity
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(
|
||||
|
@ -55,13 +64,15 @@ import app.pachli.core.database.model.TranslatedStatusEntity
|
|||
TranslatedStatusEntity::class,
|
||||
LogEntryEntity::class,
|
||||
],
|
||||
version = 6,
|
||||
version = 8,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class),
|
||||
AutoMigration(from = 2, to = 3),
|
||||
AutoMigration(from = 3, to = 4),
|
||||
AutoMigration(from = 4, to = 5),
|
||||
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() {
|
||||
|
@ -78,4 +89,54 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
@DeleteColumn("TimelineStatusEntity", "contentCollapsed")
|
||||
@DeleteColumn("TimelineStatusEntity", "contentShowing")
|
||||
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
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import app.pachli.core.network.model.NewPoll
|
|||
import app.pachli.core.network.model.Status
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Entity
|
||||
|
@ -45,7 +46,7 @@ data class DraftEntity(
|
|||
val poll: NewPoll?,
|
||||
val failedToSend: Boolean,
|
||||
val failedToSendNew: Boolean,
|
||||
val scheduledAt: String?,
|
||||
val scheduledAt: Date?,
|
||||
val language: String?,
|
||||
val statusId: String?,
|
||||
)
|
||||
|
|
|
@ -38,6 +38,7 @@ import app.pachli.core.network.model.NewPoll
|
|||
import app.pachli.core.network.model.Notification
|
||||
import app.pachli.core.network.model.Status
|
||||
import com.gaelmarhic.quadrant.QuadrantConstants
|
||||
import java.util.Date
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
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 mediaAttachments: List<Attachment>? = null,
|
||||
val draftAttachments: List<DraftAttachment>? = null,
|
||||
val scheduledAt: String? = null,
|
||||
val scheduledAt: Date? = null,
|
||||
val sensitive: Boolean? = null,
|
||||
val poll: NewPoll? = null,
|
||||
val modifiedInitialState: Boolean? = null,
|
||||
|
|
|
@ -19,6 +19,7 @@ package app.pachli.core.network.model
|
|||
import android.os.Parcelable
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
@ -30,7 +31,7 @@ data class NewStatus(
|
|||
val sensitive: Boolean,
|
||||
@Json(name = "media_ids") val mediaIds: List<String>?,
|
||||
@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 language: String?,
|
||||
)
|
||||
|
|
|
@ -18,11 +18,12 @@ package app.pachli.core.network.model
|
|||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ScheduledStatus(
|
||||
val id: String,
|
||||
@Json(name = "scheduled_at") val scheduledAt: String,
|
||||
@Json(name = "scheduled_at") val scheduledAt: Date,
|
||||
val params: StatusParams,
|
||||
@Json(name = "media_attachments") val mediaAttachments: List<Attachment>,
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue