From 266b677eceb6c94b0bb7d6ec67a994f38fee46df Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:17 +0000 Subject: [PATCH] parse Subsonic dates without DatatypeFactory, reset list scroll on option change, fix search bar over the keyboard --- .../data/source/subsonic/SubsonicXml.kt | 54 +++++++---- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 3 +- .../com/subtracks/ui/library/LibraryScreen.kt | 95 ++++++++++--------- .../subtracks/data/sync/SyncServiceTest.kt | 13 +++ 4 files changed, 101 insertions(+), 64 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index 04909a5c..c39ac374 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -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 } } diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index b1251001..9e1c188a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -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 = diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index bcdcab95..9f905f44 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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), ) } diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index c3fbb00f..134b2833 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -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 PagingSource.allRows(): List { val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false)) return (page as PagingSource.LoadResult.Page).data