Add permanent file storage.
It serialize domain objects to byte array and store it to file. For now it only uses for MusicFolder entity store. Signed-off-by: Yahor Berdnikau <egorr.berd@gmail.com>
This commit is contained in:
parent
ad52e3ad95
commit
c49e447240
|
@ -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 {
|
||||
|
|
|
@ -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?
|
||||
}
|
|
@ -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 <T> store(
|
||||
name: String,
|
||||
objectToStore: T,
|
||||
objectSerializer: Serializer<T>
|
||||
) {
|
||||
val storeFile = getFile(name)
|
||||
if (!storeFile.exists()) storeFile.createNewFile()
|
||||
storeFile.writeBytes(serializer.toByteArray(objectToStore, objectSerializer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads object with [name] key using [objectDeserializer] deserializer.
|
||||
*/
|
||||
fun <T> load(
|
||||
name: String,
|
||||
objectDeserializer: Serializer<T>
|
||||
): 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()
|
||||
}
|
||||
}
|
43
cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializer.kt
vendored
Normal file
43
cache/src/main/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializer.kt
vendored
Normal file
|
@ -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<MusicFolder>(SERIALIZATION_VERSION) {
|
||||
|
||||
override fun serializeObject(
|
||||
context: SerializationContext,
|
||||
output: SerializerOutput<out 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)
|
|
@ -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<Directories> {
|
||||
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)
|
||||
}
|
67
cache/src/test/kotlin/org/moire/ultrasonic/cache/PermanentFileStorageTest.kt
vendored
Normal file
67
cache/src/test/kotlin/org/moire/ultrasonic/cache/PermanentFileStorageTest.kt
vendored
Normal file
|
@ -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
|
||||
}
|
||||
}
|
60
cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializerTest.kt
vendored
Normal file
60
cache/src/test/kotlin/org/moire/ultrasonic/cache/serializers/MusicFolderSerializerTest.kt
vendored
Normal file
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 = [
|
||||
|
|
|
@ -6,4 +6,6 @@ package org.moire.ultrasonic.domain
|
|||
data class MusicFolder(
|
||||
val id: String,
|
||||
val name: String
|
||||
)
|
||||
) {
|
||||
companion object
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<MusicFolder> getMusicFolders(boolean refresh,
|
||||
Context context,
|
||||
ProgressListener progressListener) throws Exception {
|
||||
List<MusicFolder> cachedMusicFolders = readCachedMusicFolders(context);
|
||||
List<MusicFolder> 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<MusicFolder> musicFolders = APIMusicFolderConverter
|
||||
.toDomainEntityList(response.body().getMusicFolders());
|
||||
writeCachedMusicFolders(context, musicFolders);
|
||||
fileStorage.store(MUSIC_FOLDER_STORAGE_NAME, musicFolders,
|
||||
DomainSerializers.getMusicFolderListSerializer());
|
||||
return musicFolders;
|
||||
}
|
||||
|
||||
private static List<MusicFolder> readCachedMusicFolders(Context context) {
|
||||
String filename = getCachedMusicFoldersFilename(context);
|
||||
return FileUtil.deserialize(context, filename);
|
||||
}
|
||||
|
||||
private static void writeCachedMusicFolders(Context context, List<MusicFolder> 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,
|
||||
|
|
Loading…
Reference in New Issue