parse Subsonic dates without DatatypeFactory, reset list scroll on option change, fix search bar over the keyboard

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 63f0549181
commit 266b677ece
4 changed files with 101 additions and 64 deletions
@@ -7,8 +7,6 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import org.w3c.dom.Document
import org.w3c.dom.Element
import javax.xml.datatype.DatatypeConfigurationException
import javax.xml.datatype.DatatypeFactory
object SubsonicXml {
fun artists(
@@ -130,22 +128,46 @@ private fun Element.longAttr(name: String): Long? = getAttribute(name).takeIf {
private fun Element.dateAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
internal object IsoDate {
private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE)
private val factory: DatatypeFactory? =
try {
DatatypeFactory.newInstance()
} catch (_: DatatypeConfigurationException) {
null
}
private val pattern =
Regex(
"^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2}):(\\d{2})(?:\\.\\d+)?\\s*(Z|[+-]\\d{2}:?\\d{2})?)?$",
)
fun parse(value: String): Long? {
val factory = factory ?: return null
return try {
val zoned = if (timezone.containsMatchIn(value)) value else value + "Z"
factory.newXMLGregorianCalendar(zoned).toGregorianCalendar().timeInMillis / 1000
} catch (_: IllegalArgumentException) {
null
val match = pattern.matchEntire(value.trim()) ?: return null
val year = match.groupValues[1].toIntOrNull() ?: return null
val month = match.groupValues[2].toIntOrNull() ?: return null
val day = match.groupValues[3].toIntOrNull() ?: return null
val hour = match.groupValues[4].toIntOrNull() ?: 0
val minute = match.groupValues[5].toIntOrNull() ?: 0
val second = match.groupValues[6].toIntOrNull() ?: 0
if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) {
return null
}
val days = daysFromCivil(year, month, day)
return days * 86400L + hour * 3600L + minute * 60L + second - offsetSeconds(match.groupValues[7])
}
private fun offsetSeconds(zone: String): Long {
if (zone.isEmpty() || zone.equals("Z", ignoreCase = true)) return 0
val digits = zone.drop(1).replace(":", "")
if (digits.length != 4) return 0
val hours = digits.take(2).toLongOrNull() ?: return 0
val minutes = digits.drop(2).toLongOrNull() ?: return 0
val sign = if (zone[0] == '-') -1L else 1L
return sign * (hours * 3600 + minutes * 60)
}
private fun daysFromCivil(
year: Int,
month: Int,
day: Int,
): Long {
val y = if (month <= 2) year - 1 else year
val era = (if (y >= 0) y else y - 399) / 400
val yearOfEra = y - era * 400
val dayOfYear = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1
val dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
return era * 146097L + dayOfEra - 719468L
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -147,7 +148,7 @@ private fun MainNavigation() {
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().imePadding()) {
NavHost(
navController = navController,
modifier =
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
@@ -60,6 +59,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -327,54 +327,56 @@ fun LibraryScreen(
state = pagerState,
modifier = Modifier.fillMaxWidth().weight(1f),
) { page ->
when (LibraryTab.entries[page]) {
LibraryTab.Albums -> {
AlbumsContent(
albums,
coverArt,
listBottomInset,
onAlbumClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
key(resetKey) {
when (LibraryTab.entries[page]) {
LibraryTab.Albums -> {
AlbumsContent(
albums,
coverArt,
listBottomInset,
onAlbumClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Artists -> {
ArtistsContent(
artists,
coverArt,
listBottomInset,
onArtistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Artists -> {
ArtistsContent(
artists,
coverArt,
listBottomInset,
onArtistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Songs -> {
SongsContent(
songs,
coverArt,
listBottomInset,
onSongClick,
playingSongId,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Songs -> {
SongsContent(
songs,
coverArt,
listBottomInset,
onSongClick,
playingSongId,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
LibraryTab.Playlists -> {
PlaylistsContent(
playlists,
coverArt,
listBottomInset,
onPlaylistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
LibraryTab.Playlists -> {
PlaylistsContent(
playlists,
coverArt,
listBottomInset,
onPlaylistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
}
}
}
}
@@ -393,7 +395,6 @@ fun LibraryScreen(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
@@ -220,6 +220,19 @@ class SyncServiceTest {
)
}
@Test
fun syncStoresStarredAndAddedTimestamps() =
runTest {
insertSource()
val source = FakeMusicSource(albums = listOf(album("al1").copy(created = 1234, starred = 5678)))
SyncService(db, source).sync()
val stored = db.libraryDao().album(1, "al1").first()
assertEquals(1234L, stored?.created)
assertEquals(5678L, stored?.starred)
}
private suspend fun <T : Any> PagingSource<Int, T>.allRows(): List<T> {
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
return (page as PagingSource.LoadResult.Page).data