From 58e026e781d4f12f7aa16ec3422b4d8c34fa26e8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 30 Dec 2022 16:17:39 -0700 Subject: [PATCH] music: split off dates into separate file Split off Dates and Date Ranges off into their own file. The Music file was getting too big for it's own good, and the addition of Date ranges makes splitting it off much easier. --- .../main/java/org/oxycblt/auxio/music/Date.kt | 256 ++++++++++++++++++ .../java/org/oxycblt/auxio/music/Music.kt | 237 +--------------- .../main/java/org/oxycblt/auxio/music/Sort.kt | 6 +- 3 files changed, 260 insertions(+), 239 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/Date.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/Date.kt new file mode 100644 index 000000000..11a2378bd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/Date.kt @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import android.content.Context +import java.text.ParseException +import java.text.SimpleDateFormat +import kotlin.math.max +import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.inRangeOrNull +import org.oxycblt.auxio.util.nonZeroOrNull + +/** + * An ISO-8601/RFC 3339 Date. + * + * This class only encodes the timestamp spec and it's conversion to a human-readable date, without + * any other time management or validation. In general, this should only be used for display. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class Date private constructor(private val tokens: List) : Comparable { + private val year = tokens[0] + private val month = tokens.getOrNull(1) + private val day = tokens.getOrNull(2) + private val hour = tokens.getOrNull(3) + private val minute = tokens.getOrNull(4) + private val second = tokens.getOrNull(5) + + /** + * Resolve this instance into a human-readable date. + * @param context [Context] required to get human-readable names. + * @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan + * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will + * be properly localized. + */ + fun resolveDate(context: Context): String { + if (month != null) { + // Parse a date format from an ISO-ish format + val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat) + format.applyPattern("yyyy-MM") + val date = + try { + format.parse("$year-$month") + } catch (e: ParseException) { + null + } + + if (date != null) { + // Reformat as a readable month and year + format.applyPattern("MMM yyyy") + return format.format(date) + } + } + + // Unable to create fine-grained date, just format as a year. + return context.getString(R.string.fmt_number, year) + } + + override fun hashCode() = tokens.hashCode() + + override fun equals(other: Any?) = other is Date && tokens == other.tokens + + override fun compareTo(other: Date): Int { + for (i in 0 until max(tokens.size, other.tokens.size)) { + val ai = tokens.getOrNull(i) + val bi = other.tokens.getOrNull(i) + when { + ai != null && bi != null -> { + val result = ai.compareTo(bi) + if (result != 0) { + return result + } + } + ai == null && bi != null -> return -1 // a < b + ai == null && bi == null -> return 0 // a = b + else -> return 1 // a < b + } + } + + return 0 + } + + override fun toString() = StringBuilder().appendDate().toString() + + private fun StringBuilder.appendDate(): StringBuilder { + // Construct an ISO-8601 date, dropping precision that doesn't exist. + append(year.toStringFixed(4)) + append("-${(month ?: return this).toStringFixed(2)}") + append("-${(day ?: return this).toStringFixed(2)}") + append("T${(hour ?: return this).toStringFixed(2)}") + append(":${(minute ?: return this.append('Z')).toStringFixed(2)}") + append(":${(second ?: return this.append('Z')).toStringFixed(2)}") + return this.append('Z') + } + + private fun Int.toStringFixed(len: Int) = toString().padStart(len, '0').substring(0 until len) + + /** + * A range of [Date]s. This is used in contexts where the [Date] of an item is derived from + * several sub-items and thus can have a "range" of release dates. + * @param min The earliest [Date] in the range. + * @param max The latest [Date] in the range. May be the same as [min]. + * @author Alexander Capehart + */ + class Range private constructor(val min: Date, val max: Date) : Comparable { + + /** + * Resolve this instance into a human-readable date range. + * @param context [Context] required to get human-readable names. + * @return If the date has a maximum value, then a `min - max` formatted string will be + * returned with the formatted [Date]s of the minimum and maximum dates respectively. + * Otherwise, the formatted name of the minimum [Date] will be returned. + */ + fun resolveDate(context: Context) = + if (min != max) { + context.getString( + R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context)) + } else { + min.resolveDate(context) + } + + override fun compareTo(other: Range): Int { + return min.compareTo(other.min) + } + + companion object { + /** + * Create a [Range] from the given list of [Date]s. + * @param dates The [Date]s to use. + * @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s, + * null is returned. + */ + fun from(dates: List): Range? { + if (dates.isEmpty()) { + // Nothing to do. + return null + } + // Simultaneously find the minimum and maximum values in the given range. + // If this list has only one item, then that one date is the minimum and maximum. + var min = dates.first() + var max = min + for (i in 1..dates.lastIndex) { + if (dates[i] < min) { + min = dates[i] + } + if (dates[i] > max) { + max = dates[i] + } + } + return Range(min, max) + } + } + } + + companion object { + /** + * A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from + * https://github.com/quodlibet/mutagen + */ + private val ISO8601_REGEX = + Regex( + """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") + + /** + * Create a [Date] from a year component. + * @param year The year component. + * @return A new [Date] of the given component, or null if the component is invalid. + */ + fun from(year: Int) = fromTokens(listOf(year)) + + /** + * Create a [Date] from a date component. + * @param year The year component. + * @param month The month component. + * @param day The day component. + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid. + */ + fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day)) + + /** + * Create [Date] from a datetime component. + * @param year The year component. + * @param month The month component. + * @param day The day component. + * @param hour The hour component + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid. + */ + fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) = + fromTokens(listOf(year, month, day, hour, minute)) + + /** + * Create a [Date] from a [String] timestamp. + * @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision. + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid or + * if the timestamp is invalid. + */ + fun from(timestamp: String): Date? { + val tokens = + // Match the input with the timestamp regex + (ISO8601_REGEX.matchEntire(timestamp) ?: return null) + .groupValues + // Filter to the specific tokens we want and convert them to integer tokens. + .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } + return fromTokens(tokens) + } + + /** + * Create a [Date] from the given non-validated tokens. + * @param tokens The tokens to use for each date component, in order of precision. + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid. + */ + private fun fromTokens(tokens: List): Date? { + val validated = mutableListOf() + validateTokens(tokens, validated) + if (validated.isEmpty()) { + // No token was valid, return null. + return null + } + return Date(validated) + } + + /** + * Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop + * as soon as an invalid token is found. + * @param src The input tokens to validate. + * @param dst The destination list to add valid tokens to. + */ + private fun validateTokens(src: List, dst: MutableList) { + dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return) + dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return) + dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return) + dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return) + dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return) + dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 299e5c60b..528ff3531 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -24,8 +24,6 @@ import android.os.Parcelable import java.security.MessageDigest import java.text.CollationKey import java.text.Collator -import java.text.ParseException -import java.text.SimpleDateFormat import java.util.UUID import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel @@ -37,7 +35,6 @@ import org.oxycblt.auxio.music.extractor.parseMultiValue import org.oxycblt.auxio.music.extractor.toUuidOrNull import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.settings.Settings -import org.oxycblt.auxio.util.inRangeOrNull import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull @@ -610,8 +607,8 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( override val collationKey = makeCollationKeyImpl() override fun resolveName(context: Context) = rawName - /** The [DateRange] that [Song]s in the [Album] were released. */ - val dates: DateRange? = DateRange.from(songs.mapNotNull { it.date }) + /** The [Date.Range] that [Song]s in the [Album] were released. */ + val dates = Date.Range.from(songs.mapNotNull { it.date }) /** * The [Type] of this album, signifying the type of release it actually is. Defaults to @@ -1196,236 +1193,6 @@ class Genre constructor(private val raw: Raw, override val songs: List) : } } -/** - * An ISO-8601/RFC 3339 Date. - * - * This class only encodes the timestamp spec and it's conversion to a human-readable date, without - * any other time management or validation. In general, this should only be used for display. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class Date private constructor(private val tokens: List) : Comparable { - private val year = tokens[0] - private val month = tokens.getOrNull(1) - private val day = tokens.getOrNull(2) - private val hour = tokens.getOrNull(3) - private val minute = tokens.getOrNull(4) - private val second = tokens.getOrNull(5) - - /** - * Resolve this instance into a human-readable date. - * @param context [Context] required to get human-readable names. - * @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan - * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will - * be properly localized. - */ - fun resolveDate(context: Context): String { - if (month != null) { - // Parse a date format from an ISO-ish format - val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat) - format.applyPattern("yyyy-MM") - val date = - try { - format.parse("$year-$month") - } catch (e: ParseException) { - null - } - - if (date != null) { - // Reformat as a readable month and year - format.applyPattern("MMM yyyy") - return format.format(date) - } - } - - // Unable to create fine-grained date, just format as a year. - return context.getString(R.string.fmt_number, year) - } - - override fun hashCode() = tokens.hashCode() - - override fun equals(other: Any?) = other is Date && tokens == other.tokens - - override fun compareTo(other: Date): Int { - for (i in 0 until max(tokens.size, other.tokens.size)) { - val ai = tokens.getOrNull(i) - val bi = other.tokens.getOrNull(i) - when { - ai != null && bi != null -> { - val result = ai.compareTo(bi) - if (result != 0) { - return result - } - } - ai == null && bi != null -> return -1 // a < b - ai == null && bi == null -> return 0 // a = b - else -> return 1 // a < b - } - } - - return 0 - } - - override fun toString() = StringBuilder().appendDate().toString() - - private fun StringBuilder.appendDate(): StringBuilder { - // Construct an ISO-8601 date, dropping precision that doesn't exist. - append(year.toStringFixed(4)) - append("-${(month ?: return this).toStringFixed(2)}") - append("-${(day ?: return this).toStringFixed(2)}") - append("T${(hour ?: return this).toStringFixed(2)}") - append(":${(minute ?: return this.append('Z')).toStringFixed(2)}") - append(":${(second ?: return this.append('Z')).toStringFixed(2)}") - return this.append('Z') - } - - private fun Int.toStringFixed(len: Int) = toString().padStart(len, '0').substring(0 until len) - - companion object { - /** - * A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from - * https://github.com/quodlibet/mutagen - */ - private val ISO8601_REGEX = - Regex( - """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") - - /** - * Create a [Date] from a year component. - * @param year The year component. - * @return A new [Date] of the given component, or null if the component is invalid. - */ - fun from(year: Int) = fromTokens(listOf(year)) - - /** - * Create a [Date] from a date component. - * @param year The year component. - * @param month The month component. - * @param day The day component. - * @return A new [Date] consisting of the given components. May have reduced precision if - * the components were partially invalid, and will be null if all components are invalid. - */ - fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day)) - - /** - * Create [Date] from a datetime component. - * @param year The year component. - * @param month The month component. - * @param day The day component. - * @param hour The hour component - * @return A new [Date] consisting of the given components. May have reduced precision if - * the components were partially invalid, and will be null if all components are invalid. - */ - fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) = - fromTokens(listOf(year, month, day, hour, minute)) - - /** - * Create a [Date] from a [String] timestamp. - * @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision. - * @return A new [Date] consisting of the given components. May have reduced precision if - * the components were partially invalid, and will be null if all components are invalid or - * if the timestamp is invalid. - */ - fun from(timestamp: String): Date? { - val tokens = - // Match the input with the timestamp regex - (ISO8601_REGEX.matchEntire(timestamp) ?: return null) - .groupValues - // Filter to the specific tokens we want and convert them to integer tokens. - .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } - return fromTokens(tokens) - } - - /** - * Create a [Date] from the given non-validated tokens. - * @param tokens The tokens to use for each date component, in order of precision. - * @return A new [Date] consisting of the given components. May have reduced precision if - * the components were partially invalid, and will be null if all components are invalid. - */ - private fun fromTokens(tokens: List): Date? { - val validated = mutableListOf() - validateTokens(tokens, validated) - if (validated.isEmpty()) { - // No token was valid, return null. - return null - } - return Date(validated) - } - - /** - * Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop - * as soon as an invalid token is found. - * @param src The input tokens to validate. - * @param dst The destination list to add valid tokens to. - */ - private fun validateTokens(src: List, dst: MutableList) { - dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return) - dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return) - dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return) - dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return) - dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return) - dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return) - } - } -} - -/** - * A range of [Date]s. This is used in contexts where the [Date] of an item is derived from several - * sub-items and thus can have a "range" of release dates. - * @param min The earliest [Date] in the range. - * @param max The latest [Date] in the range. May be the same as [min]. - * @author Alexander Capehart - */ -class DateRange private constructor(val min: Date, val max: Date) : Comparable { - - /** - * Resolve this instance into a human-readable date range. - * @param context [Context] required to get human-readable names. - * @return If the date has a maximum value, then a `min - max` formatted string will be returned - * with the formatted [Date]s of the minimum and maximum dates respectively. Otherwise, the - * formatted name of the minimum [Date] will be returned. - */ - fun resolveDate(context: Context) = - if (min != max) { - context.getString( - R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context)) - } else { - min.resolveDate(context) - } - - override fun compareTo(other: DateRange): Int { - return min.compareTo(other.min) - } - - companion object { - /** - * Create a [DateRange] from the given list of [Date]s. - * @param dates The [Date]s to use. - * @return A [DateRange] based on the minimum and maximum [Date]s from [dates], or a - * [DateRange] with a single minimum. If no [Date]s were given, null is returned. - */ - fun from(dates: List): DateRange? { - if (dates.isEmpty()) { - // Nothing to do. - return null - } - // Simultaneously find the minimum and maximum values in the given range. - // If this list has only one item, then that one date is the minimum and maximum. - var min = dates.first() - var max = min - for (i in 1..dates.lastIndex) { - if (dates[i] < min) { - min = dates[i] - } - if (dates[i] > max) { - max = dates[i] - } - } - return DateRange(min, max) - } - } -} - // --- MUSIC UID CREATION UTILITIES --- /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt index 4f8b9c2d7..5c8fa818f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt @@ -543,10 +543,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { val INT = NullableComparator() /** A re-usable instance configured for [Long]s. */ val LONG = NullableComparator() - /** A re-usable instance configured for [Date]s. */ - val DATE = NullableComparator() - /** A re-usable instance configured for [DateRange]s. */ - val DATE_RANGE = NullableComparator() + /** A re-usable instance configured for [Date.Range]s. */ + val DATE_RANGE = NullableComparator() } }