Author: Adam Evyčędo <git@apiote.xyz>
save feed infos, not response
%!v(PANIC=String method: strings: negative Repeat count)
diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt index a93eb3aba06721748653ce20bd6b35e591997495..f7f11028cc98d6a6293159a69cd838bef236a0ac 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt @@ -72,6 +72,9 @@ val intent = (context as Activity).intent var feeds = OfflineRepository().getFeeds(context) if (feeds.isNullOrEmpty()) { feeds = OnlineRepository().getFeeds(context) + if (feeds != null) { + OfflineRepository().saveFeedCache(context, feeds) + } } return when (intent.action) { Intent.ACTION_VIEW -> { diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt index 4c3211555a8fc4219845e9562e824ca6195c25f5..04d7f546e88a6465b0fc535cd15d304d42b478d6 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt @@ -6,11 +6,24 @@ package xyz.apiote.bimba.czwek.repo import xyz.apiote.bimba.czwek.api.structs.FeedInfoV1 import xyz.apiote.bimba.czwek.api.structs.FeedInfoV2 +import xyz.apiote.bimba.czwek.api.structs.QrLocationV1 +import xyz.apiote.fruchtfleisch.Reader +import xyz.apiote.fruchtfleisch.Writer +import java.io.InputStream +import java.io.OutputStream import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale +class FeedInfoPrev { + companion object { + fun unmarshal(stream: InputStream): FeedInfo { + return FeedInfo(FeedInfoPrev()) + } + } +} + data class FeedInfo( val id: String, val name: String, @@ -24,6 +37,41 @@ val validSince: LocalDate?, val validTill: LocalDate?, val cached: Boolean ) { + companion object { + const val VERSION = 100u + private fun parseDate(dateString: String) = + LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE) + + + fun unmarshal(stream: InputStream): FeedInfo { + val reader = Reader(stream) + val id = reader.readString() + val name = reader.readString() + val attribution = reader.readString() + val description = reader.readString() + val lastUpdate = parseDate(reader.readString()) + val qrHost = reader.readString() + val qrIn = QrLocation.of(QrLocationV1.of(reader.readUInt().toULong().toUInt())) + val qrSelector = reader.readString() + val validSince = reader.readString() + val validTill = reader.readString() + + return FeedInfo( + id, + name, + attribution, + description, + lastUpdate, + qrHost, + qrIn, + qrSelector, + if (validSince != "") parseDate(validSince) else null, + if (validTill != "") parseDate(validTill) else null, + true + ) + } + } + constructor(f: FeedInfoV2, cached: Boolean = false) : this( f.id, f.name, @@ -52,6 +100,38 @@ null, cached ) + constructor(f: FeedInfoPrev) : this( + "", + "", + "", + "", + LocalDate.MIN, + "", + QrLocation.UNKNOWN, + "", + null, + null, + false + ) + + fun marshal(stream: OutputStream) { + val writer = Writer(stream) + writer.writeString(id) + writer.writeString(name) + writer.writeString(attribution) + writer.writeString(description) + writer.writeString(formatDateMarshal(lastUpdate)) + writer.writeString(qrHost) + writer.writeUInt(qrIn.value().toULong()) + writer.writeString(qrSelector) + writer.writeString(if (validSince == null) "" else formatDateMarshal(validSince)) + writer.writeString(if (validTill == null) "" else formatDateMarshal(validTill)) + } + + private fun formatDateMarshal(date: LocalDate): String { + return date.format(DateTimeFormatter.ISO_LOCAL_DATE) + } + fun formatDate(): String { return lastUpdate.format( DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(Locale.getDefault()) @@ -74,8 +154,16 @@ if (lastUpdate.isAfter(other.lastUpdate)) lastUpdate else other.lastUpdate, other.qrHost, other.qrIn, other.qrSelector, - if (other.validSince == null || (validSince?:LocalDate.MIN).isAfter(other.validSince)) validSince else other.validSince, - if (other.validTill == null || (validTill?:LocalDate.MIN).isAfter(other.validTill)) validTill else other.validTill, + if (other.validSince == null || (validSince + ?: LocalDate.MIN).isAfter(other.validSince) + ) validSince else other.validSince, + if (other.validTill == null || (validTill + ?: LocalDate.MIN).isAfter(other.validTill) + ) validTill else other.validTill, this.cached && other.cached ) } + +fun migrateFeeds() { + +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt index 060a5e8b409d2253e46f0193a99e0536234e3435..370e3fdc6c72ba886737c4440c0b88e18d4c77cc 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt @@ -5,19 +5,33 @@ package xyz.apiote.bimba.czwek.repo import android.content.Context -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import androidx.core.content.edit import xyz.apiote.bimba.czwek.api.Server -import xyz.apiote.bimba.czwek.api.responses.FeedsResponse -import xyz.apiote.bimba.czwek.api.responses.FeedsResponseDev -import xyz.apiote.bimba.czwek.api.responses.FeedsResponseV1 -import xyz.apiote.bimba.czwek.api.responses.FeedsResponseV2 +import xyz.apiote.fruchtfleisch.Reader +import xyz.apiote.fruchtfleisch.Writer import java.io.File -import java.io.FileInputStream import java.net.URLEncoder import java.time.LocalDate class OfflineRepository : Repository { + fun saveFeedCache(context: Context, feedInfos: Map<String, FeedInfo>) { + val file = File( + context.filesDir, URLEncoder.encode(Server.get(context).apiPath, "utf-8") + ) + context.getSharedPreferences("offlineFeeds", Context.MODE_PRIVATE).edit { + putInt("version", FeedInfo.VERSION.toInt()) + } + val stream = file.outputStream() + val writer = Writer(stream) + writer.writeUInt(feedInfos.size.toULong()) + feedInfos.forEach { + it.value.marshal(stream) + } + stream.flush() + stream.close() + } + + @Suppress("RedundantNullableReturnType") override suspend fun getFeeds( context: Context, server: Server @@ -28,31 +42,25 @@ ) if (!file.exists()) { return emptyMap() } - return when (val response = - withContext(Dispatchers.IO) { FeedsResponse.unmarshal(FileInputStream(file)) }) { - is FeedsResponseDev -> response.feeds.associate { - Pair( - it.id, - FeedInfo(it).copy(cached = true) - ) - } - is FeedsResponseV2 -> response.feeds.associate { - Pair( - it.id, - FeedInfo(it).copy(cached = true) - ) - } - - is FeedsResponseV1 -> response.feeds.associate { - Pair( - it.id, - FeedInfo(it).copy(cached = true) - ) + val version = + context.getSharedPreferences("offlineFeeds", Context.MODE_PRIVATE).getInt("version", -1) + if (version < 0) { + return emptyMap() + } + val unmarshaller = + if (version.toUInt() == FeedInfo.VERSION) FeedInfo::unmarshal else FeedInfoPrev::unmarshal + val stream = file.inputStream() + val feeds = mutableMapOf<String, FeedInfo>() + val n = Reader(stream).readUInt().toULong().toInt() + repeat(n) { + val feed = unmarshaller(stream) + feeds[feed.id] = feed + if (version.toUInt() != FeedInfo.VERSION) { + saveFeedCache(context, feeds) } - - else -> null } + return feeds } override suspend fun getDepartures( diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt index 46c1285469207349ce1535a492aef6d7b1fb8a30..41719310917f2dbc331d147cd03fa30d5676bd61 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt @@ -52,13 +52,6 @@ // todo [3.2] in Repository check if responses are BARE or HTML class OnlineRepository : Repository { - private fun saveFeedCache(server: Server, context: Context, rawResponse: ByteArray) { - val file = File( - context.filesDir, URLEncoder.encode(server.apiPath, "utf-8") - ) - file.writeBytes(rawResponse) - } - override suspend fun getFeeds( context: Context, server: Server @@ -74,7 +67,6 @@ throw TrafficResponseException(result.error.statusCode, "", result.error) } } else { val rawResponse = result.stream!!.readBytes() - saveFeedCache(server, context, rawResponse) return when (val response = withContext(Dispatchers.IO) { FeedsResponse.unmarshal(rawResponse.inputStream()) }) { is FeedsResponseDev -> response.feeds.associate { Pair(it.id, FeedInfo(it)) } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt index 93d5932ed09463d59ff583a66727df435461f03e..108b0077fdb81df22d1e537ed9e696f3bc6d4f92 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt @@ -20,4 +20,13 @@ QrLocationV1.QUERY -> QUERY } } } + + fun value(): UInt { + return when (this) { + UNKNOWN -> 0u + NONE -> 1u + PATH -> 2u + QUERY -> 3u + } + } } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt index 4b651ced88475df9b4e0d3ae1982b9eb49d26500..6d5fb983e6baf603ce0237922088462d9450f328 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import xyz.apiote.bimba.czwek.api.Error import xyz.apiote.bimba.czwek.repo.FeedInfo import xyz.apiote.bimba.czwek.repo.OfflineRepository @@ -44,25 +45,36 @@ setSettings(feedID, feedSettings?.copy(enabled = enabled) ?: FeedSettings(enabled, true)) } fun loadFeeds(context: Context) { + var offlineFeeds: Map<String, FeedInfo>? = null + var onlineFeeds: Map<String, FeedInfo>? = null + var error: Error? = null MainScope().launch { - val offlineRepository = OfflineRepository() - val offlineFeeds = - offlineRepository.getFeeds(context) - if (!offlineFeeds.isNullOrEmpty()) { - _feeds.value = offlineFeeds!! + withContext(coroutineContext) { + launch { + offlineFeeds = + OfflineRepository().getFeeds(context) + if (!offlineFeeds.isNullOrEmpty()) { + _feeds.value = offlineFeeds!! + } + } + launch { + try { + val repository = OnlineRepository() + onlineFeeds = + repository.getFeeds(context) + } catch (e: TrafficResponseException) { + error = e.error + Log.e("Feeds", "$e") + } + } } - try { - val repository = OnlineRepository() - val onlineFeeds = - repository.getFeeds(context) + if (offlineFeeds.isNullOrEmpty() && error != null) { + _error.value = error!! + } else{ joinFeeds(offlineFeeds, onlineFeeds).let { joinedFeeds -> _feeds.value = joinedFeeds + OfflineRepository().saveFeedCache(context, joinedFeeds) } - } catch (e: TrafficResponseException) { - if (offlineFeeds.isNullOrEmpty()) { - _error.value = e.error - } - Log.e("Feeds", "$e") } } } @@ -82,6 +94,8 @@ if (feeds2.isNullOrEmpty()) { return feeds1 } - return feeds1.keys.union(feeds2.keys).associateWith { feeds1[it].join(feeds2[it]) } + return feeds1.keys.union(feeds2.keys).associateWith { + feeds1[it].join(feeds2[it]) + } } } diff --git a/fruchtfleisch/build.gradle b/fruchtfleisch/build.gradle index a510340b4c57eef3e6a539c90d057d9dd62b44f1..85c32f76546645efcd429747057f155ce9b51955 100644 --- a/fruchtfleisch/build.gradle +++ b/fruchtfleisch/build.gradle @@ -8,9 +8,15 @@ id 'org.jetbrains.kotlin.jvm' } dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' + //implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.10' } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 +} +test { + useJUnitPlatform() } \ No newline at end of file diff --git a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt index a336c9f136e40ff0896895110071eb81a23f8ba3..3b29727d880991e2d5a8cd58f7e91bd2e34401f4 100644 --- a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt +++ b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt @@ -9,17 +9,10 @@ import java.io.InputStream import java.lang.Double.longBitsToDouble import java.lang.Float.intBitsToFloat -data class IntVar(private val v: Long) { - fun toLong() = v -} -data class UIntVar(private val v: ULong) { - fun toULong() = v -} - @Suppress("MemberVisibilityCanBePrivate", "unused", "BooleanMethodIsAlwaysInverted") class Reader(private val stream: InputStream) { fun readUInt(): UIntVar { - var result: ULong = 0UL + var result = 0UL var i = 0 var s = 0 while (true) { diff --git a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/UInt.kt b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/UInt.kt new file mode 100644 index 0000000000000000000000000000000000000000..d058ee473c7bc0bf6bc200dfc5aca6ac8da2ed84 --- /dev/null +++ b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/UInt.kt @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Adam Evyčędo +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package xyz.apiote.fruchtfleisch + +data class IntVar(private val v: Long) { + fun toLong() = v +} +data class UIntVar(private val v: ULong) { + fun toULong() = v +} \ No newline at end of file diff --git a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Writer.kt b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Writer.kt new file mode 100644 index 0000000000000000000000000000000000000000..7fe8ebe1fe76d0dbda1f0af4bb65cf8f69e673fe --- /dev/null +++ b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Writer.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Adam Evyčędo +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package xyz.apiote.fruchtfleisch + +import java.io.OutputStream + +@Suppress("MemberVisibilityCanBePrivate") +class Writer(private val stream: OutputStream) { + @OptIn(ExperimentalUnsignedTypes::class) + fun writeUInt(v: ULong) { + var value = v + val bytes = mutableListOf<UByte>() + while (value >= 0x80u) { + bytes.add(value.toUByte() or 0x80u) + value = value.shr(7) + } + bytes.add(value.toUByte()) + stream.write(bytes.toUByteArray().toByteArray()) + } + + fun writeFixedData(v: ByteArray) { + stream.write(v) + } + + fun writeData(v: ByteArray) { + writeUInt(v.size.toULong()) + writeFixedData(v) + } + + fun writeString(v: String) { + writeData(v.encodeToByteArray()) + } +} \ No newline at end of file diff --git a/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/ReaderTest.kt b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/ReaderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..210beec41cea3377f8cd050f2091bb4f4c1bb652 --- /dev/null +++ b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/ReaderTest.kt @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Adam Evyčędo +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package xyz.apiote.fruchtfleisch + +import org.junit.jupiter.api.Test + + +@OptIn(ExperimentalUnsignedTypes::class) +class ReaderTest { + @Test + fun readUInt17() { + val stream = byteArrayOf(0x11).inputStream() + val reader = Reader(stream) + assert(reader.readUInt().toULong().toInt() == 17) + } + @Test + fun readUInt23() { + val stream = byteArrayOf(0x17).inputStream() + val reader = Reader(stream) + assert(reader.readUInt().toULong().toInt() == 23) + } + @Test + fun readUInt999() { + val stream = ubyteArrayOf(0xe7u, 0x7u).toByteArray().inputStream() + val reader = Reader(stream) + assert(reader.readUInt().toULong().toInt() == 999) + } + + @Test + fun readInt() { + } + + @Test + fun readU8() { + } + + @Test + fun readU16() { + } + + @Test + fun readU32() { + } + + @Test + fun readU64() { + } + + @Test + fun readI8() { + } + + @Test + fun readI16() { + } + + @Test + fun readI32() { + } + + @Test + fun readI64() { + } + + @Test + fun readFloat32() { + } + + @Test + fun readFloat64() { + } + + @Test + fun readData() { + } + + @Test + fun readStringAscii() { + val stream = byteArrayOf(0x24, 0x4d, 0x72, 0x2e, 0x20, 0x4a, 0x6f, 0x63, 0x6b, 0x2c, 0x20, 0x54, 0x56, 0x20, 0x71, 0x75, 0x69, 0x7a, 0x20, 0x50, 0x68, 0x44, 0x2c, 0x20, 0x62, 0x61, 0x67, 0x73, 0x20, 0x66, 0x65, 0x77, 0x20, 0x6c, 0x79, 0x6e, 0x78).inputStream() + val reader = Reader(stream) + assert(reader.readString() == "Mr. Jock, TV quiz PhD, bags few lynx") + } + + @Test + fun readStringUnicode() { + val stream = ubyteArrayOf(0x34u, 0x53u, 0x74u, 0x72u, 0xc3u, 0xb3u, 0xc5u, 0xbcu, 0x20u, 0x70u, 0x63u, 0x68u, 0x6eu, 0xc4u, 0x85u, 0xc5u, 0x82u, 0x20u, 0x6bu, 0x6fu, 0xc5u, 0x9bu, 0xc4u, 0x87u, 0x20u, 0x77u, 0x20u, 0x71u, 0x75u, 0x69u, 0x7au, 0x20u, 0x67u, 0xc4u, 0x99u, 0x64u, 0xc5u, 0xbau, 0x62u, 0x20u, 0x76u, 0x65u, 0x6cu, 0x20u, 0x66u, 0x61u, 0x78u, 0x20u, 0x6du, 0x79u, 0x6au, 0xc5u, 0x84u).toByteArray().inputStream() + val reader = Reader(stream) + assert(reader.readString() == "Stróż pchnął kość w quiz gędźb vel fax myjń") + } + + @Test + fun readBoolean() { + } +} \ No newline at end of file diff --git a/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/WriterTest.kt b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/WriterTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..cad8ee16bbcd6c8082e51e99b0302636c75913c1 --- /dev/null +++ b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/WriterTest.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Adam Evyčędo +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package xyz.apiote.fruchtfleisch + +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream + +@OptIn(ExperimentalUnsignedTypes::class) +class WriterTest { + + @Test + fun writeUInt17() { + val stream = ByteArrayOutputStream() + val writer = Writer(stream) + writer.writeUInt(17u) + val bytes = stream.toByteArray() + assert(bytes.contentEquals(byteArrayOf(0x11))) + } + + @Test + fun writeUInt23() { + val stream = ByteArrayOutputStream() + val writer = Writer(stream) + writer.writeUInt(23u) + val bytes = stream.toByteArray() + assert(bytes.contentEquals(byteArrayOf(0x17))) + } + + @Test + fun writeUInt999() { + val stream = ByteArrayOutputStream() + val writer = Writer(stream) + writer.writeUInt(999u) + val bytes = stream.toByteArray().toUByteArray() + assert(bytes.contentEquals(ubyteArrayOf(0xe7u, 0x7u))) + } + + @Test + fun writeStringAscii() { + val stream = ByteArrayOutputStream() + val writer = Writer(stream) + writer.writeString("Mr. Jock, TV quiz PhD, bags few lynx") + val bytes = stream.toByteArray() + assert(bytes.contentEquals(byteArrayOf(0x24, 0x4d, 0x72, 0x2e, 0x20, 0x4a, 0x6f, 0x63, 0x6b, 0x2c, 0x20, 0x54, 0x56, 0x20, 0x71, 0x75, 0x69, 0x7a, 0x20, 0x50, 0x68, 0x44, 0x2c, 0x20, 0x62, 0x61, 0x67, 0x73, 0x20, 0x66, 0x65, 0x77, 0x20, 0x6c, 0x79, 0x6e, 0x78))) + } +} \ No newline at end of file