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 com.subtracks.data.model.Song
import org.w3c.dom.Document import org.w3c.dom.Document
import org.w3c.dom.Element import org.w3c.dom.Element
import javax.xml.datatype.DatatypeConfigurationException
import javax.xml.datatype.DatatypeFactory
object SubsonicXml { object SubsonicXml {
fun artists( 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) private fun Element.dateAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
internal object IsoDate { internal object IsoDate {
private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE) private val pattern =
Regex(
private val factory: DatatypeFactory? = "^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2}):(\\d{2})(?:\\.\\d+)?\\s*(Z|[+-]\\d{2}:?\\d{2})?)?$",
try { )
DatatypeFactory.newInstance()
} catch (_: DatatypeConfigurationException) {
null
}
fun parse(value: String): Long? { fun parse(value: String): Long? {
val factory = factory ?: return null val match = pattern.matchEntire(value.trim()) ?: return null
return try { val year = match.groupValues[1].toIntOrNull() ?: return null
val zoned = if (timezone.containsMatchIn(value)) value else value + "Z" val month = match.groupValues[2].toIntOrNull() ?: return null
factory.newXMLGregorianCalendar(zoned).toGregorianCalendar().timeInMillis / 1000 val day = match.groupValues[3].toIntOrNull() ?: return null
} catch (_: IllegalArgumentException) { val hour = match.groupValues[4].toIntOrNull() ?: 0
null 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.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -147,7 +148,7 @@ private fun MainNavigation() {
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().imePadding()) {
NavHost( NavHost(
navController = navController, navController = navController,
modifier = modifier =
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -60,6 +59,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -327,54 +327,56 @@ fun LibraryScreen(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.fillMaxWidth().weight(1f),
) { page -> ) { page ->
when (LibraryTab.entries[page]) { key(resetKey) {
LibraryTab.Albums -> { when (LibraryTab.entries[page]) {
AlbumsContent( LibraryTab.Albums -> {
albums, AlbumsContent(
coverArt, albums,
listBottomInset, coverArt,
onAlbumClick, listBottomInset,
filtered = filtersActive, onAlbumClick,
onClearFilters = onClearFilters, filtered = filtersActive,
resetKey = resetKey, onClearFilters = onClearFilters,
) resetKey = resetKey,
} )
}
LibraryTab.Artists -> { LibraryTab.Artists -> {
ArtistsContent( ArtistsContent(
artists, artists,
coverArt, coverArt,
listBottomInset, listBottomInset,
onArtistClick, onArtistClick,
filtered = filtersActive, filtered = filtersActive,
onClearFilters = onClearFilters, onClearFilters = onClearFilters,
resetKey = resetKey, resetKey = resetKey,
) )
} }
LibraryTab.Songs -> { LibraryTab.Songs -> {
SongsContent( SongsContent(
songs, songs,
coverArt, coverArt,
listBottomInset, listBottomInset,
onSongClick, onSongClick,
playingSongId, playingSongId,
filtered = filtersActive, filtered = filtersActive,
onClearFilters = onClearFilters, onClearFilters = onClearFilters,
resetKey = resetKey, resetKey = resetKey,
) )
} }
LibraryTab.Playlists -> { LibraryTab.Playlists -> {
PlaylistsContent( PlaylistsContent(
playlists, playlists,
coverArt, coverArt,
listBottomInset, listBottomInset,
onPlaylistClick, onPlaylistClick,
filtered = filtersActive, filtered = filtersActive,
onClearFilters = onClearFilters, onClearFilters = onClearFilters,
resetKey = resetKey, resetKey = resetKey,
) )
}
} }
} }
} }
@@ -393,7 +395,6 @@ fun LibraryScreen(
Modifier Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp), .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> { private suspend fun <T : Any> PagingSource<Int, T>.allRows(): List<T> {
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false)) val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
return (page as PagingSource.LoadResult.Page).data return (page as PagingSource.LoadResult.Page).data