feat: Enforce same upload date formatting for torrents using better date parser

Signed-off-by: prajwalch <prajwal.chapagain58@gmail.com>
This commit is contained in:
prajwalch
2026-05-05 13:04:06 +05:45
parent f2dbb9d37d
commit ec3ef8881f
36 changed files with 274 additions and 297 deletions
@@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "a6af5f7e17fb26408d670e52e0d69958",
"identityHash": "0128221c3b632dc50d6f1acb304aca4b",
"entities": [
{
"tableName": "bookmarks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `size` TEXT NOT NULL, `seeders` INTEGER NOT NULL, `peers` INTEGER NOT NULL, `providerName` TEXT NOT NULL, `uploadDate` TEXT NOT NULL, `category` TEXT NOT NULL, `descriptionPageUrl` TEXT NOT NULL, `magnetUri` TEXT DEFAULT NULL, `fileDownloadLink` TEXT DEFAULT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `size` TEXT NOT NULL, `seeders` INTEGER NOT NULL, `peers` INTEGER NOT NULL, `providerName` TEXT NOT NULL, `uploadDate` INTEGER DEFAULT NULL, `category` TEXT NOT NULL, `descriptionPageUrl` TEXT NOT NULL, `magnetUri` TEXT DEFAULT NULL, `fileDownloadLink` TEXT DEFAULT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "infoHash",
@@ -47,8 +47,8 @@
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": true
"affinity": "INTEGER",
"defaultValue": "NULL"
},
{
"fieldPath": "category",
@@ -208,7 +208,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a6af5f7e17fb26408d670e52e0d69958')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0128221c3b632dc50d6f1acb304aca4b')"
]
}
}
@@ -22,8 +22,12 @@ import com.prajwalch.torrentsearch.data.local.entities.BookmarkedTorrent
import com.prajwalch.torrentsearch.data.local.entities.SearchHistoryEntity
import com.prajwalch.torrentsearch.data.local.entities.TorznabConfigEntity
import com.prajwalch.torrentsearch.data.local.entities.ViewedTorrentEntity
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import java.time.Instant
import java.util.Locale
/** Application database. */
@Database(
entities = [
@@ -95,6 +99,7 @@ abstract class TorrentSearchDatabase : RoomDatabase() {
/**
* Migration from version 4 to 5:
* - Changes `bookmarks.id` from `Long` to `String` (info hash).
* - Changes `bookmarks.uploadDate` from `String` to `Long` representing [java.time.Instant].
* - Creates a new viewed_torrents table.
*/
private val MIGRATION_4_5 = object : Migration(4, 5) {
@@ -113,7 +118,7 @@ private val MIGRATION_4_5 = object : Migration(4, 5) {
`seeders` INTEGER NOT NULL,
`peers` INTEGER NOT NULL,
`providerName` TEXT NOT NULL,
`uploadDate` TEXT NOT NULL,
`uploadDate` INTEGER DEFAULT NULL,
`category` TEXT NOT NULL,
`descriptionPageUrl` TEXT NOT NULL,
`magnetUri` TEXT DEFAULT NULL,
@@ -139,6 +144,10 @@ private val MIGRATION_4_5 = object : Migration(4, 5) {
val peers = cursor.getInt(cursor.getColumnIndexOrThrow("peers"))
val providerName = cursor.getString(cursor.getColumnIndexOrThrow("providerName"))
val uploadDate = cursor.getString(cursor.getColumnIndexOrThrow("uploadDate"))
val newUploadDate = parseOldTorrentUploadDate(
date = uploadDate,
providerName = providerName,
)?.toEpochMilli()
val category = cursor.getString(cursor.getColumnIndexOrThrow("category"))
val descriptionPageUrl = cursor
.getString(cursor.getColumnIndexOrThrow("descriptionPageUrl"))
@@ -157,7 +166,7 @@ private val MIGRATION_4_5 = object : Migration(4, 5) {
put("seeders", seeders)
put("peers", peers)
put("providerName", providerName)
put("uploadDate", uploadDate)
put("uploadDate", newUploadDate)
put("category", category)
put("descriptionPageUrl", descriptionPageUrl)
put("magnetUri", magnetUri)
@@ -183,4 +192,45 @@ private val MIGRATION_4_5 = object : Migration(4, 5) {
""".trimIndent()
)
}
private fun parseOldTorrentUploadDate(
date: String,
providerName: String,
): Instant? = when (providerName) {
// Eztv uses weird formatting on top of that it's now Cloudflare protected.
// AniRena and FileMood date parsing was involved.
"AniRena", "Eztv", "FileMood" -> null
"AnimeTosho",
"BitSearch",
"Dmhy",
"InternetArchive",
"Knaben",
"Nyaa",
"SubsPlease",
"Sukebei",
"ThePirateBay",
"TheRarBg",
"TokyoToshokan",
"TorrentDatabase",
"TorrentDownloads",
"TorrentsCSV",
"XXXClub",
"Yts",
-> TorrentDateParser.parse(date = date, format = "dd MMM yyyy")
"LimeTorrents",
"MyPornClub",
"TorrentDownload",
"UIndex",
-> TorrentDateParser.tryParseRelative(date)
"XXXTracker" -> TorrentDateParser.parse(
date = date,
format = "dd MMM yy",
locale = Locale.forLanguageTag("ru_RU"),
)
else -> null
}
}
@@ -9,6 +9,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import kotlinx.serialization.Serializable
import java.time.Instant
@Entity(
tableName = "bookmarks",
@@ -24,7 +25,8 @@ data class BookmarkedTorrent(
val seeders: Int,
val peers: Int,
val providerName: String,
val uploadDate: String,
@ColumnInfo(defaultValue = "NULL")
val uploadDate: Long? = null,
val category: String,
val descriptionPageUrl: String,
@ColumnInfo(defaultValue = "NULL")
@@ -41,7 +43,7 @@ fun BookmarkedTorrent.toDomain() =
seeders = this.seeders.toUInt(),
peers = this.peers.toUInt(),
providerName = this.providerName,
uploadDate = this.uploadDate,
uploadDate = this.uploadDate?.let(Instant::ofEpochMilli),
category = if (this.category.isNotEmpty()) {
Category.valueOf(this.category)
} else {
@@ -60,7 +62,7 @@ fun Torrent.toEntity() =
seeders = this.seeders.toInt(),
peers = this.peers.toInt(),
providerName = this.providerName,
uploadDate = this.uploadDate,
uploadDate = this.uploadDate?.toEpochMilli(),
category = this.category?.name ?: "",
descriptionPageUrl = this.descriptionPageUrl,
magnetUri = this.magnetUri,
@@ -1,6 +1,7 @@
package com.prajwalch.torrentsearch.domain.model
import com.prajwalch.torrentsearch.util.TorrentUtils
import java.time.Instant
/** Represents a magnet URI. */
typealias MagnetUri = String
@@ -19,8 +20,8 @@ data class Torrent(
val peers: UInt,
/** Name of the search provider from where torrent is searched. */
val providerName: String,
/** Torrent upload date (in pretty format). */
val uploadDate: String,
/** Torrent upload date. */
val uploadDate: Instant? = null,
/** Category of the torrent. */
val category: Category? = null,
/** URL of the page where the torrent details is available. */
@@ -91,7 +91,6 @@ class AniRena : SearchProvider {
// Getting upload date requires an additional request to
// 'anirena.com/torrent_details.php?id={id}'. The ID can be found in
// the 'id' attribute of the element next to given div as 'details{id}'.
uploadDate = "0m ago",
category = specializedCategory,
providerName = name,
descriptionPageUrl = "",
@@ -4,7 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -14,6 +14,8 @@ import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.TextNode
import java.time.Instant
class AnimeTosho : SearchProvider, TorrentDetailsProvider {
override val id = "animetosho"
override val name = "AnimeTosho"
@@ -80,7 +82,7 @@ private class AnimeToshoResultsPageParser(
}
/** Parses the upload date and converts "Today"/"Yesterday" into real dates. */
private fun parseUploadDate(entryDiv: Element): String? {
private fun parseUploadDate(entryDiv: Element): Instant? {
val raw = entryDiv
.selectFirst("div.date")
?.attr("title")
@@ -89,14 +91,13 @@ private class AnimeToshoResultsPageParser(
?: return null
return when {
raw.startsWith("Today") -> DateUtils.formatTodayDate()
raw.startsWith("Yesterday") -> DateUtils.formatYesterdayDate()
raw.startsWith("Today") -> TorrentDateParser.getTodayDate()
raw.startsWith("Yesterday") -> TorrentDateParser.getYesterdayDate()
else -> {
raw
.split(' ', limit = 2)
.firstOrNull()
?.let { DateUtils.formatDayMonthYear(it) }
?: raw
?.let(TorrentDateParser::parseDayMonthYear)
}
}
}
@@ -4,7 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -104,7 +104,7 @@ private class BitSearchResultsPageParser(
val seeders = listItem.selectFirst(SEEDERS)?.ownText()
val peers = listItem.selectFirst(PEERS)?.ownText()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.ownText()
?.let(DateUtils::formatMonthDayYear)
?.let(TorrentDateParser::parseMonthDayYear)
val category = listItem.selectFirst(CATEGORY)?.ownText()?.let(::categoryFromRawString)
val fileDownloadLink = listItem.selectFirst(FILE_DOWNLOAD_LINK)?.attr("abs:href")
val detailsPageUrl = listItem.selectFirst(DETAILS_PAGE_URL)?.attr("abs:href")
@@ -116,7 +116,7 @@ private class BitSearchResultsPageParser(
seeders = seeders?.toUIntOrNull() ?: 0U,
peers = peers?.toUIntOrNull() ?: 0U,
providerName = providerName,
uploadDate = uploadDate ?: "0 min. ago",
uploadDate = uploadDate,
category = category,
magnetUri = magnetUri,
fileDownloadLink = fileDownloadLink,
@@ -134,7 +134,7 @@ private class BitSearchResultsPageParser(
private const val SIZE = "$CATEGORY_AND_METADATA > span:nth-child(2) > span"
private const val SEEDERS = "$SWARM_STATS > span:nth-child(1) > span:nth-child(2)"
private const val PEERS = "$SWARM_STATS > span:nth-child(2) > span:nth-child(2)"
private const val UPLOAD_DATE = "$CATEGORY_AND_METADATA >span:nth-child(3) > span"
private const val UPLOAD_DATE = "$CATEGORY_AND_METADATA > span:nth-child(3) > span"
private const val CATEGORY = "$CATEGORY_AND_METADATA > span:nth-child(1) > span"
private const val MAGNET_LINK = "$DOWNLOAD_LINKS > a:nth-child(2)"
private const val FILE_DOWNLOAD_LINK = "$DOWNLOAD_LINKS > a:nth-child(1)"
@@ -2,8 +2,8 @@ package com.prajwalch.torrentsearch.providers
import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -53,10 +53,7 @@ private class DmhyResultsPageParser(
val seeders = listItem.selectFirst(SEEDERS)?.text()?.toUIntOrNull()
val peers = listItem.selectFirst(PEERS)?.text()?.toUIntOrNull()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.ownText()
?.split(' ')
?.firstOrNull()
?.replace("/", "-")
?.let(DateUtils::formatYearMonthDay)
?.let { TorrentDateParser.parse(date = it, format = "yyyy/MM/dd HH:mm") }
val category = listItem.selectFirst(CATEGORY)?.className()
?.removePrefix("sort-")
?.let(::getCategoryFromId)
@@ -68,7 +65,7 @@ private class DmhyResultsPageParser(
size = size ?: "0 KB",
seeders = seeders ?: 0U,
peers = peers ?: 0U,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = category,
providerName = providerName,
magnetUri = magnetUri,
@@ -82,7 +79,7 @@ private class DmhyResultsPageParser(
private const val SIZE = "td:nth-child(5)"
private const val SEEDERS = "td:nth-child(6)"
private const val PEERS = "td:nth-child(7)"
private const val UPLOAD_DATE = "td:nth-child(1)"
private const val UPLOAD_DATE = "td:nth-child(1) > span"
private const val CATEGORY = "td:nth-child(2) > a"
private const val MAGNET_URI = "td:nth-child(4) > a:nth-child(1)"
private const val DETAILS_PAGE_URL = TORRENT_NAME
@@ -96,7 +96,7 @@ class Eztv : SearchProvider {
// TODO: The date format used the results page is 'time ago'
// (e.g. '7h 8m', '1 week', '1 mo'). The format we want
// is present in the details page. Let's extract it in future.
val uploadDate = tr.selectFirst("td:nth-child(5)")?.ownText() ?: return null
// val uploadDate = tr.selectFirst("td:nth-child(5)")?.ownText() ?: return null
// Some torrents will not have any seeds (no idea why), in that case
// it will contain '-' text node, and in other case it will contain a
@@ -116,7 +116,7 @@ class Eztv : SearchProvider {
seeders = seeders.toUIntOrNull() ?: 0u,
peers = peers,
providerName = name,
uploadDate = uploadDate,
// uploadDate = uploadDate,
category = specializedCategory,
descriptionPageUrl = descriptionPageUrl,
magnetUri = magnetUri,
@@ -67,7 +67,6 @@ private class FileMoodResultsPageParser(private val providerName: String) {
size = size ?: "0 KB",
seeders = seeders?.toUIntOrNull() ?: 0U,
peers = peers?.toUIntOrNull() ?: 0U,
uploadDate = "0 min ago",
category = Category.Other,
providerName = providerName,
descriptionPageUrl = descriptionPageUrl,
@@ -11,6 +11,7 @@ import com.prajwalch.torrentsearch.extension.getString
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -96,7 +97,7 @@ private class IAResultsJsonParser(
val size = obj.getLong("item_size")
?.let { FileSizeUtils.formatBytes(it.toFloat()) }
?: return null
val uploadDate = obj.getString("publicdate")?.let(DateUtils::formatIsoDate) ?: return null
val uploadDate = obj.getString("publicdate")?.let(TorrentDateParser::parseIso)
val category = obj.getString("mediatype")?.let(::categoryFromMediaType) ?: return null
val descriptionPageUrl = obj.getString("identifier")
?.let { "$providerUrl/details/$it" }
@@ -7,8 +7,8 @@ import com.prajwalch.torrentsearch.extension.getArray
import com.prajwalch.torrentsearch.extension.getLong
import com.prajwalch.torrentsearch.extension.getString
import com.prajwalch.torrentsearch.extension.getUInt
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -89,7 +89,7 @@ class Knaben : SearchProvider {
val seeders = obj.getUInt("seeders") ?: 0u
val peers = obj.getUInt("peers") ?: 0u
val uploadDate = obj.getString("date")?.let { DateUtils.formatIsoDate(it) } ?: ""
val uploadDate = obj.getString("date")?.let(TorrentDateParser::parseIso)
val descriptionPageUrl = obj.getString("details").orEmpty()
val category = extractCategory(obj)
@@ -5,6 +5,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.TorrentDateParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -81,7 +82,7 @@ private class LimeTorrentsResultsPageParser(
val descriptionPageUrl = nameAnchor.attr("abs:href")
val infoHash = extractInfoHash(row) ?: return null
val uploadDate = extractUploadDate(row)
val uploadDate = extractUploadDate(row).let(TorrentDateParser::tryParseRelative)
val size = row.selectFirst("td:nth-child(3)")?.text() ?: return null
val seeders = row.selectFirst(".tdseed")?.text()?.toUIntOrNull() ?: 0u
val peers = row.selectFirst(".tdleech")?.text()?.toUIntOrNull() ?: 0u
@@ -4,6 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -72,6 +73,7 @@ private class MyPornClubResultsPageParser(
val seeders = listItem.selectFirst(SEEDERS)?.ownText()?.toUIntOrNull()
val peers = listItem.selectFirst(PEERS)?.ownText()?.toUIntOrNull()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.text()
?.let(TorrentDateParser::tryParseRelative)
return Torrent(
infoHash = torrentDetails.infoHash,
@@ -80,7 +82,7 @@ private class MyPornClubResultsPageParser(
seeders = seeders ?: 0U,
peers = peers ?: 0U,
providerName = providerName,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = providerSpecializedCategory,
descriptionPageUrl = detailsPageUrl,
magnetUri = torrentDetails.magnetUri,
@@ -4,7 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -92,8 +92,8 @@ private class NyaaResultsPageParser(
val uploadDate = tr
.selectFirst("td:nth-child(5)")
?.attr("data-timestamp")
?.let { DateUtils.formatEpochSecond(it.toLong()) }
?: return null
?.toLongOrNull()
?.let(TorrentDateParser::epochSecondToInstant)
val seeders = tr.selectFirst("td:nth-child(6)")?.ownText() ?: return null
val peers = tr.selectFirst("td:nth-child(7)")?.ownText() ?: return null
@@ -8,8 +8,8 @@ import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.extension.asObject
import com.prajwalch.torrentsearch.extension.getArray
import com.prajwalch.torrentsearch.extension.getString
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -52,12 +52,9 @@ class SubsPlease : SearchProvider {
private fun parseAnimeObject(animeObject: JsonObject): List<Torrent>? {
val name = animeObject.getString("show") ?: return null
val uploadDate = animeObject
.getString("release_date")
?.let(DateUtils::formatRFC1123Date)
?: return null
val descriptionPageUrl = animeObject
.getString("page")
val uploadDate = animeObject.getString("release_date")
?.let(TorrentDateParser::parseRFC1123)
val descriptionPageUrl = animeObject.getString("page")
?.let { "$url/$it" }
?: return null
@@ -4,7 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -66,7 +66,7 @@ private class SukebeiResultsPageParser(
val size = listItem.selectFirst(SIZE)?.ownText()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.attr("data-timestamp")
?.toLongOrNull()
?.let(DateUtils::formatEpochSecond)
?.let(TorrentDateParser::epochSecondToInstant)
val seeders = listItem.selectFirst(SEEDERS)?.ownText()?.toUIntOrNull()
val peers = listItem.selectFirst(PEERS)?.ownText()?.toUIntOrNull()
val detailsPageUrl = listItem.selectFirst(DETAILS_PAGE_URL)?.attr("abs:href")
@@ -78,7 +78,7 @@ private class SukebeiResultsPageParser(
seeders = seeders ?: 0u,
peers = peers ?: 0u,
providerName = providerName,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = providerSpecializedCategory,
descriptionPageUrl = detailsPageUrl ?: "",
magnetUri = magnetUri,
@@ -11,6 +11,7 @@ import com.prajwalch.torrentsearch.extension.getString
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -121,7 +122,7 @@ private class TBPResultsJsonParser(
val uploadDate = torrentObject
.getString("added")
?.toLongOrNull()
?.let { DateUtils.formatEpochSecond(it) }
?.let(TorrentDateParser::epochSecondToInstant)
?: return null
val categoryId = torrentObject.getString("category") ?: return null
@@ -5,8 +5,8 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -90,7 +90,7 @@ private class TheRarBgResultsPageParser(private val providerName: String) {
val peers = listItem.selectFirst(PEERS)?.ownText()?.toUIntOrNull()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.attr("data-order")
?.toLongOrNull()
?.let(DateUtils::formatEpochSecond)
?.let(TorrentDateParser::epochSecondToInstant)
val category = listItem.selectFirst(CATEGORY)?.ownText()?.let(::categoryFromRawString)
return Torrent(
@@ -100,7 +100,7 @@ private class TheRarBgResultsPageParser(private val providerName: String) {
seeders = seeders ?: 0U,
peers = peers ?: 0U,
providerName = providerName,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = category,
magnetUri = torrentDetails.magnetUri,
fileDownloadLink = torrentDetails.fileDownloadLink,
@@ -4,8 +4,8 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -75,8 +75,7 @@ private class TokyoToshokanResultsPageParser(
?: listOf(null, null)
val size = rawSize?.let(FileSizeUtils::normalizeSize)
val uploadDate = rawUploadDate
?.takeWhile { !it.isWhitespace() }
?.let(DateUtils::formatYearMonthDay)
?.let { TorrentDateParser.parse(date = it, format = "yyyy-MM-dd HH:mm Z") }
// Seeders and peers.
val seeders = tr2.selectFirst(SEEDERS)?.ownText()?.toUIntOrNull()
@@ -89,7 +88,7 @@ private class TokyoToshokanResultsPageParser(
seeders = seeders ?: 0u,
peers = peers ?: 0u,
providerName = providerName,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = providerSpecializedCategory,
descriptionPageUrl = detailsPageUrl ?: "",
magnetUri = magnetUri,
@@ -4,8 +4,8 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -78,8 +78,7 @@ private class TdResultsPageParser(private val providerName: String) {
val size = listItem.selectFirst(SIZE)?.ownText()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)
?.ownText()
?.takeWhile { !it.isWhitespace() }
?.let(DateUtils::formatYearMonthDay)
?.let { TorrentDateParser.parse(date = it, format = "yyyy-MM-dd HH:mm:ss") }
val seeders = listItem.selectFirst(SEEDERS)?.ownText()
val peers = listItem.selectFirst(PEERS)?.ownText()
@@ -89,7 +88,7 @@ private class TdResultsPageParser(private val providerName: String) {
size = size ?: "0 KB",
seeders = seeders?.toUIntOrNull() ?: 0U,
peers = peers?.toUIntOrNull() ?: 0U,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = category,
providerName = providerName,
descriptionPageUrl = descriptionPageUrl ?: "",
@@ -4,6 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -69,7 +70,8 @@ private class TorrentDownloadResultsParser(private val providerName: String) {
.trim()
.lowercase()
val uploadDate = tr.selectFirst("td:nth-child(2)")?.ownText() ?: return null
val uploadDate = tr.selectFirst("td:nth-child(2)")?.ownText()
?.let(TorrentDateParser::tryParseRelative)
val size = tr.selectFirst("td:nth-child(3)")?.ownText() ?: return null
val seeders = tr.selectFirst("td:nth-child(4)")?.ownText()?.replace(",", "") ?: return null
val peers = tr.selectFirst("td:nth-child(5)")?.ownText()?.replace(",", "") ?: return null
@@ -3,7 +3,7 @@ package com.prajwalch.torrentsearch.providers
import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -12,6 +12,8 @@ import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.time.Instant
class TorrentDownloads : SearchProvider {
override val id = "torrentdownloads"
override val name = "TorrentDownloads"
@@ -110,7 +112,7 @@ class TorrentDownloads : SearchProvider {
private suspend fun getUploadDateAndMagnetUri(
httpClient: HttpClient,
descriptionPageUrl: String,
): Pair<String, String>? {
): Pair<Instant?, String>? {
val responseHtml = httpClient.get(url = descriptionPageUrl)
val innerContainer = Jsoup
.parse(responseHtml)
@@ -127,10 +129,7 @@ class TorrentDownloads : SearchProvider {
val uploadDate = greyBars.getOrNull(6)
?.selectFirst("p")
?.ownText()
?.split(' ')
?.first()
?.let { DateUtils.formatYearMonthDay(it) }
?: return null
?.let { TorrentDateParser.parse(date = it, format = "yyyy-MM-dd HH:mm:ss") }
return Pair(uploadDate, magnetUri)
}
@@ -7,8 +7,8 @@ import com.prajwalch.torrentsearch.extension.getArray
import com.prajwalch.torrentsearch.extension.getLong
import com.prajwalch.torrentsearch.extension.getString
import com.prajwalch.torrentsearch.extension.getUInt
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -68,9 +68,8 @@ class TorrentsCSV : SearchProvider {
val seeders = torrentObject.getUInt("seeders") ?: return null
val peers = torrentObject.getUInt("leechers") ?: return null
val uploadDate = torrentObject
.getLong("created_unix")
?.let { DateUtils.formatEpochSecond(it) }
val uploadDate = torrentObject.getLong("created_unix")
?.let(TorrentDateParser::epochSecondToInstant)
?: return null
return Torrent(
@@ -79,7 +78,7 @@ class TorrentsCSV : SearchProvider {
size = size,
seeders = seeders,
peers = peers,
providerName = name,
providerName = this.name,
uploadDate = uploadDate,
descriptionPageUrl = "",
)
@@ -10,6 +10,7 @@ import com.prajwalch.torrentsearch.domain.model.TorznabConfig
import com.prajwalch.torrentsearch.domain.model.TorznabConnectionCheckResult
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.FileSizeUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import io.ktor.client.statement.bodyAsText
@@ -22,6 +23,7 @@ import org.xmlpull.v1.XmlPullParserException
import java.net.ConnectException
import java.net.UnknownHostException
import java.time.Instant
/**
* Search provider (or indexer) custom category range start.
@@ -345,7 +347,7 @@ private class TorznabResponseXmlParser(
var size: String? = null
var seeders: String? = null
var peers: String? = null
var uploadDate: String? = null
var uploadDate: Instant? = null
var descriptionPageUrl: String? = null
var magnetUri: String? = null
var infoHash: String? = null
@@ -404,7 +406,7 @@ private class TorznabResponseXmlParser(
seeders = seeders?.toUIntOrNull() ?: return,
peers = peers?.toUIntOrNull() ?: return,
providerName = providerName,
uploadDate = uploadDate ?: return,
uploadDate = uploadDate,
category = category,
descriptionPageUrl = descriptionPageUrl ?: return,
magnetUri = magnetUri,
@@ -421,11 +423,9 @@ private class TorznabResponseXmlParser(
return readTextContainedTag(tagName = "comments")
}
private fun readPubDate(): String {
private fun readPubDate(): Instant {
return readTextContainedTag(tagName = "pubDate")
.split(' ')
.subList(fromIndex = 1, toIndex = 4)
.joinToString(separator = " ")
.let(TorrentDateParser::parseRFC1123)
}
private fun readSize(): String {
@@ -4,6 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -75,6 +76,7 @@ private class UIndexResultsPageParser(
val seeders = listItem.selectFirst(SEEDERS)?.ownText()?.filter { it != ',' }?.toUIntOrNull()
val peers = listItem.selectFirst(PEERS)?.ownText()?.filter { it != ',' }?.toUIntOrNull()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.ownText()
?.let(TorrentDateParser::tryParseRelative)
val category = listItem.selectFirst(CATEGORY)?.ownText()?.let(::categoryFromRawString)
val detailsPageUrl = listItem.selectFirst(DETAILS_PAGE_URL)?.attr("abs:href")
@@ -85,7 +87,7 @@ private class UIndexResultsPageParser(
seeders = seeders ?: 0u,
peers = peers ?: 0u,
providerName = providerName,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = category,
descriptionPageUrl = detailsPageUrl ?: "",
magnetUri = magnetUri,
@@ -4,6 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -61,7 +62,8 @@ private class XXXClubResultsPageParser(private val providerName: String) {
val size = listItem.selectFirst(SIZE)?.ownText()
val seeders = listItem.selectFirst(SEEDERS)?.ownText()?.toUIntOrNull()
val peers = listItem.selectFirst(PEERS)?.ownText()?.toUIntOrNull()
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.ownText()?.let(::parseUploadDate)
val uploadDate = listItem.selectFirst(UPLOAD_DATE)?.ownText()
?.let { TorrentDateParser.parse(date = it, format = "dd MMM yyyy HH:mm:ss") }
return Torrent(
infoHash = torrentDetails.infoHash,
@@ -70,7 +72,7 @@ private class XXXClubResultsPageParser(private val providerName: String) {
seeders = seeders ?: 0u,
peers = peers ?: 0u,
providerName = providerName,
uploadDate = uploadDate ?: "0 min ago",
uploadDate = uploadDate,
category = Category.Porn,
descriptionPageUrl = detailsPageUrl,
magnetUri = torrentDetails.magnetUri,
@@ -78,15 +80,6 @@ private class XXXClubResultsPageParser(private val providerName: String) {
)
}
private fun parseUploadDate(raw: String): String {
val lastSpaceIndex = raw
.indexOfLast { ch -> ch == ' ' }
.takeIf { it != -1 }
?: return raw
return raw.substring(0..lastSpaceIndex).trim()
}
private companion object {
private const val LIST_ITEM = "ul.tsearch > li"
private const val NAME = "span:nth-child(2) > a:nth-child(2)"
@@ -4,6 +4,7 @@ import com.prajwalch.torrentsearch.domain.model.Category
import com.prajwalch.torrentsearch.domain.model.Torrent
import com.prajwalch.torrentsearch.domain.model.TorrentDetails
import com.prajwalch.torrentsearch.network.HttpClient
import com.prajwalch.torrentsearch.util.TorrentDateParser
import com.prajwalch.torrentsearch.util.TorrentUtils
import kotlinx.coroutines.Dispatchers
@@ -12,6 +13,8 @@ import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.util.Locale
class XXXTracker : SearchProvider, TorrentDetailsProvider {
override val id = "xxxtracker"
override val name = "XXXTracker"
@@ -51,7 +54,13 @@ class XXXTracker : SearchProvider, TorrentDetailsProvider {
}
private fun parseTr(tr: Element): Torrent? {
val uploadDate = tr.selectFirst("td:nth-child(1)")?.ownText() ?: return null
val uploadDate = tr.selectFirst("td:nth-child(1)")?.ownText()?.let {
TorrentDateParser.parse(
date = it,
format = "dd MMM yy",
locale = Locale.forLanguageTag("ru_RU"),
)
}
val secondTd = tr.selectFirst("td:nth-child(2)") ?: return null
val magnetUri = secondTd.selectFirst("a:nth-child(1)")?.attr("href") ?: return null
@@ -8,7 +8,7 @@ import com.prajwalch.torrentsearch.extension.getLong
import com.prajwalch.torrentsearch.extension.getObject
import com.prajwalch.torrentsearch.extension.getString
import com.prajwalch.torrentsearch.extension.getUInt
import com.prajwalch.torrentsearch.util.DateUtils
import com.prajwalch.torrentsearch.util.TorrentDateParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -173,8 +173,7 @@ class Yts : SearchProvider {
val peers = torrentObject.getUInt("peers") ?: return null
val uploadDate = torrentObject
.getLong("date_uploaded_unix")
?.let { DateUtils.formatEpochSecond(it) }
?: return null
?.let(TorrentDateParser::epochSecondToInstant)
return Torrent(
infoHash = infoHash,
@@ -96,7 +96,7 @@ private fun BookmarkListItem(
size = bookmark.size,
seeders = bookmark.seeders,
peers = bookmark.peers,
uploadDate = bookmark.uploadDate,
uploadDate = bookmark.uploadDate?.toString(),
category = bookmark.category,
providerName = bookmark.providerName,
isNSFW = bookmark.isNSFW,
@@ -34,7 +34,7 @@ fun TorrentListItem(
size: String,
seeders: UInt,
peers: UInt,
uploadDate: String,
uploadDate: String?,
category: Category?,
providerName: String,
isNSFW: Boolean,
@@ -58,7 +58,7 @@ fun TorrentListItem(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spaces.small),
verticalAlignment = Alignment.CenterVertically,
) {
Text(uploadDate)
uploadDate?.let { Text(it) }
if (isNSFW) NSFWBadge()
}
Text(providerName)
@@ -67,7 +67,7 @@ fun SearchResults(
size = it.size,
seeders = it.seeders,
peers = it.peers,
uploadDate = it.uploadDate,
uploadDate = it.uploadDate?.toString(),
category = it.category,
providerName = it.providerName,
isNSFW = it.isNSFW,
@@ -1,21 +1,21 @@
package com.prajwalch.torrentsearch.util
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
object DateUtils {
private val dateFormatter = TorrentSearchDateFormatter.init()
/** The date output format. */
const val OUTPUT_FORMAT = "dd MMM yyyy"
private val outputFormatter = DateTimeFormatter.ofPattern(OUTPUT_FORMAT)
fun formatEpochSecond(epochSecond: Long) = dateFormatter.formatEpochSecond(epochSecond)
fun formatEpochSecond(epochSecond: Long): String {
val instant = Instant.ofEpochSecond(epochSecond)
fun formatYearMonthDay(date: String) = dateFormatter.formatYearMonthDay(date)
val zoneId = ZoneId.systemDefault()
val zonedDateTime = instant.atZone(zoneId)
fun formatDayMonthYear(date: String) = dateFormatter.formatDayMonthYear(date)
fun formatMonthDayYear(date: String) = dateFormatter.formatMonthDayYear(date)
fun formatIsoDate(date: String) = dateFormatter.formatIsoDate(date)
fun formatRFC1123Date(date: String) = dateFormatter.formatRFC1123Date(date)
fun formatTodayDate() = dateFormatter.formatTodayDate()
fun formatYesterdayDate() = dateFormatter.formatYesterdayDate()
return zonedDateTime.format(outputFormatter)
}
}
@@ -0,0 +1,105 @@
package com.prajwalch.torrentsearch.util
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
object TorrentDateParser {
const val YEAR_MONTH_DAY = "yyyy-M-d"
const val DAY_MONTH_YEAR = "d/M/yyyy"
const val MONTH_DAY_YEAR = "M/d/yyyy"
private val RelativeTimePattern = Regex(
"""\b(\d+(?:\.\d+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|wk|wks|week|weeks|mo|mos|month|months|y|yr|yrs|year|years)\b\s*(ago)?\b""",
RegexOption.IGNORE_CASE
)
private val DefaultTimeZone = ZoneOffset.UTC
fun parse(date: String, format: String): Instant? {
val inputFormatter = DateTimeFormatter.ofPattern(format)
return LocalDate.parse(date, inputFormatter)
.atStartOfDay(DefaultTimeZone)
.toInstant()
}
fun parse(date: String, format: String, locale: Locale): Instant? {
val inputFormatter = DateTimeFormatter.ofPattern(format, locale)
return LocalDate.parse(date, inputFormatter)
.atStartOfDay(DefaultTimeZone)
.toInstant()
}
fun tryParseRelative(date: String): Instant? {
// One of the clown doesn't even display proper relative time, and instead
// uses '1 Year+' formatting for every old torrents.
val date = date.removeSuffix("+").trim()
val parsed = tryParseSpecialRelative(date)
if (parsed != null) return parsed
val matched = RelativeTimePattern.find(date) ?: return null
val value = matched.groupValues[1].toDoubleOrNull()?.toLong() ?: return null
val unit = matched.groupValues[2].lowercase()
val duration = when (unit) {
"s", "sec", "secs", "second", "seconds" -> Duration.ofSeconds(value)
"m", "min", "mins", "minute", "minutes" -> Duration.ofMinutes(value)
"h", "hr", "hrs", "hour", "hours" -> Duration.ofHours(value)
"d", "day", "days" -> Duration.ofDays(value)
"w", "wk", "wks", "week", "weeks" -> Duration.ofDays(value * 7L)
"mo", "mos", "month", "months" -> Duration.ofDays(value * 30L)
"y", "yr", "yrs", "year", "years" -> Duration.ofDays(value * 365L)
else -> return null
}
return Instant.now().minus(duration)
}
fun tryParseSpecialRelative(date: String): Instant? = when (date.lowercase()) {
"today", "just now", "moments ago" -> Instant.now()
"yesterday" -> LocalDate.now(DefaultTimeZone)
.minusDays(1L)
.atStartOfDay(DefaultTimeZone)
.toInstant()
"last week" -> Instant.now().minus(Duration.ofDays(7))
"last month" -> Instant.now().minus(Duration.ofDays(30))
"last year" -> Instant.now().minus(Duration.ofDays(365))
else -> null
}
fun epochSecondToInstant(second: Long): Instant =
Instant.ofEpochSecond(second)
fun parseYearMonthDay(date: String): Instant? =
parse(date = date, format = YEAR_MONTH_DAY)
fun parseDayMonthYear(date: String): Instant? =
parse(date = date, format = DAY_MONTH_YEAR)
fun parseMonthDayYear(date: String): Instant? =
parse(date = date, format = MONTH_DAY_YEAR)
fun parseIso(date: String): Instant =
OffsetDateTime.parse(date).toInstant()
fun parseRFC1123(date: String): Instant {
val inputFormatter = DateTimeFormatter.RFC_1123_DATE_TIME
return LocalDate.parse(date, inputFormatter).atStartOfDay(DefaultTimeZone).toInstant()
}
fun getTodayDate(): Instant =
LocalDate.now(DefaultTimeZone)
.atStartOfDay(DefaultTimeZone)
.toInstant()
fun getYesterdayDate(): Instant =
LocalDate.now(DefaultTimeZone)
.minusDays(1L)
.atStartOfDay(DefaultTimeZone)
.toInstant()
}
@@ -1,182 +0,0 @@
package com.prajwalch.torrentsearch.util
import android.os.Build
import androidx.annotation.RequiresApi
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Locale
class TorrentSearchDateFormatter private constructor(
formatter: DateFormatter,
) : DateFormatter by formatter {
companion object {
/** Initializes appropriate date formatter for current Android version. */
fun init(): TorrentSearchDateFormatter {
val formatter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ModernDateFormatter()
} else {
LegacyDateFormatter()
}
return TorrentSearchDateFormatter(formatter = formatter)
}
}
}
/** Different date formats used to either format or parse date. */
private object DateFormats {
/** The date output format. */
const val OUTPUT_FORMAT = "dd MMM yyyy"
/** ISO-8601 variant format without timestamp. */
const val YEAR_MONTH_DAY_HYPHEN_SEPARATED = "yyyy-M-d"
const val DAY_MONTH_YEAR = "d/M/yyyy"
const val MONTH_DAY_YEAR = "M/d/yyyy"
/** Format used to parse ISO-8601 date in legacy date formatter. */
const val LEGACY_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
/** Format used to parse RFC-1123 date in legacy date formatter. */
const val LEGACY_RFC_1123 = "EEE, dd MMM yyyy HH:mm:ss z"
}
/** The common interface for both legacy and modern date formatters. */
private interface DateFormatter {
fun formatEpochSecond(epochSecond: Long): String
fun formatYearMonthDay(date: String): String
fun formatDayMonthYear(date: String): String
fun formatMonthDayYear(date: String): String
/**
* Parses ISO 8601 date (e.g., `2025-06-11T06:13:57+00:00`) into a more
* readable format.
*
* @return Date formatted as [DateFormats.OUTPUT_FORMAT], e.g., `"11 Jun 2025"`.
*/
fun formatIsoDate(date: String): String
fun formatRFC1123Date(date: String): String
fun formatTodayDate(): String
fun formatYesterdayDate(): String
}
/** The date formatter for Android version >= 8.0. */
@RequiresApi(Build.VERSION_CODES.O)
private class ModernDateFormatter : DateFormatter {
private val outputFormatter = DateTimeFormatter.ofPattern(DateFormats.OUTPUT_FORMAT)
override fun formatEpochSecond(epochSecond: Long): String {
val instant = Instant.ofEpochSecond(epochSecond)
val zoneId = ZoneId.systemDefault()
val zonedDateTime = instant.atZone(zoneId)
return zonedDateTime.format(outputFormatter)
}
override fun formatYearMonthDay(date: String): String {
return formatDate(pattern = DateFormats.YEAR_MONTH_DAY_HYPHEN_SEPARATED, date = date)
}
override fun formatDayMonthYear(date: String): String {
return formatDate(pattern = DateFormats.DAY_MONTH_YEAR, date = date)
}
override fun formatMonthDayYear(date: String): String {
return formatDate(pattern = DateFormats.MONTH_DAY_YEAR, date = date)
}
override fun formatIsoDate(date: String): String {
val date = OffsetDateTime.parse(date)
return outputFormatter.format(date)
}
override fun formatRFC1123Date(date: String): String {
val inputFormatter = DateTimeFormatter.RFC_1123_DATE_TIME
val parsedDate = LocalDate.parse(date, inputFormatter) ?: return date
return parsedDate.format(outputFormatter)
}
override fun formatTodayDate(): String {
return LocalDate.now().format(outputFormatter)
}
override fun formatYesterdayDate(): String {
return LocalDate.now().minusDays(1L).format(outputFormatter)
}
private fun formatDate(pattern: String, date: String): String {
val inputFormatter = DateTimeFormatter.ofPattern(pattern)
val localDate = LocalDate.parse(date, inputFormatter) ?: return date
return localDate.format(outputFormatter)
}
}
/** The date formatter for Android version < 8.0. */
private class LegacyDateFormatter : DateFormatter {
private val outputFormatter = SimpleDateFormat(DateFormats.OUTPUT_FORMAT, Locale.getDefault())
override fun formatEpochSecond(epochSecond: Long): String {
return outputFormatter.format(epochSecond * 1000L)
}
override fun formatYearMonthDay(date: String): String {
return formatDate(pattern = DateFormats.YEAR_MONTH_DAY_HYPHEN_SEPARATED, date = date)
}
override fun formatDayMonthYear(date: String): String {
return formatDate(pattern = DateFormats.DAY_MONTH_YEAR, date = date)
}
override fun formatMonthDayYear(date: String): String {
return formatDate(pattern = DateFormats.MONTH_DAY_YEAR, date = date)
}
override fun formatIsoDate(date: String): String {
return formatDate(pattern = DateFormats.LEGACY_ISO_8601, date = date)
}
override fun formatRFC1123Date(date: String): String {
return formatDate(pattern = DateFormats.LEGACY_RFC_1123, date = date)
}
override fun formatTodayDate(): String {
val todayCalender = Calendar.getInstance()
todayCalender.set(Calendar.HOUR_OF_DAY, 0)
val todayDate = todayCalender.time
return outputFormatter.format(todayDate)
}
override fun formatYesterdayDate(): String {
val todayCalendar = Calendar.getInstance()
todayCalendar.add(Calendar.DATE, -1)
val yesterdayDate = todayCalendar.time
return outputFormatter.format(yesterdayDate)
}
private fun formatDate(pattern: String, date: String): String {
val inputFormatter = SimpleDateFormat(pattern, Locale.getDefault())
val date = inputFormatter.parse(date) ?: return date
return outputFormatter.format(date)
}
}
+2
View File
@@ -7,6 +7,7 @@ composeMarkdown = "0.7.0"
coreKtx = "1.18.0"
coreSplashscreen = "1.2.0"
datastorePreferences = "1.2.1"
desugarJdkLibs = "2.1.5"
espressoCore = "3.7.0"
flexmark = "0.64.8"
hilt = "2.59.2"
@@ -48,6 +49,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
flexmark-html2md-converter = { group = "com.vladsch.flexmark", name = "flexmark-html2md-converter", version.ref = "flexmark" }
flexmark-util = { group = "com.vladsch.flexmark", name = "flexmark-util", version.ref = "flexmark" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }