diff --git a/.circleci/config.yml b/.circleci/config.yml index 8fea41d8..367a35bf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,7 +22,7 @@ jobs: - run: name: unit-tests command: | - ./gradlew :subsonic-api:test :ultrasonic:testDebugUnitTest + ./gradlew :subsonic-api:test :cache:test :ultrasonic:testDebugUnitTest ./gradlew jacocoFullReport bash <(curl -s https://codecov.io/bash) - run: diff --git a/cache/build.gradle b/cache/build.gradle new file mode 100644 index 00000000..57cecf59 --- /dev/null +++ b/cache/build.gradle @@ -0,0 +1,46 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' +apply plugin: 'jacoco' +apply from: '../gradle_scripts/code_quality.gradle' + +dependencies { + api project(':domain') + api other.kotlinStdlib + api other.twitterSerial + + testImplementation testing.junit + testImplementation testing.kotlinJunit + testImplementation testing.mockito + testImplementation testing.mockitoInline + testImplementation testing.mockitoKotlin + testImplementation testing.kluent +} + +jacoco { + toolVersion(versions.jacoco) +} + +ext { + jacocoExclude = [] +} + +jacocoTestReport { + reports { + html.enabled true + csv.enabled false + xml.enabled true + } + + afterEvaluate { + classDirectories = files(classDirectories.files.collect { + fileTree(dir: it, excludes: jacocoExclude) + }) + } +} + +test.finalizedBy jacocoTestReport +test { + jacoco { + excludes += jacocoExclude + } +} diff --git a/cache/src/main/kotlin/org/moire/ultrasonic/cache/Directories.kt b/cache/src/main/kotlin/org/moire/ultrasonic/cache/Directories.kt new file mode 100644 index 00000000..ad0aa6de --- /dev/null +++ b/cache/src/main/kotlin/org/moire/ultrasonic/cache/Directories.kt @@ -0,0 +1,14 @@ +package org.moire.ultrasonic.cache + +import java.io.File + +/** + * Provides access to generic directories: + * - for temporary caches + * - for permanent data storage + */ +interface Directories { + fun getInternalCacheDir(): File + fun getInternalDataDir(): File + fun getExternalCacheDir(): File? +} diff --git a/cache/src/main/kotlin/org/moire/ultrasonic/cache/PermanentFileStorage.kt b/cache/src/main/kotlin/org/moire/ultrasonic/cache/PermanentFileStorage.kt new file mode 100644 index 00000000..dbfa9658 --- /dev/null +++ b/cache/src/main/kotlin/org/moire/ultrasonic/cache/PermanentFileStorage.kt @@ -0,0 +1,75 @@ +package org.moire.ultrasonic.cache + +import com.twitter.serial.serializer.SerializationContext +import com.twitter.serial.serializer.Serializer +import com.twitter.serial.stream.Serial +import com.twitter.serial.stream.bytebuffer.ByteBufferSerial +import java.io.File + +typealias DomainEntitySerializer = Serializer + +internal const val STORAGE_DIR_NAME = "persistent_storage" + +/** + * Provides access to permanent file based storage. + * + * [serverId] is currently active server. Should be unique per server so stored data will not + * interfere with other server data. + * + * Look at [org.moire.ultrasonic.cache.serializers] package for available [DomainEntitySerializer]s. + */ +class PermanentFileStorage( + private val directories: Directories, + private val serverId: String, + private val debug: Boolean = false +) { + private val serializationContext = object : SerializationContext { + override fun isDebug(): Boolean = debug + override fun isRelease(): Boolean = !debug + } + + private val serializer: Serial = ByteBufferSerial(serializationContext) + + /** + * Stores given [objectToStore] using [name] as a key and [objectSerializer] as serializer. + */ + fun store( + name: String, + objectToStore: T, + objectSerializer: DomainEntitySerializer + ) { + val storeFile = getFile(name) + if (!storeFile.exists()) storeFile.createNewFile() + storeFile.writeBytes(serializer.toByteArray(objectToStore, objectSerializer)) + } + + /** + * Loads object with [name] key using [objectDeserializer] deserializer. + */ + fun load( + name: String, + objectDeserializer: DomainEntitySerializer + ): T? { + val storeFile = getFile(name) + if (!storeFile.exists()) return null + + return serializer.fromByteArray(storeFile.readBytes(), objectDeserializer) + } + + /** + * Clear all files in storage. + */ + fun clearAll() { + val storageDir = getStorageDir() + storageDir.listFiles().forEach { it.deleteRecursively() } + } + + private fun getFile(name: String) = File(getStorageDir(), "$name.ser") + + private fun getStorageDir(): File { + val mainDir = File(directories.getInternalDataDir(), STORAGE_DIR_NAME) + val serverDir = File(mainDir, serverId) + if (!serverDir.exists()) serverDir.mkdirs() + return serverDir + } +} diff --git a/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/ArtistSerializer.kt b/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/ArtistSerializer.kt new file mode 100644 index 00000000..751a97d5 --- /dev/null +++ b/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/ArtistSerializer.kt @@ -0,0 +1,65 @@ +@file:JvmMultifileClass +@file:JvmName("DomainSerializers") +package org.moire.ultrasonic.cache.serializers + +import com.twitter.serial.serializer.CollectionSerializers +import com.twitter.serial.serializer.ObjectSerializer +import com.twitter.serial.serializer.SerializationContext +import com.twitter.serial.stream.SerializerDefs +import com.twitter.serial.stream.SerializerInput +import com.twitter.serial.stream.SerializerOutput +import org.moire.ultrasonic.cache.DomainEntitySerializer +import org.moire.ultrasonic.domain.Artist + +private const val SERIALIZER_VERSION = 1 + +private val artistSerializer get() = object : ObjectSerializer(SERIALIZER_VERSION) { + override fun serializeObject( + context: SerializationContext, + output: SerializerOutput>, + item: Artist + ) { + output.writeString(item.id) + .writeString(item.name) + .writeString(item.index) + .writeString(item.coverArt) + .apply { + val albumCount = item.albumCount + if (albumCount != null) writeLong(albumCount) else writeNull() + } + .writeInt(item.closeness) + } + + override fun deserializeObject( + context: SerializationContext, + input: SerializerInput, + versionNumber: Int + ): Artist? { + if (versionNumber != SERIALIZER_VERSION) return null + + val id = input.readString() + val name = input.readString() + val index = input.readString() + val coverArt = input.readString() + val albumCount = if (input.peekType() == SerializerDefs.TYPE_NULL) { + input.readNull() + null + } else { + input.readLong() + } + val closeness = input.readInt() + return Artist(id, name, index, coverArt, albumCount, closeness) + } +} + +/** + * Serializer/deserializer for [Artist] domain entity. + */ +fun getArtistsSerializer(): DomainEntitySerializer = artistSerializer + +private val artistListSerializer = CollectionSerializers.getListSerializer(artistSerializer) + +/** + * Serializer/deserializer for list of [Artist] domain entities. + */ +fun getArtistListSerializer(): DomainEntitySerializer> = artistListSerializer diff --git a/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/IndexesSerializer.kt b/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/IndexesSerializer.kt new file mode 100644 index 00000000..c237be76 --- /dev/null +++ b/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/IndexesSerializer.kt @@ -0,0 +1,48 @@ +@file:JvmMultifileClass +@file:JvmName("DomainSerializers") +package org.moire.ultrasonic.cache.serializers + +import com.twitter.serial.serializer.ObjectSerializer +import com.twitter.serial.serializer.SerializationContext +import com.twitter.serial.stream.SerializerInput +import com.twitter.serial.stream.SerializerOutput +import org.moire.ultrasonic.cache.DomainEntitySerializer +import org.moire.ultrasonic.domain.Artist +import org.moire.ultrasonic.domain.Indexes + +private const val SERIALIZATION_VERSION = 1 + +private val indexesSerializer get() = object : ObjectSerializer(SERIALIZATION_VERSION) { + override fun serializeObject( + context: SerializationContext, + output: SerializerOutput>, + item: Indexes + ) { + val artistListSerializer = getArtistListSerializer() + output.writeLong(item.lastModified) + .writeString(item.ignoredArticles) + .writeObject>(context, item.shortcuts, artistListSerializer) + .writeObject>(context, item.artists, artistListSerializer) + } + + override fun deserializeObject( + context: SerializationContext, + input: SerializerInput, + versionNumber: Int + ): Indexes? { + if (versionNumber != SERIALIZATION_VERSION) return null + + val artistListDeserializer = getArtistListSerializer() + val lastModified = input.readLong() + val ignoredArticles = input.readString() ?: return null + val shortcutsList = input.readObject(context, artistListDeserializer) ?: return null + val artistsList = input.readObject(context, artistListDeserializer) ?: return null + return Indexes(lastModified, ignoredArticles, shortcutsList.toMutableList(), + artistsList.toMutableList()) + } +} + +/** + * Get serializer/deserializer for [Indexes] entity. + */ +fun getIndexesSerializer(): DomainEntitySerializer = indexesSerializer diff --git a/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializer.kt b/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializer.kt new file mode 100644 index 00000000..c65fabad --- /dev/null +++ b/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializer.kt @@ -0,0 +1,50 @@ +@file:JvmMultifileClass +@file:JvmName("DomainSerializers") +package org.moire.ultrasonic.cache.serializers + +import com.twitter.serial.serializer.CollectionSerializers +import com.twitter.serial.serializer.ObjectSerializer +import com.twitter.serial.serializer.SerializationContext +import com.twitter.serial.stream.SerializerInput +import com.twitter.serial.stream.SerializerOutput +import org.moire.ultrasonic.cache.DomainEntitySerializer +import org.moire.ultrasonic.domain.MusicFolder + +private const val SERIALIZATION_VERSION = 1 + +private val musicFolderSerializer = object : ObjectSerializer(SERIALIZATION_VERSION) { + + override fun serializeObject( + context: SerializationContext, + output: SerializerOutput>, + item: MusicFolder + ) { + output.writeString(item.id).writeString(item.name) + } + + override fun deserializeObject( + context: SerializationContext, + input: SerializerInput, + versionNumber: Int + ): MusicFolder? { + if (versionNumber != SERIALIZATION_VERSION) return null + + val id = input.readString() ?: return null + val name = input.readString() ?: return null + return MusicFolder(id, name) + } +} + +/** + * Serializer/deserializer for [MusicFolder] domain entity. + */ +fun getMusicFolderSerializer(): DomainEntitySerializer = musicFolderSerializer + +private val musicFolderListSerializer = + CollectionSerializers.getListSerializer(musicFolderSerializer) + +/** + * Serializer/deserializer for [List] of [MusicFolder] items. + */ +fun getMusicFolderListSerializer(): DomainEntitySerializer> = + musicFolderListSerializer diff --git a/cache/src/test/kotlin/org/moire/ultrasonic/cache/BaseStorageTest.kt b/cache/src/test/kotlin/org/moire/ultrasonic/cache/BaseStorageTest.kt new file mode 100644 index 00000000..bc06f483 --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/BaseStorageTest.kt @@ -0,0 +1,42 @@ +package org.moire.ultrasonic.cache + +import com.nhaarman.mockito_kotlin.mock +import com.twitter.serial.util.SerializationUtils +import org.amshove.kluent.`it returns` +import org.junit.Before +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File + +internal const val INTERNAL_DATA_FOLDER = "data" +internal const val INTERNAL_CACHE_FOLDER = "cache" +internal const val EXTERNAL_CACHE_FOLDER = "external_cache" + +/** + * Base test class that inits the storage + */ +abstract class BaseStorageTest { + @get:Rule val tempFileRule = TemporaryFolder() + + protected lateinit var mockDirectories: Directories + protected lateinit var storage: PermanentFileStorage + + open val serverId: String = "" + + @Before + fun setUp() { + mockDirectories = mock { + on { getInternalDataDir() } `it returns` tempFileRule.newFolder(INTERNAL_DATA_FOLDER) + on { getInternalCacheDir() } `it returns` tempFileRule.newFolder(INTERNAL_CACHE_FOLDER) + on { getExternalCacheDir() } `it returns` tempFileRule.newFolder(EXTERNAL_CACHE_FOLDER) + } + storage = PermanentFileStorage(mockDirectories, serverId, true) + } + + protected val storageDir get() = File(mockDirectories.getInternalDataDir(), STORAGE_DIR_NAME) + + protected fun validateSerializedData(index: Int = 0) { + val serializedFileBytes = storageDir.listFiles()[index].readBytes() + SerializationUtils.validateSerializedData(serializedFileBytes) + } +} diff --git a/cache/src/test/kotlin/org/moire/ultrasonic/cache/PermanentFileStorageTest.kt b/cache/src/test/kotlin/org/moire/ultrasonic/cache/PermanentFileStorageTest.kt new file mode 100644 index 00000000..bd84b528 --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/PermanentFileStorageTest.kt @@ -0,0 +1,81 @@ +package org.moire.ultrasonic.cache + +import org.amshove.kluent.`should contain` +import org.amshove.kluent.`should equal to` +import org.amshove.kluent.`should equal` +import org.junit.Test +import org.moire.ultrasonic.cache.serializers.getMusicFolderSerializer +import org.moire.ultrasonic.domain.MusicFolder +import java.io.File + +/** + * Integration test for [PermanentFileStorage]. + */ +class PermanentFileStorageTest : BaseStorageTest() { + override val serverId: String + get() = "some-server-id" + + @Test + fun `Should create storage dir if it is not exist`() { + val item = MusicFolder("1", "2") + storage.store("test", item, getMusicFolderSerializer()) + + storageDir.exists() `should equal to` true + getServerStorageDir().exists() `should equal to` true + } + + @Test + fun `Should serialize to file`() { + val item = MusicFolder("1", "23") + val name = "some-name" + + storage.store(name, item, getMusicFolderSerializer()) + + val storageFiles = getServerStorageDir().listFiles() + storageFiles.size `should equal to` 1 + storageFiles[0].name `should contain` name + } + + @Test + fun `Should deserialize stored object`() { + val item = MusicFolder("some", "nice") + val name = "some-name" + storage.store(name, item, getMusicFolderSerializer()) + + val loadedItem = storage.load(name, getMusicFolderSerializer()) + + loadedItem `should equal` item + } + + @Test + fun `Should overwrite existing stored object`() { + val name = "some-nice-name" + val item1 = MusicFolder("1", "1") + val item2 = MusicFolder("2", "2") + storage.store(name, item1, getMusicFolderSerializer()) + storage.store(name, item2, getMusicFolderSerializer()) + + val loadedItem = storage.load(name, getMusicFolderSerializer()) + + loadedItem `should equal` item2 + } + + @Test + fun `Should clear all files when clearAll is called`() { + storage.store("name1", MusicFolder("1", "1"), getMusicFolderSerializer()) + storage.store("name2", MusicFolder("2", "2"), getMusicFolderSerializer()) + + storage.clearAll() + + getServerStorageDir().listFiles().size `should equal to` 0 + } + + @Test + fun `Should return null if serialized file not available`() { + val loadedItem = storage.load("some-name", getMusicFolderSerializer()) + + loadedItem `should equal` null + } + + private fun getServerStorageDir() = File(storageDir, serverId) +} diff --git a/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/ArtistSerializerTest.kt b/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/ArtistSerializerTest.kt new file mode 100644 index 00000000..7829467b --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/ArtistSerializerTest.kt @@ -0,0 +1,57 @@ +package org.moire.ultrasonic.cache.serializers + +import org.amshove.kluent.`should equal` +import org.junit.Test +import org.moire.ultrasonic.cache.BaseStorageTest +import org.moire.ultrasonic.domain.Artist + +/** + * [Artist] serializers test. + */ +class ArtistSerializerTest : BaseStorageTest() { + @Test + fun `Should correctly serialize Artist object`() { + val item = Artist("id", "name", "index", "coverArt", 1, 0) + + storage.store("some-name", item, getArtistsSerializer()) + + validateSerializedData() + } + + @Test + fun `Should correctly deserialize Artist object`() { + val itemName = "some-name" + val item = Artist("id", "name", "index", "coverArt", null, 0) + storage.store(itemName, item, getArtistsSerializer()) + + val loadedItem = storage.load(itemName, getArtistsSerializer()) + + loadedItem `should equal` item + } + + @Test + fun `Should correctly serialize list of Artists`() { + val itemsList = listOf( + Artist(id = "1"), + Artist(id = "2", name = "some") + ) + + storage.store("some-name", itemsList, getArtistListSerializer()) + + validateSerializedData() + } + + @Test + fun `Should correctly deserialize list of Artists`() { + val name = "some-name" + val itemsList = listOf( + Artist(id = "1"), + Artist(id = "2", name = "some") + ) + storage.store(name, itemsList, getArtistListSerializer()) + + val loadedItems = storage.load(name, getArtistListSerializer()) + + loadedItems `should equal` itemsList + } +} diff --git a/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/IndexesSerializerTest.kt b/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/IndexesSerializerTest.kt new file mode 100644 index 00000000..9cffbd8b --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/IndexesSerializerTest.kt @@ -0,0 +1,40 @@ +package org.moire.ultrasonic.cache.serializers + +import org.amshove.kluent.`should equal` +import org.junit.Test +import org.moire.ultrasonic.cache.BaseStorageTest +import org.moire.ultrasonic.domain.Artist +import org.moire.ultrasonic.domain.Indexes + +/** + * Test [Indexes] domain entity serializer. + */ +class IndexesSerializerTest : BaseStorageTest() { + @Test + fun `Should correctly serialize Indexes object`() { + val item = Indexes(220L, "", mutableListOf( + Artist("12") + ), mutableListOf( + Artist("233", "some") + )) + + storage.store("some-name", item, getIndexesSerializer()) + + validateSerializedData() + } + + @Test + fun `Should correctly deserialize Indexes object`() { + val name = "some-name" + val item = Indexes(220L, "", mutableListOf( + Artist("12") + ), mutableListOf( + Artist("233", "some") + )) + storage.store(name, item, getIndexesSerializer()) + + val loadedItem = storage.load(name, getIndexesSerializer()) + + loadedItem `should equal` item + } +} diff --git a/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializerTest.kt b/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializerTest.kt new file mode 100644 index 00000000..0efe2811 --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializerTest.kt @@ -0,0 +1,57 @@ +package org.moire.ultrasonic.cache.serializers + +import org.amshove.kluent.`should equal` +import org.junit.Test +import org.moire.ultrasonic.cache.BaseStorageTest +import org.moire.ultrasonic.domain.MusicFolder + +/** + * [MusicFolder] serializers test. + */ +class MusicFolderSerializerTest : BaseStorageTest() { + @Test + fun `Should correctly serialize MusicFolder object`() { + val item = MusicFolder("Music", "Folder") + + storage.store("some-name", item, getMusicFolderSerializer()) + + validateSerializedData() + } + + @Test + fun `Should correctly deserialize MusicFolder object`() { + val name = "name" + val item = MusicFolder("some", "none") + storage.store(name, item, getMusicFolderSerializer()) + + val loadedItem = storage.load(name, getMusicFolderSerializer()) + + loadedItem `should equal` item + } + + @Test + fun `Should correctly serialize list of MusicFolders objects`() { + val itemsList = listOf( + MusicFolder("1", "1"), + MusicFolder("2", "2") + ) + + storage.store("some-name", itemsList, getMusicFolderListSerializer()) + + validateSerializedData() + } + + @Test + fun `Should correctly deserialize list of MusicFolder objects`() { + val name = "some-name" + val itemsList = listOf( + MusicFolder("1", "1"), + MusicFolder("2", "2") + ) + storage.store(name, itemsList, getMusicFolderListSerializer()) + + val loadedItem = storage.load(name, getMusicFolderListSerializer()) + + loadedItem `should equal` itemsList + } +} diff --git a/dependencies.gradle b/dependencies.gradle index 69ade4df..d0173c23 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -22,6 +22,7 @@ ext.versions = [ jackson : "2.9.0", okhttp : "3.9.0", semver : "1.0.0", + twitterSerial : "0.1.6", junit : "4.12", mockito : "2.12.0", @@ -52,6 +53,7 @@ ext.other = [ jacksonKotlin : "com.fasterxml.jackson.module:jackson-module-kotlin:$versions.jackson", okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp", semver : "net.swiftzer.semver:semver:$versions.semver", + twitterSerial : "com.twitter.serial:serial:$versions.twitterSerial", ] ext.testing = [ diff --git a/domain/build.gradle b/domain/build.gradle new file mode 100644 index 00000000..cab611de --- /dev/null +++ b/domain/build.gradle @@ -0,0 +1,38 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' +apply plugin: 'jacoco' +apply from: '../gradle_scripts/code_quality.gradle' + +dependencies { + api other.kotlinStdlib + api other.semver +} + +jacoco { + toolVersion(versions.jacoco) +} + +ext { + jacocoExclude = [] +} + +jacocoTestReport { + reports { + html.enabled true + csv.enabled false + xml.enabled true + } + + afterEvaluate { + classDirectories = files(classDirectories.files.collect { + fileTree(dir: it, excludes: jacocoExclude) + }) + } +} + +test.finalizedBy jacocoTestReport +test { + jacoco { + excludes += jacocoExclude + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Bookmark.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Bookmark.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Bookmark.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Bookmark.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/ChatMessage.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/ChatMessage.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/ChatMessage.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/ChatMessage.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Indexes.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Indexes.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Indexes.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Indexes.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/JukeboxStatus.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/JukeboxStatus.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/JukeboxStatus.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/JukeboxStatus.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Lyrics.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Lyrics.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Lyrics.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Lyrics.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/PlayerState.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/PlayerState.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/PlayerState.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/PlayerState.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/SearchCriteria.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchCriteria.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/SearchCriteria.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchCriteria.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt similarity index 59% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt index 60f9b1cf..738ccfc6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt +++ b/domain/src/main/kotlin/org/moire/ultrasonic/domain/SearchResult.kt @@ -1,10 +1,12 @@ package org.moire.ultrasonic.domain +import org.moire.ultrasonic.domain.MusicDirectory.Entry + /** * The result of a search. Contains matching artists, albums and songs. */ data class SearchResult( val artists: List, - val albums: List, - val songs: List + val albums: List, + val songs: List ) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/UserInfo.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/UserInfo.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/UserInfo.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/UserInfo.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Version.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/Version.kt similarity index 100% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/domain/Version.kt rename to domain/src/main/kotlin/org/moire/ultrasonic/domain/Version.kt diff --git a/gradle_scripts/jacoco.gradle b/gradle_scripts/jacoco.gradle index 67a75609..5be89860 100644 --- a/gradle_scripts/jacoco.gradle +++ b/gradle_scripts/jacoco.gradle @@ -6,9 +6,11 @@ task jacocoMergeReports(type: JacocoMerge) { def subsonicApi = project.findProject("subsonic-api") def ultrasonicApp = project.findProject("ultrasonic") + def cache = project.findProject("cache") executionData( "${subsonicApi.buildDir}/jacoco/test.exec", "${ultrasonicApp.buildDir}/jacoco/testDebugUnitTest.exec", + "${cache.buildDir}/jacoco/test.exec" ) destinationFile(file("${project.buildDir}/jacoco/jacoco.exec")) } @@ -20,6 +22,7 @@ def createJacocoFullReportTask() { def subsonicApi = project.findProject("subsonic-api") def ultrasonicApp = project.findProject("ultrasonic") + def cache = project.findProject("cache") classDirectories = files( fileTree( @@ -29,6 +32,10 @@ def createJacocoFullReportTask() { fileTree( dir: "${ultrasonicApp.buildDir}/intermediates/classes/debug/org", excludes: ultrasonicApp.jacocoExclude + ), + fileTree( + dir: "${cache.buildDir}/classes/kotlin/main", + excludes: cache.jacocoExclude ) ) sourceDirectories = files(subsonicApi.sourceSets.main.getAllSource(), diff --git a/settings.gradle b/settings.gradle index 7601205f..256032c5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,7 @@ -include ':library', ':subsonic-api' +include ':library' +include ':domain' +include ':subsonic-api' +include ':cache' include ':menudrawer' include ':pulltorefresh' include ':ultrasonic' diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index e4050434..1a0b385c 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -47,15 +47,15 @@ dependencies { implementation project(':menudrawer') implementation project(':pulltorefresh') implementation project(':library') + implementation project(':domain') implementation project(':subsonic-api') + implementation project(':cache') implementation androidSupport.support implementation androidSupport.design implementation other.kotlinStdlib - implementation other.semver - testImplementation other.kotlinReflect testImplementation testing.junit testImplementation testing.kotlinJunit diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java index 85456be2..536e6fc4 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ServerSettingsFragment.java @@ -12,7 +12,9 @@ import android.support.annotation.Nullable; import android.util.Log; import android.view.View; +import org.moire.ultrasonic.BuildConfig; import org.moire.ultrasonic.R; +import org.moire.ultrasonic.cache.PermanentFileStorage; import org.moire.ultrasonic.service.MusicService; import org.moire.ultrasonic.service.MusicServiceFactory; import org.moire.ultrasonic.util.Constants; @@ -279,6 +281,15 @@ public class ServerSettingsFragment extends PreferenceFragment int activeServers = sharedPreferences .getInt(Constants.PREFERENCES_KEY_ACTIVE_SERVERS, 0); + // Clear permanent storage + final String storageServerId = MusicServiceFactory.getServerId(sharedPreferences, serverId); + final PermanentFileStorage fileStorage = new PermanentFileStorage( + MusicServiceFactory.getDirectories(getActivity()), + storageServerId, + BuildConfig.DEBUG + ); + fileStorage.clearAll(); + // Reset values to null so when we ask for them again they are new sharedPreferences.edit() .remove(Constants.PREFERENCES_KEY_SERVER_NAME + serverId) diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicServiceFactory.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicServiceFactory.java index 4d2b3d4b..eb859aa5 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicServiceFactory.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicServiceFactory.java @@ -22,12 +22,17 @@ import android.content.Context; import android.content.SharedPreferences; import android.util.Log; +import org.jetbrains.annotations.NotNull; import org.moire.ultrasonic.BuildConfig; import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient; import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions; +import org.moire.ultrasonic.cache.Directories; +import org.moire.ultrasonic.cache.PermanentFileStorage; import org.moire.ultrasonic.util.Constants; import org.moire.ultrasonic.util.Util; +import java.io.File; + /** * @author Sindre Mehus * @version $Id$ @@ -44,7 +49,9 @@ public class MusicServiceFactory { synchronized (MusicServiceFactory.class) { if (OFFLINE_MUSIC_SERVICE == null) { Log.d(LOG_TAG, "Creating new offline music service"); - OFFLINE_MUSIC_SERVICE = new OfflineMusicService(createSubsonicApiClient(context)); + OFFLINE_MUSIC_SERVICE = new OfflineMusicService( + createSubsonicApiClient(context), + getPermanentFileStorage(context)); } } } @@ -57,7 +64,8 @@ public class MusicServiceFactory { if (REST_MUSIC_SERVICE == null) { Log.d(LOG_TAG, "Creating new rest music service"); REST_MUSIC_SERVICE = new CachedMusicService(new RESTMusicService( - createSubsonicApiClient(context))); + createSubsonicApiClient(context), + getPermanentFileStorage(context))); } } } @@ -104,4 +112,39 @@ public class MusicServiceFactory { Constants.REST_CLIENT_ID, allowSelfSignedCertificate, enableLdapUserSupport, BuildConfig.DEBUG); } + + private static PermanentFileStorage getPermanentFileStorage(final Context context) { + final SharedPreferences preferences = Util.getPreferences(context); + int instance = preferences.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + final String serverId = getServerId(preferences, instance); + + return new PermanentFileStorage(getDirectories(context), serverId, BuildConfig.DEBUG); + } + + public static String getServerId(final SharedPreferences sp, final int instance) { + String serverUrl = sp.getString( + Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + return String.valueOf(Math.abs((serverUrl + instance).hashCode())); + } + + public static Directories getDirectories(final Context context) { + return new Directories() { + @NotNull + @Override + public File getInternalCacheDir() { + return context.getCacheDir(); + } + + @NotNull + @Override + public File getInternalDataDir() { + return context.getFilesDir(); + } + + @Override + public File getExternalCacheDir() { + return context.getExternalCacheDir(); + } + }; + } } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/OfflineMusicService.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/OfflineMusicService.java index b3dac806..8a93dec0 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/OfflineMusicService.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/OfflineMusicService.java @@ -24,6 +24,7 @@ import android.media.MediaMetadataRetriever; import android.util.Log; import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient; +import org.moire.ultrasonic.cache.PermanentFileStorage; import org.moire.ultrasonic.domain.Artist; import org.moire.ultrasonic.domain.Genre; import org.moire.ultrasonic.domain.Indexes; @@ -67,8 +68,8 @@ public class OfflineMusicService extends RESTMusicService private static final String TAG = OfflineMusicService.class.getSimpleName(); private static final Pattern COMPILE = Pattern.compile(" "); - public OfflineMusicService(SubsonicAPIClient subsonicAPIClient) { - super(subsonicAPIClient); + public OfflineMusicService(SubsonicAPIClient subsonicAPIClient, PermanentFileStorage storage) { + super(subsonicAPIClient, storage); } @Override diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java index a4129f52..74e146d7 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java @@ -61,6 +61,8 @@ import org.moire.ultrasonic.api.subsonic.response.SharesResponse; import org.moire.ultrasonic.api.subsonic.response.StreamResponse; import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse; import org.moire.ultrasonic.api.subsonic.response.VideosResponse; +import org.moire.ultrasonic.cache.PermanentFileStorage; +import org.moire.ultrasonic.cache.serializers.DomainSerializers; import org.moire.ultrasonic.domain.APIAlbumConverter; import org.moire.ultrasonic.domain.APIArtistConverter; import org.moire.ultrasonic.domain.APIBookmarkConverter; @@ -104,7 +106,6 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -117,10 +118,19 @@ import retrofit2.Response; public class RESTMusicService implements MusicService { private static final String TAG = RESTMusicService.class.getSimpleName(); - private final SubsonicAPIClient subsonicAPIClient; + private static final String MUSIC_FOLDER_STORAGE_NAME = "music_folder"; + private static final String INDEXES_STORAGE_NAME = "indexes"; + private static final String ARTISTS_STORAGE_NAME = "artists"; - public RESTMusicService(SubsonicAPIClient subsonicAPIClient) { + private final SubsonicAPIClient subsonicAPIClient; + private final PermanentFileStorage fileStorage; + + public RESTMusicService( + final SubsonicAPIClient subsonicAPIClient, + final PermanentFileStorage fileStorage + ) { this.subsonicAPIClient = subsonicAPIClient; + this.fileStorage = fileStorage; } @Override @@ -146,7 +156,8 @@ public class RESTMusicService implements MusicService { public List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception { - List cachedMusicFolders = readCachedMusicFolders(context); + List cachedMusicFolders = fileStorage.load(MUSIC_FOLDER_STORAGE_NAME, + DomainSerializers.getMusicFolderListSerializer()); if (cachedMusicFolders != null && !refresh) { return cachedMusicFolders; } @@ -157,31 +168,18 @@ public class RESTMusicService implements MusicService { List musicFolders = APIMusicFolderConverter .toDomainEntityList(response.body().getMusicFolders()); - writeCachedMusicFolders(context, musicFolders); + fileStorage.store(MUSIC_FOLDER_STORAGE_NAME, musicFolders, + DomainSerializers.getMusicFolderListSerializer()); return musicFolders; } - private static List readCachedMusicFolders(Context context) { - String filename = getCachedMusicFoldersFilename(context); - return FileUtil.deserialize(context, filename); - } - - private static void writeCachedMusicFolders(Context context, List musicFolders) { - String filename = getCachedMusicFoldersFilename(context); - FileUtil.serialize(context, new ArrayList<>(musicFolders), filename); - } - - private static String getCachedMusicFoldersFilename(Context context) { - String s = Util.getRestUrl(context, null); - return String.format(Locale.US, "musicFolders-%d.ser", Math.abs(s.hashCode())); - } - @Override public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception { - Indexes cachedIndexes = readCachedIndexes(context, musicFolderId); + Indexes cachedIndexes = fileStorage.load(INDEXES_STORAGE_NAME, + DomainSerializers.getIndexesSerializer()); if (cachedIndexes != null && !refresh) { return cachedIndexes; } @@ -192,59 +190,30 @@ public class RESTMusicService implements MusicService { checkResponseSuccessful(response); Indexes indexes = APIIndexesConverter.toDomainEntity(response.body().getIndexes()); - writeCachedIndexes(context, indexes, musicFolderId); + fileStorage.store(INDEXES_STORAGE_NAME, indexes, DomainSerializers.getIndexesSerializer()); return indexes; } - private static Indexes readCachedIndexes(Context context, String musicFolderId) { - String filename = getCachedIndexesFilename(context, musicFolderId); - return FileUtil.deserialize(context, filename); - } - - private static void writeCachedIndexes(Context context, Indexes indexes, String musicFolderId) { - String filename = getCachedIndexesFilename(context, musicFolderId); - FileUtil.serialize(context, indexes, filename); - } - - private static String getCachedIndexesFilename(Context context, String musicFolderId) { - String s = Util.getRestUrl(context, null) + musicFolderId; - return String.format(Locale.US, "indexes-%d.ser", Math.abs(s.hashCode())); - } - @Override public Indexes getArtists(boolean refresh, Context context, ProgressListener progressListener) throws Exception { - Indexes cachedArtists = readCachedArtists(context); - if (cachedArtists != null && - !refresh) { + Indexes cachedArtists = fileStorage + .load(ARTISTS_STORAGE_NAME, DomainSerializers.getIndexesSerializer()); + if (cachedArtists != null && !refresh) { return cachedArtists; } updateProgressListener(progressListener, R.string.parser_reading); - Response response = subsonicAPIClient.getApi().getArtists(null).execute(); + Response response = subsonicAPIClient.getApi() + .getArtists(null).execute(); checkResponseSuccessful(response); Indexes indexes = APIIndexesConverter.toDomainEntity(response.body().getIndexes()); - writeCachedArtists(context, indexes); + fileStorage.store(ARTISTS_STORAGE_NAME, indexes, DomainSerializers.getIndexesSerializer()); return indexes; } - private static Indexes readCachedArtists(Context context) { - String filename = getCachedArtistsFilename(context); - return FileUtil.deserialize(context, filename); - } - - private static void writeCachedArtists(Context context, Indexes artists) { - String filename = getCachedArtistsFilename(context); - FileUtil.serialize(context, artists, filename); - } - - private static String getCachedArtistsFilename(Context context) { - String s = Util.getRestUrl(context, null); - return String.format(Locale.US, "indexes-%d.ser", Math.abs(s.hashCode())); - } - @Override public void star(String id, String albumId, diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/FileUtil.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/FileUtil.java index c306cbb7..fdcf9abd 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/FileUtil.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/FileUtil.java @@ -26,7 +26,6 @@ import android.text.TextUtils; import android.util.Log; import org.moire.ultrasonic.activity.SubsonicTabActivity; -import org.moire.ultrasonic.domain.Artist; import org.moire.ultrasonic.domain.MusicDirectory; import java.io.File; @@ -300,11 +299,6 @@ public class FileUtil return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opt); } - public static File getArtistDirectory(Context context, Artist artist) - { - return new File(String.format("%s/%s", getMusicDirectory(context).getPath(), fileSystemSafe(artist.getName()))); - } - public static File getAlbumArtDirectory() { File albumArtDir = new File(getUltraSonicDirectory(), "artwork");