diff --git a/cache/build.gradle b/cache/build.gradle index 336add8d..57cecf59 100644 --- a/cache/build.gradle +++ b/cache/build.gradle @@ -3,11 +3,17 @@ apply plugin: 'kotlin' apply plugin: 'jacoco' apply from: '../gradle_scripts/code_quality.gradle' -sourceSets { - main.java.srcDirs += "${projectDir}/src/main/kotlin" -} 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 { 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..243077f1 --- /dev/null +++ b/cache/src/main/kotlin/org/moire/ultrasonic/cache/PermanentFileStorage.kt @@ -0,0 +1,68 @@ +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 + +internal const val STORAGE_DIR_NAME = "persistent_storage" + +/** + * Provides access to permanent file based storage. + * + * Look at [org.moire.ultrasonic.cache.serializers] package for available [Serializer]s. + */ +class PermanentFileStorage( + private val directories: Directories, + 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: Serializer + ) { + 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: Serializer + ): 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() + if (storageDir.exists()) { + storageDir.listFiles().forEach { if (it.isFile) it.delete() } + } + } + + private fun getFile(name: String) = File(getStorageDir(), "$name.ser") + + private fun getStorageDir() = File(directories.getInternalDataDir(), STORAGE_DIR_NAME).apply { + if (!exists()) mkdirs() + } +} 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..655c3fd4 --- /dev/null +++ b/cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializer.kt @@ -0,0 +1,43 @@ +@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.domain.MusicFolder + +private const val SERIALIZATION_VERSION = 1 + +/** + * Serializer/deserializer for [MusicFolder] domain entity. + */ +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 [List] of [MusicFolder] items. + */ +val musicFolderListSerializer = CollectionSerializers.getListSerializer(musicFolderSerializer) 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..5a6d127b --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/BaseStorageTest.kt @@ -0,0 +1,34 @@ +package org.moire.ultrasonic.cache + +import com.nhaarman.mockito_kotlin.mock +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 + + @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, true) + } + + protected val storageDir get() = File(mockDirectories.getInternalDataDir(), STORAGE_DIR_NAME) +} 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..856e6590 --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/PermanentFileStorageTest.kt @@ -0,0 +1,67 @@ +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.musicFolderSerializer +import org.moire.ultrasonic.domain.MusicFolder + +/** + * Integration test for [PermanentFileStorage]. + */ +class PermanentFileStorageTest : BaseStorageTest() { + @Test + fun `Should create storage dir if it is not exist`() { + val item = MusicFolder("1", "2") + storage.store("test", item, musicFolderSerializer) + + storageDir.exists() `should equal to` true + } + + @Test + fun `Should serialize to file`() { + val item = MusicFolder("1", "23") + val name = "some-name" + + storage.store(name, item, musicFolderSerializer) + + val storageFiles = storageDir.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, musicFolderSerializer) + + val loadedItem = storage.load(name, musicFolderSerializer) + + 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, musicFolderSerializer) + storage.store(name, item2, musicFolderSerializer) + + val loadedItem = storage.load(name, musicFolderSerializer) + + loadedItem `should equal` item2 + } + + @Test + fun `Should clear all files when clearAll is called`() { + storage.store("name1", MusicFolder("1", "1"), musicFolderSerializer) + storage.store("name2", MusicFolder("2", "2"), musicFolderSerializer) + + storage.clearAll() + + storageDir.listFiles().size `should equal to` 0 + } +} 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..0fedccf1 --- /dev/null +++ b/cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializerTest.kt @@ -0,0 +1,60 @@ +package org.moire.ultrasonic.cache.serializers + +import com.twitter.serial.util.SerializationUtils +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, musicFolderSerializer) + + val serializedFileBytes = storageDir.listFiles()[0].readBytes() + SerializationUtils.validateSerializedData(serializedFileBytes) + } + + @Test + fun `Should correctly deserialize MusicFolder object`() { + val name = "name" + val item = MusicFolder("some", "none") + storage.store(name, item, musicFolderSerializer) + + val loadedItem = storage.load(name, musicFolderSerializer) + + 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, musicFolderListSerializer) + + val serializedFileBytes = storageDir.listFiles()[0].readBytes() + SerializationUtils.validateSerializedData(serializedFileBytes) + } + + @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, musicFolderListSerializer) + + val loadedItem = storage.load(name, musicFolderListSerializer) + + loadedItem `should equal` itemsList + } +} diff --git a/dependencies.gradle b/dependencies.gradle index 69ade4df..e32b7801 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.5", 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/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt b/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt index a7e91d27..85661482 100644 --- a/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt +++ b/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt @@ -6,4 +6,6 @@ package org.moire.ultrasonic.domain data class MusicFolder( val id: String, val name: String -) +) { + companion object +} 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..c9bbea1d 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,29 @@ public class MusicServiceFactory { Constants.REST_CLIENT_ID, allowSelfSignedCertificate, enableLdapUserSupport, BuildConfig.DEBUG); } + + private static PermanentFileStorage getPermanentFileStorage(final Context context) { + return new PermanentFileStorage(getDirectories(context), BuildConfig.DEBUG); + } + + private 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..61d50c89 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; @@ -117,10 +119,17 @@ 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"; - 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 +155,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,25 +167,11 @@ 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,