diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 936f20a3d..c1f78b46e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -47,6 +47,7 @@ import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -58,10 +59,8 @@ import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; -import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; -import java.util.Locale; import at.connyduck.sparkbutton.helpers.Utils; @@ -90,6 +89,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private NotificationActionListener notificationActionListener; private AccountActionListener accountActionListener; private AdapterDataSource dataSource; + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); public NotificationsAdapter(String accountId, AdapterDataSource dataSource, @@ -119,7 +119,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_STATUS_NOTIFICATION: { View view = inflater .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions); + return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); } case VIEW_TYPE_FOLLOW: { View view = inflater @@ -383,19 +383,22 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private final Button contentWarningButton; private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder private StatusDisplayOptions statusDisplayOptions; + private final AbsoluteTimeFormatter absoluteTimeFormatter; private String accountId; private String notificationId; private NotificationActionListener notificationActionListener; private StatusViewData.Concrete statusViewData; - private SimpleDateFormat shortSdf; - private SimpleDateFormat longSdf; private int avatarRadius48dp; private int avatarRadius36dp; private int avatarRadius24dp; - StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + StatusNotificationViewHolder( + View itemView, + StatusDisplayOptions statusDisplayOptions, + AbsoluteTimeFormatter absoluteTimeFormatter + ) { super(itemView); message = itemView.findViewById(R.id.notification_top_text); statusNameBar = itemView.findViewById(R.id.status_name_bar); @@ -409,6 +412,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); this.statusDisplayOptions = statusDisplayOptions; + this.absoluteTimeFormatter = absoluteTimeFormatter; int darkerFilter = Color.rgb(123, 123, 123); statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); @@ -417,8 +421,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter { itemView.setOnClickListener(this); message.setOnClickListener(this); statusContent.setOnClickListener(this); - shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); @@ -448,17 +450,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { protected void setCreatedAt(@Nullable Date createdAt) { if (statusDisplayOptions.useAbsoluteTime()) { - String time; - if (createdAt != null) { - if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { - time = longSdf.format(createdAt); - } else { - time = shortSdf.format(createdAt); - } - } else { - time = "??:??:??"; - } - timestampInfo.setText(time); + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); } else { // This is the visible timestampInfo. String readout; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index dbca518ac..c2729aa51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -40,6 +40,7 @@ import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -54,10 +55,8 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.NumberFormat; -import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; -import java.util.Locale; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; @@ -103,10 +102,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private TextView cardUrl; private PollAdapter pollAdapter; - private SimpleDateFormat shortSdf; - private SimpleDateFormat longSdf; - private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); protected int avatarRadius48dp; private int avatarRadius36dp; @@ -170,9 +167,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); - this.shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - this.longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -320,7 +314,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(getAbsoluteTime(createdAt)); + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); } else { if (createdAt == null) { timestampInfo.setText("?m"); @@ -333,21 +327,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private String getAbsoluteTime(Date createdAt) { - if (createdAt == null) { - return "??:??:??"; - } - if (DateUtils.isToday(createdAt.getTime())) { - return shortSdf.format(createdAt); - } else { - return longSdf.format(createdAt); - } - } - private CharSequence getCreatedAtDescription(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (statusDisplayOptions.useAbsoluteTime()) { - return getAbsoluteTime(createdAt); + return absoluteTimeFormatter.format(createdAt, true); } else { /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" * as 17 meters instead of minutes. */ @@ -1028,7 +1011,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { return votesText; } else { if (statusDisplayOptions.useAbsoluteTime()) { - pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt())); + pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false)); } else { pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 9dceddecb..82dbf163d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER @@ -51,6 +52,7 @@ class StatusViewHolder( private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val statusViewHelper = StatusViewHelper(itemView) + private val absoluteTimeFormatter = AbsoluteTimeFormatter() private val previewListener = object : StatusViewHelper.MediaPreviewListener { override fun onViewMedia(v: View?, idx: Int) { @@ -154,7 +156,7 @@ class StatusViewHolder( private fun setCreatedAt(createdAt: Date?) { if (statusDisplayOptions.useAbsoluteTime) { - binding.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt) + binding.timestampInfo.text = absoluteTimeFormatter.format(createdAt) } else { binding.timestampInfo.text = if (createdAt != null) { val then = createdAt.time diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt new file mode 100644 index 000000000..7d46388ba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt @@ -0,0 +1,59 @@ +/* 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.util + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class AbsoluteTimeFormatter @JvmOverloads constructor(private val tz: TimeZone = TimeZone.getDefault()) { + private val sameDaySdf = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val sameYearSdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { this.timeZone = tz } + private val otherYearCompleteSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + + @JvmOverloads + fun format(time: Date?, shortFormat: Boolean = true, now: Date = Date()): String { + return when { + time == null -> "??" + isSameDate(time, now, tz) -> sameDaySdf.format(time) + isSameYear(time, now, tz) -> sameYearSdf.format(time) + shortFormat -> otherYearSdf.format(time) + else -> otherYearCompleteSdf.format(time) + } + } + + companion object { + + private fun isSameDate(dateOne: Date, dateTwo: Date, tz: TimeZone): Boolean { + val calendarOne = Calendar.getInstance(tz).apply { time = dateOne } + val calendarTwo = Calendar.getInstance(tz).apply { time = dateTwo } + + return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) && + calendarOne.get(Calendar.MONTH) == calendarTwo.get(Calendar.MONTH) && + calendarOne.get(Calendar.DAY_OF_MONTH) == calendarTwo.get(Calendar.DAY_OF_MONTH) + } + + private fun isSameYear(dateOne: Date, dateTwo: Date, timeZone1: TimeZone): Boolean { + val calendarOne = Calendar.getInstance(timeZone1).apply { time = dateOne } + val calendarTwo = Calendar.getInstance(timeZone1).apply { time = dateTwo } + + return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 60ac73f47..0752c4e5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -34,20 +34,16 @@ import com.keylesspalace.tusky.viewdata.PollViewData import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.math.min class StatusViewHelper(private val itemView: View) { + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + interface MediaPreviewListener { fun onViewMedia(v: View?, idx: Int) fun onContentHiddenChange(isShowing: Boolean) } - private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) - fun setMediasPreview( statusDisplayOptions: StatusDisplayOptions, attachments: List, @@ -295,7 +291,7 @@ class StatusViewHelper(private val itemView: View) { context.getString(R.string.poll_info_closed) } else { if (useAbsoluteTime) { - context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt)) + context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.expiresAt, false)) } else { TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp) } @@ -330,18 +326,6 @@ class StatusViewHelper(private val itemView: View) { } } - fun getAbsoluteTime(time: Date?): String { - return if (time != null) { - if (android.text.format.DateUtils.isToday(time.time)) { - shortSdf.format(time) - } else { - longSdf.format(time) - } - } else { - "??:??:??" - } - } - companion object { val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) val NO_INPUT_FILTER = arrayOfNulls(0) diff --git a/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt new file mode 100644 index 000000000..57f3bed47 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt @@ -0,0 +1,46 @@ +package com.keylesspalace.tusky.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Instant +import java.util.Date +import java.util.TimeZone + +class AbsoluteTimeFormatterTest { + + private val formatter = AbsoluteTimeFormatter(TimeZone.getTimeZone("UTC")) + private val now = Date.from(Instant.parse("2022-04-11T00:00:00.00Z")) + + @Test + fun `null handling`() { + assertEquals("??", formatter.format(null, true, now)) + assertEquals("??", formatter.format(null, false, now)) + } + + @Test + fun `same day formatting`() { + val tenTen = Date.from(Instant.parse("2022-04-11T10:10:00.00Z")) + assertEquals("10:10", formatter.format(tenTen, true, now)) + assertEquals("10:10", formatter.format(tenTen, false, now)) + } + + @Test + fun `same year formatting`() { + val nextDay = Date.from(Instant.parse("2022-04-12T00:10:00.00Z")) + assertEquals("04-12 00:10", formatter.format(nextDay, true, now)) + assertEquals("04-12 00:10", formatter.format(nextDay, false, now)) + val endOfYear = Date.from(Instant.parse("2022-12-31T23:59:00.00Z")) + assertEquals("12-31 23:59", formatter.format(endOfYear, true, now)) + assertEquals("12-31 23:59", formatter.format(endOfYear, false, now)) + } + + @Test + fun `other year formatting`() { + val firstDayNextYear = Date.from(Instant.parse("2023-01-01T00:00:00.00Z")) + assertEquals("2023-01-01", formatter.format(firstDayNextYear, true, now)) + assertEquals("2023-01-01 00:00", formatter.format(firstDayNextYear, false, now)) + val inTenYears = Date.from(Instant.parse("2032-04-11T10:10:00.00Z")) + assertEquals("2032-04-11", formatter.format(inTenYears, true, now)) + assertEquals("2032-04-11 10:10", formatter.format(inTenYears, false, now)) + } +}