This commit is contained in:
grakovne
2025-11-26 21:45:13 +01:00
parent fbac1ecfc7
commit a9cc424c56
3 changed files with 681 additions and 604 deletions

View File

@@ -21,6 +21,7 @@ import org.grakovne.lissen.common.moshi
import org.grakovne.lissen.lib.domain.DetailedItem
import org.grakovne.lissen.lib.domain.DownloadOption
import org.grakovne.lissen.lib.domain.Library
import org.grakovne.lissen.lib.domain.LibraryOffset
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.lib.domain.SeekTime
import org.grakovne.lissen.lib.domain.connection.LocalUrl
@@ -38,481 +39,511 @@ import javax.inject.Singleton
@Singleton
class LissenSharedPreferences
@Inject
constructor(
@ApplicationContext context: Context,
) {
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE)
fun hasCredentials(): Boolean {
val host = getHost()
val username = getUsername()
val hasToken = getToken() != null || getAccessToken() != null
return try {
host != null && username != null && hasToken
} catch (ex: Exception) {
false
}
@Inject
constructor(
@ApplicationContext context: Context,
) {
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE)
fun hasCredentials(): Boolean {
val host = getHost()
val username = getUsername()
val hasToken = getToken() != null || getAccessToken() != null
return try {
host != null && username != null && hasToken
} catch (ex: Exception) {
false
}
fun clearCredentials() {
sharedPreferences.edit {
remove(KEY_TOKEN)
remove(KEY_ACCESS_TOKEN)
remove(KEY_REFRESH_TOKEN)
}
}
fun clearCredentials() {
sharedPreferences.edit {
remove(KEY_TOKEN)
remove(KEY_ACCESS_TOKEN)
remove(KEY_REFRESH_TOKEN)
}
fun clearPreferences() {
sharedPreferences.edit {
remove(KEY_HOST)
remove(KEY_USERNAME)
remove(KEY_TOKEN)
remove(KEY_ACCESS_TOKEN)
remove(KEY_REFRESH_TOKEN)
remove(KEY_SERVER_VERSION)
remove(CACHE_FORCE_ENABLED)
remove(KEY_PREFERRED_LIBRARY_ID)
remove(KEY_PREFERRED_LIBRARY_NAME)
remove(KEY_PREFERRED_LIBRARY_TYPE)
remove(KEY_CUSTOM_HEADERS)
remove(KEY_BYPASS_SSL)
remove(KEY_LOCAL_URLS)
remove(KEY_PLAYING_BOOK)
}
}
fun clearPreferences() {
sharedPreferences.edit {
remove(KEY_HOST)
remove(KEY_USERNAME)
remove(KEY_TOKEN)
remove(KEY_ACCESS_TOKEN)
remove(KEY_REFRESH_TOKEN)
remove(KEY_SERVER_VERSION)
remove(CACHE_FORCE_ENABLED)
remove(KEY_PREFERRED_LIBRARY_ID)
remove(KEY_PREFERRED_LIBRARY_NAME)
remove(KEY_PREFERRED_LIBRARY_TYPE)
remove(KEY_CUSTOM_HEADERS)
remove(KEY_BYPASS_SSL)
remove(KEY_LOCAL_URLS)
remove(KEY_PLAYING_BOOK)
}
fun getAutoDownloadDelayed() = sharedPreferences.getBoolean(KEY_AUTO_DOWNLOAD_DELAYED, false)
fun saveAutoDownloadDelayed(enabled: Boolean) {
sharedPreferences.edit {
putBoolean(KEY_AUTO_DOWNLOAD_DELAYED, enabled)
}
}
fun getAutoDownloadDelayed() = sharedPreferences.getBoolean(KEY_AUTO_DOWNLOAD_DELAYED, false)
fun saveAutoDownloadDelayed(enabled: Boolean) {
sharedPreferences.edit {
putBoolean(KEY_AUTO_DOWNLOAD_DELAYED, enabled)
}
fun getAcraEnabled() = sharedPreferences.getBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, true)
fun saveAcraEnabled(enabled: Boolean) {
sharedPreferences.edit {
putBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, enabled)
}
}
fun getAcraEnabled() = sharedPreferences.getBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, true)
fun saveAcraEnabled(enabled: Boolean) {
sharedPreferences.edit {
putBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, enabled)
}
fun getSslBypass() = sharedPreferences.getBoolean(KEY_BYPASS_SSL, false)
fun saveSslBypass(enabled: Boolean) {
sharedPreferences.edit {
putBoolean(KEY_BYPASS_SSL, enabled)
}
}
fun getSslBypass() = sharedPreferences.getBoolean(KEY_BYPASS_SSL, false)
fun saveSslBypass(enabled: Boolean) {
sharedPreferences.edit {
putBoolean(KEY_BYPASS_SSL, enabled)
}
fun saveHost(host: String) = sharedPreferences.edit { putString(KEY_HOST, host) }
fun getHost(): String? = sharedPreferences.getString(KEY_HOST, null)
fun getDeviceId(): String {
val existingDeviceId = sharedPreferences.getString(KEY_DEVICE_ID, null)
if (existingDeviceId != null) {
return existingDeviceId
}
return UUID
.randomUUID()
.toString()
.also { sharedPreferences.edit { putString(KEY_DEVICE_ID, it) } }
}
fun saveHost(host: String) = sharedPreferences.edit { putString(KEY_HOST, host) }
fun getHost(): String? = sharedPreferences.getString(KEY_HOST, null)
fun getDeviceId(): String {
val existingDeviceId = sharedPreferences.getString(KEY_DEVICE_ID, null)
if (existingDeviceId != null) {
return existingDeviceId
}
// Once the different channel will supported, this shall be extended
fun getChannel() = ChannelCode.AUDIOBOOKSHELF
fun getPreferredLibrary(): Library? {
val id = getPreferredLibraryId() ?: return null
val name = getPreferredLibraryName() ?: return null
val type = getPreferredLibraryType()
return Library(
id = id,
title = name,
type = type,
)
return UUID
.randomUUID()
.toString()
.also { sharedPreferences.edit { putString(KEY_DEVICE_ID, it) } }
}
// Once the different channel will supported, this shall be extended
fun getChannel() = ChannelCode.AUDIOBOOKSHELF
fun getPreferredLibrary(): Library? {
val id = getPreferredLibraryId() ?: return null
val name = getPreferredLibraryName() ?: return null
val type = getPreferredLibraryType()
return Library(
id = id,
title = name,
type = type,
)
}
fun savePreferredLibrary(library: Library) {
saveActiveLibraryId(library.id)
saveActiveLibraryName(library.title)
saveActiveLibraryType(library.type)
}
fun saveLibraryOrdering(configuration: LibraryOrderingConfiguration) {
val adapter = moshi.adapter(LibraryOrderingConfiguration::class.java)
val json = adapter.toJson(configuration)
sharedPreferences.edit {
putString(KEY_PREFERRED_LIBRARY_ORDERING, json)
}
fun savePreferredLibrary(library: Library) {
saveActiveLibraryId(library.id)
saveActiveLibraryName(library.title)
saveActiveLibraryType(library.type)
}
fun saveLibraryOrdering(configuration: LibraryOrderingConfiguration) {
val adapter = moshi.adapter(LibraryOrderingConfiguration::class.java)
val json = adapter.toJson(configuration)
sharedPreferences.edit {
putString(KEY_PREFERRED_LIBRARY_ORDERING, json)
}
}
fun getLibraryOrdering(): LibraryOrderingConfiguration {
val json = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ORDERING, null)
return when (json) {
null -> LibraryOrderingConfiguration.default
else -> {
val adapter = moshi.adapter(LibraryOrderingConfiguration::class.java)
adapter.fromJson(json) ?: LibraryOrderingConfiguration.default
}
}
}
fun savePlaybackVolumeBoost(playbackVolumeBoost: PlaybackVolumeBoost) =
sharedPreferences.edit {
putString(KEY_VOLUME_BOOST, playbackVolumeBoost.name)
}
fun getPlaybackVolumeBoost(): PlaybackVolumeBoost =
sharedPreferences
.getString(KEY_VOLUME_BOOST, PlaybackVolumeBoost.DISABLED.name)
?.let { PlaybackVolumeBoost.valueOf(it) }
?: PlaybackVolumeBoost.DISABLED
fun saveAutoDownloadNetworkType(networkTypeAutoCache: NetworkTypeAutoCache) =
sharedPreferences.edit {
putString(KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE, networkTypeAutoCache.name)
}
fun getAutoDownloadNetworkType(): NetworkTypeAutoCache =
sharedPreferences
.getString(KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE, NetworkTypeAutoCache.WIFI_ONLY.name)
?.let { NetworkTypeAutoCache.valueOf(it) }
?: NetworkTypeAutoCache.WIFI_ONLY
fun saveAutoDownloadLibraryTypes(types: List<LibraryType>) {
val type = Types.newParameterizedType(List::class.java, LibraryType::class.java)
val adapter = moshi.adapter<List<LibraryType>>(type)
val json = adapter.toJson(types)
sharedPreferences.edit {
putString(KEY_PREFERRED_AUTO_DOWNLOAD_LIBRARY_TYPE, json)
}
}
fun getAutoDownloadLibraryTypes(): List<LibraryType> {
val json = sharedPreferences.getString(KEY_PREFERRED_AUTO_DOWNLOAD_LIBRARY_TYPE, null)
return when (json) {
null -> LibraryType.meaningfulTypes
else -> {
val type = Types.newParameterizedType(List::class.java, LibraryType::class.java)
val adapter = moshi.adapter<List<LibraryType>>(type)
adapter.fromJson(json) ?: LibraryType.meaningfulTypes
}
}
}
fun saveColorScheme(colorScheme: ColorScheme) =
sharedPreferences.edit {
putString(KEY_PREFERRED_COLOR_SCHEME, colorScheme.name)
}
fun getColorScheme(): ColorScheme =
sharedPreferences
.getString(KEY_PREFERRED_COLOR_SCHEME, ColorScheme.FOLLOW_SYSTEM.name)
?.let { ColorScheme.valueOf(it) }
?: ColorScheme.FOLLOW_SYSTEM
fun saveAutoDownloadOption(option: DownloadOption?) =
sharedPreferences.edit {
putString(KEY_PREFERRED_AUTO_DOWNLOAD, option?.makeId())
}
fun getAutoDownloadOption(): DownloadOption? =
sharedPreferences
.getString(KEY_PREFERRED_AUTO_DOWNLOAD, null)
?.makeDownloadOption()
fun savePlaybackSpeed(factor: Float) = sharedPreferences.edit { putFloat(KEY_PREFERRED_PLAYBACK_SPEED, factor) }
fun getPlaybackSpeed(): Float = sharedPreferences.getFloat(KEY_PREFERRED_PLAYBACK_SPEED, 1f)
val playingBookFlow: Flow<DetailedItem?> =
callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == KEY_PLAYING_BOOK) {
trySend(getPlayingBook())
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
trySend(getPlayingBook())
awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}.distinctUntilChanged()
val playbackVolumeBoostFlow: Flow<PlaybackVolumeBoost> =
callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == KEY_VOLUME_BOOST) {
trySend(getPlaybackVolumeBoost())
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
trySend(getPlaybackVolumeBoost())
awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}.distinctUntilChanged()
val colorSchemeFlow: Flow<ColorScheme> =
callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == KEY_PREFERRED_COLOR_SCHEME) {
trySend(getColorScheme())
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
trySend(getColorScheme())
awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}.distinctUntilChanged()
private fun saveActiveLibraryId(host: String) = sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_ID, host) }
private fun getPreferredLibraryId(): String? = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ID, null)
private fun saveActiveLibraryName(host: String) = sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_NAME, host) }
private fun getPreferredLibraryType(): LibraryType =
sharedPreferences
.getString(KEY_PREFERRED_LIBRARY_TYPE, null)
?.let { LibraryType.valueOf(it) }
?: LibraryType.LIBRARY
private fun saveActiveLibraryType(type: LibraryType) =
sharedPreferences.edit {
putString(KEY_PREFERRED_LIBRARY_TYPE, type.name)
}
private fun getPreferredLibraryName(): String? = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_NAME, null)
fun enableForceCache() = sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, true) }
fun disableForceCache() = sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, false) }
fun isForceCache(): Boolean = sharedPreferences.getBoolean(CACHE_FORCE_ENABLED, false)
fun saveUsername(username: String) = sharedPreferences.edit { putString(KEY_USERNAME, username) }
fun getUsername(): String? = sharedPreferences.getString(KEY_USERNAME, null)
fun saveServerVersion(version: String) = sharedPreferences.edit { putString(KEY_SERVER_VERSION, version) }
fun getServerVersion(): String? = sharedPreferences.getString(KEY_SERVER_VERSION, null)
fun saveToken(token: String) {
val encrypted = encrypt(token)
sharedPreferences.edit { putString(KEY_TOKEN, encrypted) }
}
fun saveAccessToken(accessToken: String) {
val encrypted = encrypt(accessToken)
sharedPreferences.edit { putString(KEY_ACCESS_TOKEN, encrypted) }
}
fun saveRefreshToken(refreshToken: String) {
val encrypted = encrypt(refreshToken)
sharedPreferences.edit { putString(KEY_REFRESH_TOKEN, encrypted) }
}
fun getAccessToken(): String? {
val encrypted = sharedPreferences.getString(KEY_ACCESS_TOKEN, null) ?: return null
return decrypt(encrypted)
}
fun getRefreshToken(): String? {
val encrypted = sharedPreferences.getString(KEY_REFRESH_TOKEN, null) ?: return null
return decrypt(encrypted)
}
fun getToken(): String? {
val encrypted = sharedPreferences.getString(KEY_TOKEN, null) ?: return null
return decrypt(encrypted)
}
fun savePlayingBook(book: DetailedItem?) {
if (book == null) {
sharedPreferences.edit {
remove(KEY_PLAYING_BOOK)
}
return
}
val adapter = moshi.adapter(DetailedItem::class.java)
val json = adapter.toJson(book)
sharedPreferences.edit {
putString(KEY_PLAYING_BOOK, json)
}
}
fun getPlayingBook(): DetailedItem? {
val json = sharedPreferences.getString(KEY_PLAYING_BOOK, null)
return when (json) {
null -> null
else -> {
val adapter = moshi.adapter(DetailedItem::class.java)
adapter.fromJson(json)
}
}
}
fun saveSeekTime(seekTime: SeekTime) {
val adapter = moshi.adapter(SeekTime::class.java)
val json = adapter.toJson(seekTime)
sharedPreferences.edit(commit = true) { putString(KEY_PREFERRED_SEEK_TIME, json) }
}
fun getSeekTime(): SeekTime {
val json = sharedPreferences.getString(KEY_PREFERRED_SEEK_TIME, null)
return when (json) {
null -> SeekTime.Default
else -> {
val adapter = moshi.adapter(SeekTime::class.java)
adapter.fromJson(json) ?: SeekTime.Default
}
}
}
fun saveCustomHeaders(headers: List<ServerRequestHeader>) {
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
val json = adapter.toJson(headers)
sharedPreferences.edit {
putString(KEY_CUSTOM_HEADERS, json)
}
}
fun getCustomHeaders(): List<ServerRequestHeader> {
val json = sharedPreferences.getString(KEY_CUSTOM_HEADERS, null)
return when (json) {
null -> emptyList()
else -> {
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
adapter.fromJson(json) ?: emptyList()
}
}
}
fun saveLocalUrls(urls: List<LocalUrl>) {
val type = Types.newParameterizedType(List::class.java, LocalUrl::class.java)
val adapter = moshi.adapter<List<LocalUrl>>(type)
val json = adapter.toJson(urls)
sharedPreferences.edit {
putString(KEY_LOCAL_URLS, json)
}
}
fun getLocalUrls(): List<LocalUrl> {
val json = sharedPreferences.getString(KEY_LOCAL_URLS, null)
return when (json) {
null -> emptyList()
else -> {
val type = Types.newParameterizedType(List::class.java, LocalUrl::class.java)
val adapter = moshi.adapter<List<LocalUrl>>(type)
adapter.fromJson(json) ?: emptyList()
}
}
}
companion object {
private const val KEY_ALIAS = "secure_key_alias"
private const val KEY_HOST = "host"
private const val KEY_USERNAME = "username"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_TOKEN = "token"
private const val CACHE_FORCE_ENABLED = "cache_force_enabled"
private const val KEY_SERVER_VERSION = "server_version"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_PREFERRED_LIBRARY_ID = "preferred_library_id"
private const val KEY_PREFERRED_LIBRARY_NAME = "preferred_library_name"
private const val KEY_PREFERRED_LIBRARY_TYPE = "preferred_library_type"
private const val KEY_PREFERRED_PLAYBACK_SPEED = "preferred_playback_speed"
private const val KEY_PREFERRED_SEEK_TIME = "preferred_seek_time"
private const val KEY_PREFERRED_COLOR_SCHEME = "preferred_color_scheme"
private const val KEY_PREFERRED_AUTO_DOWNLOAD = "preferred_auto_download"
private const val KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE = "preferred_auto_download_network_type"
private const val KEY_PREFERRED_AUTO_DOWNLOAD_LIBRARY_TYPE = "preferred_auto_download_library_type"
private const val KEY_AUTO_DOWNLOAD_DELAYED = "auto_download_delayed"
private const val KEY_PREFERRED_LIBRARY_ORDERING = "preferred_library_ordering"
private const val KEY_CUSTOM_HEADERS = "custom_headers"
private const val KEY_BYPASS_SSL = "bypass_ssl"
private const val KEY_LOCAL_URLS = "local_urls"
private const val KEY_PLAYING_BOOK = "playing_book"
private const val KEY_VOLUME_BOOST = "volume_boost"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private fun getSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
keyStore.getKey(KEY_ALIAS, null)?.let {
return it as SecretKey
}
val keyGenerator =
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
val keyGenParameterSpec =
KeyGenParameterSpec
.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
keyGenerator.init(keyGenParameterSpec)
return keyGenerator.generateKey()
}
private fun encrypt(data: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
val cipherText = cipher.doFinal(data.toByteArray())
val ivAndCipherText = cipher.iv + cipherText
return Base64.encodeToString(ivAndCipherText, Base64.DEFAULT)
}
private fun decrypt(data: String): String? {
val decodedData = Base64.decode(data, Base64.DEFAULT)
val iv = decodedData.sliceArray(0 until 12)
val cipherText = decodedData.sliceArray(12 until decodedData.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
return try {
String(cipher.doFinal(cipherText))
} catch (ex: Exception) {
null
}
}
fun getLibraryOrdering(): LibraryOrderingConfiguration {
val json = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ORDERING, null)
return when (json) {
null -> LibraryOrderingConfiguration.default
else -> {
val adapter = moshi.adapter(LibraryOrderingConfiguration::class.java)
adapter.fromJson(json) ?: LibraryOrderingConfiguration.default
}
}
}
fun savePlaybackVolumeBoost(playbackVolumeBoost: PlaybackVolumeBoost) =
sharedPreferences.edit {
putString(KEY_VOLUME_BOOST, playbackVolumeBoost.name)
}
fun getPlaybackVolumeBoost(): PlaybackVolumeBoost =
sharedPreferences
.getString(KEY_VOLUME_BOOST, PlaybackVolumeBoost.DISABLED.name)
?.let { PlaybackVolumeBoost.valueOf(it) }
?: PlaybackVolumeBoost.DISABLED
fun saveAutoDownloadNetworkType(networkTypeAutoCache: NetworkTypeAutoCache) =
sharedPreferences.edit {
putString(KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE, networkTypeAutoCache.name)
}
fun getAutoDownloadNetworkType(): NetworkTypeAutoCache =
sharedPreferences
.getString(KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE, NetworkTypeAutoCache.WIFI_ONLY.name)
?.let { NetworkTypeAutoCache.valueOf(it) }
?: NetworkTypeAutoCache.WIFI_ONLY
fun saveAutoDownloadLibraryTypes(types: List<LibraryType>) {
val type = Types.newParameterizedType(List::class.java, LibraryType::class.java)
val adapter = moshi.adapter<List<LibraryType>>(type)
val json = adapter.toJson(types)
sharedPreferences.edit {
putString(KEY_PREFERRED_AUTO_DOWNLOAD_LIBRARY_TYPE, json)
}
}
fun getAutoDownloadLibraryTypes(): List<LibraryType> {
val json = sharedPreferences.getString(KEY_PREFERRED_AUTO_DOWNLOAD_LIBRARY_TYPE, null)
return when (json) {
null -> LibraryType.meaningfulTypes
else -> {
val type = Types.newParameterizedType(List::class.java, LibraryType::class.java)
val adapter = moshi.adapter<List<LibraryType>>(type)
adapter.fromJson(json) ?: LibraryType.meaningfulTypes
}
}
}
fun saveColorScheme(colorScheme: ColorScheme) =
sharedPreferences.edit {
putString(KEY_PREFERRED_COLOR_SCHEME, colorScheme.name)
}
fun getColorScheme(): ColorScheme =
sharedPreferences
.getString(KEY_PREFERRED_COLOR_SCHEME, ColorScheme.FOLLOW_SYSTEM.name)
?.let { ColorScheme.valueOf(it) }
?: ColorScheme.FOLLOW_SYSTEM
fun saveAutoDownloadOption(option: DownloadOption?) =
sharedPreferences.edit {
putString(KEY_PREFERRED_AUTO_DOWNLOAD, option?.makeId())
}
fun getAutoDownloadOption(): DownloadOption? =
sharedPreferences
.getString(KEY_PREFERRED_AUTO_DOWNLOAD, null)
?.makeDownloadOption()
fun savePlaybackSpeed(factor: Float) = sharedPreferences.edit { putFloat(KEY_PREFERRED_PLAYBACK_SPEED, factor) }
fun getPlaybackSpeed(): Float = sharedPreferences.getFloat(KEY_PREFERRED_PLAYBACK_SPEED, 1f)
val playingBookFlow: Flow<DetailedItem?> =
callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == KEY_PLAYING_BOOK) {
trySend(getPlayingBook())
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
trySend(getPlayingBook())
awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}.distinctUntilChanged()
val playbackVolumeBoostFlow: Flow<PlaybackVolumeBoost> =
callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == KEY_VOLUME_BOOST) {
trySend(getPlaybackVolumeBoost())
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
trySend(getPlaybackVolumeBoost())
awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}.distinctUntilChanged()
val colorSchemeFlow: Flow<ColorScheme> =
callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == KEY_PREFERRED_COLOR_SCHEME) {
trySend(getColorScheme())
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
trySend(getColorScheme())
awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}.distinctUntilChanged()
private fun saveActiveLibraryId(host: String) = sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_ID, host) }
private fun getPreferredLibraryId(): String? = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ID, null)
private fun saveActiveLibraryName(host: String) = sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_NAME, host) }
private fun getPreferredLibraryType(): LibraryType =
sharedPreferences
.getString(KEY_PREFERRED_LIBRARY_TYPE, null)
?.let { LibraryType.valueOf(it) }
?: LibraryType.LIBRARY
private fun saveActiveLibraryType(type: LibraryType) =
sharedPreferences.edit {
putString(KEY_PREFERRED_LIBRARY_TYPE, type.name)
}
private fun getPreferredLibraryName(): String? = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_NAME, null)
fun enableForceCache() = sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, true) }
fun disableForceCache() = sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, false) }
fun isForceCache(): Boolean = sharedPreferences.getBoolean(CACHE_FORCE_ENABLED, false)
fun saveUsername(username: String) = sharedPreferences.edit { putString(KEY_USERNAME, username) }
fun getUsername(): String? = sharedPreferences.getString(KEY_USERNAME, null)
fun saveServerVersion(version: String) = sharedPreferences.edit { putString(KEY_SERVER_VERSION, version) }
fun getServerVersion(): String? = sharedPreferences.getString(KEY_SERVER_VERSION, null)
fun saveToken(token: String) {
val encrypted = encrypt(token)
sharedPreferences.edit { putString(KEY_TOKEN, encrypted) }
}
fun saveAccessToken(accessToken: String) {
val encrypted = encrypt(accessToken)
sharedPreferences.edit { putString(KEY_ACCESS_TOKEN, encrypted) }
}
fun saveRefreshToken(refreshToken: String) {
val encrypted = encrypt(refreshToken)
sharedPreferences.edit { putString(KEY_REFRESH_TOKEN, encrypted) }
}
fun getAccessToken(): String? {
val encrypted = sharedPreferences.getString(KEY_ACCESS_TOKEN, null) ?: return null
return decrypt(encrypted)
}
fun getRefreshToken(): String? {
val encrypted = sharedPreferences.getString(KEY_REFRESH_TOKEN, null) ?: return null
return decrypt(encrypted)
}
fun getToken(): String? {
val encrypted = sharedPreferences.getString(KEY_TOKEN, null) ?: return null
return decrypt(encrypted)
}
fun savePlayingBook(book: DetailedItem?) {
if (book == null) {
sharedPreferences.edit {
remove(KEY_PLAYING_BOOK)
}
return
}
val adapter = moshi.adapter(DetailedItem::class.java)
val json = adapter.toJson(book)
sharedPreferences.edit {
putString(KEY_PLAYING_BOOK, json)
}
}
fun getPlayingBook(): DetailedItem? {
val json = sharedPreferences.getString(KEY_PLAYING_BOOK, null)
return when (json) {
null -> null
else -> {
val adapter = moshi.adapter(DetailedItem::class.java)
adapter.fromJson(json)
}
}
}
fun saveSeekTime(seekTime: SeekTime) {
val adapter = moshi.adapter(SeekTime::class.java)
val json = adapter.toJson(seekTime)
sharedPreferences.edit(commit = true) { putString(KEY_PREFERRED_SEEK_TIME, json) }
}
fun getSeekTime(): SeekTime {
val json = sharedPreferences.getString(KEY_PREFERRED_SEEK_TIME, null)
return when (json) {
null -> SeekTime.Default
else -> {
val adapter = moshi.adapter(SeekTime::class.java)
adapter.fromJson(json) ?: SeekTime.Default
}
}
}
fun saveLibraryOffset(offset: LibraryOffset?) {
if (offset == null) {
sharedPreferences.edit {
remove(KEY_LIBRARY_OFFSET)
}
return
}
val adapter = moshi.adapter(LibraryOffset::class.java)
val json = adapter.toJson(offset)
sharedPreferences.edit {
putString(KEY_LIBRARY_OFFSET, json)
}
}
fun getLibraryOffset(): LibraryOffset? {
val json = sharedPreferences.getString(KEY_LIBRARY_OFFSET, null)
return when (json) {
null -> null
else -> {
val adapter = moshi.adapter(LibraryOffset::class.java)
adapter.fromJson(json)
}
}
}
fun saveCustomHeaders(headers: List<ServerRequestHeader>) {
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
val json = adapter.toJson(headers)
sharedPreferences.edit {
putString(KEY_CUSTOM_HEADERS, json)
}
}
fun getCustomHeaders(): List<ServerRequestHeader> {
val json = sharedPreferences.getString(KEY_CUSTOM_HEADERS, null)
return when (json) {
null -> emptyList()
else -> {
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
adapter.fromJson(json) ?: emptyList()
}
}
}
fun saveLocalUrls(urls: List<LocalUrl>) {
val type = Types.newParameterizedType(List::class.java, LocalUrl::class.java)
val adapter = moshi.adapter<List<LocalUrl>>(type)
val json = adapter.toJson(urls)
sharedPreferences.edit {
putString(KEY_LOCAL_URLS, json)
}
}
fun getLocalUrls(): List<LocalUrl> {
val json = sharedPreferences.getString(KEY_LOCAL_URLS, null)
return when (json) {
null -> emptyList()
else -> {
val type = Types.newParameterizedType(List::class.java, LocalUrl::class.java)
val adapter = moshi.adapter<List<LocalUrl>>(type)
adapter.fromJson(json) ?: emptyList()
}
}
}
companion object {
private const val KEY_ALIAS = "secure_key_alias"
private const val KEY_HOST = "host"
private const val KEY_USERNAME = "username"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_TOKEN = "token"
private const val CACHE_FORCE_ENABLED = "cache_force_enabled"
private const val KEY_SERVER_VERSION = "server_version"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_PREFERRED_LIBRARY_ID = "preferred_library_id"
private const val KEY_PREFERRED_LIBRARY_NAME = "preferred_library_name"
private const val KEY_PREFERRED_LIBRARY_TYPE = "preferred_library_type"
private const val KEY_PREFERRED_PLAYBACK_SPEED = "preferred_playback_speed"
private const val KEY_PREFERRED_SEEK_TIME = "preferred_seek_time"
private const val KEY_PREFERRED_COLOR_SCHEME = "preferred_color_scheme"
private const val KEY_PREFERRED_AUTO_DOWNLOAD = "preferred_auto_download"
private const val KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE = "preferred_auto_download_network_type"
private const val KEY_PREFERRED_AUTO_DOWNLOAD_LIBRARY_TYPE = "preferred_auto_download_library_type"
private const val KEY_AUTO_DOWNLOAD_DELAYED = "auto_download_delayed"
private const val KEY_PREFERRED_LIBRARY_ORDERING = "preferred_library_ordering"
private const val KEY_CUSTOM_HEADERS = "custom_headers"
private const val KEY_BYPASS_SSL = "bypass_ssl"
private const val KEY_LOCAL_URLS = "local_urls"
private const val KEY_PLAYING_BOOK = "playing_book"
private const val KEY_VOLUME_BOOST = "volume_boost"
private const val KEY_LIBRARY_OFFSET = "library_offset"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private fun getSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
keyStore.getKey(KEY_ALIAS, null)?.let {
return it as SecretKey
}
val keyGenerator =
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
val keyGenParameterSpec =
KeyGenParameterSpec
.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
keyGenerator.init(keyGenParameterSpec)
return keyGenerator.generateKey()
}
private fun encrypt(data: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
val cipherText = cipher.doFinal(data.toByteArray())
val ivAndCipherText = cipher.iv + cipherText
return Base64.encodeToString(ivAndCipherText, Base64.DEFAULT)
}
private fun decrypt(data: String): String? {
val decodedData = Base64.decode(data, Base64.DEFAULT)
val iv = decodedData.sliceArray(0 until 12)
val cipherText = decodedData.sliceArray(12 until decodedData.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
return try {
String(cipher.doFinal(cipherText))
} catch (ex: Exception) {
null
}
}
}
}

View File

@@ -22,6 +22,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.grakovne.lissen.content.LissenMediaProvider
import org.grakovne.lissen.lib.domain.Book
import org.grakovne.lissen.lib.domain.LibraryOffset
import org.grakovne.lissen.lib.domain.LibraryType
import org.grakovne.lissen.lib.domain.RecentBook
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
@@ -32,147 +33,181 @@ import javax.inject.Inject
@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryViewModel
@Inject
constructor(
private val mediaChannel: LissenMediaProvider,
private val preferences: LissenSharedPreferences,
) : ViewModel() {
private val _recentBooks = MutableLiveData<List<RecentBook>>(emptyList())
val recentBooks: LiveData<List<RecentBook>> = _recentBooks
private val _recentBookUpdating = MutableLiveData(false)
val recentBookUpdating: LiveData<Boolean> = _recentBookUpdating
private val _searchRequested = MutableLiveData(false)
val searchRequested: LiveData<Boolean> = _searchRequested
private val _searchToken = MutableStateFlow(EMPTY_SEARCH)
private var defaultPagingSource: PagingSource<Int, Book>? = null
private var searchPagingSource: PagingSource<Int, Book>? = null
private val _totalCount = MutableLiveData<Int>()
val totalCount: LiveData<Int> = _totalCount
private val pageConfig =
PagingConfig(
pageSize = PAGE_SIZE,
initialLoadSize = PAGE_SIZE,
prefetchDistance = PAGE_SIZE,
)
fun getPager(isSearchRequested: Boolean) =
when (isSearchRequested) {
true -> searchPager
false -> libraryPager
}
private val searchPager: Flow<PagingData<Book>> =
combine(
_searchToken,
searchRequested.asFlow(),
) { token, requested ->
Pair(token, requested)
}.flatMapLatest { (token, _) ->
Pager(
config = pageConfig,
pagingSourceFactory = {
val source =
LibrarySearchPagingSource(
preferences = preferences,
mediaChannel = mediaChannel,
searchToken = token,
limit = PAGE_SEARCH_SIZE,
) { _totalCount.postValue(it) }
searchPagingSource = source
source
},
).flow
}.cachedIn(viewModelScope)
private val libraryPager: Flow<PagingData<Book>> by lazy {
@Inject
constructor(
private val mediaChannel: LissenMediaProvider,
private val preferences: LissenSharedPreferences,
) : ViewModel() {
private val _recentBooks = MutableLiveData<List<RecentBook>>(emptyList())
val recentBooks: LiveData<List<RecentBook>> = _recentBooks
private val _recentBookUpdating = MutableLiveData(false)
val recentBookUpdating: LiveData<Boolean> = _recentBookUpdating
private val _searchRequested = MutableLiveData(false)
val searchRequested: LiveData<Boolean> = _searchRequested
private val _searchToken = MutableStateFlow(EMPTY_SEARCH)
private var defaultPagingSource: PagingSource<Int, Book>? = null
private var searchPagingSource: PagingSource<Int, Book>? = null
private val _totalCount = MutableLiveData<Int>()
val totalCount: LiveData<Int> = _totalCount
private val _libraryOffset = MutableLiveData<LibraryOffset?>(preferences.getLibraryOffset())
private val pageConfig =
PagingConfig(
pageSize = PAGE_SIZE,
initialLoadSize = PAGE_SIZE,
prefetchDistance = PAGE_SIZE,
)
fun getPager(isSearchRequested: Boolean) =
when (isSearchRequested) {
true -> searchPager
false -> libraryPager
}
private val searchPager: Flow<PagingData<Book>> =
combine(
_searchToken,
searchRequested.asFlow(),
) { token, requested ->
Pair(token, requested)
}.flatMapLatest { (token, _) ->
Pager(
config = pageConfig,
pagingSourceFactory = {
val source = LibraryDefaultPagingSource(preferences, mediaChannel) { _totalCount.postValue(it) }
defaultPagingSource = source
val source =
LibrarySearchPagingSource(
preferences = preferences,
mediaChannel = mediaChannel,
searchToken = token,
limit = PAGE_SEARCH_SIZE,
) { _totalCount.postValue(it) }
searchPagingSource = source
source
},
).flow.cachedIn(viewModelScope)
}
fun requestSearch() {
_searchRequested.postValue(true)
}
fun dismissSearch() {
_searchRequested.postValue(false)
_searchToken.value = EMPTY_SEARCH
}
fun updateSearch(token: String) {
viewModelScope.launch { _searchToken.emit(token) }
}
fun fetchPreferredLibraryTitle(): String? =
preferences
.getPreferredLibrary()
?.title
fun fetchPreferredLibraryType() =
preferences
.getPreferredLibrary()
?.type
?: LibraryType.UNKNOWN
fun refreshRecentListening() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
fetchRecentListening()
}
}
}
fun refreshLibrary() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
when (searchRequested.value) {
true -> searchPagingSource?.invalidate()
else -> defaultPagingSource?.invalidate()
}
}
}
}
fun fetchRecentListening() {
_recentBookUpdating.postValue(true)
val preferredLibrary =
preferences.getPreferredLibrary()?.id ?: run {
_recentBookUpdating.postValue(false)
return
}
viewModelScope.launch {
mediaChannel
.fetchRecentListenedBooks(preferredLibrary)
.fold(
onSuccess = {
_recentBooks.postValue(it)
_recentBookUpdating.postValue(false)
},
onFailure = {
_recentBookUpdating.postValue(false)
},
)
}
}
companion object {
private const val EMPTY_SEARCH = ""
private const val PAGE_SIZE = 20
private const val PAGE_SEARCH_SIZE = 50
).flow
}.cachedIn(viewModelScope)
private val libraryPager: Flow<PagingData<Book>> by lazy {
Pager(
config = pageConfig,
pagingSourceFactory = {
val source = LibraryDefaultPagingSource(preferences, mediaChannel) { _totalCount.postValue(it) }
defaultPagingSource = source
source
},
).flow.cachedIn(viewModelScope)
}
fun requestSearch() {
_searchRequested.postValue(true)
}
fun dismissSearch() {
_searchRequested.postValue(false)
_searchToken.value = EMPTY_SEARCH
}
fun updateSearch(token: String) {
viewModelScope.launch { _searchToken.emit(token) }
}
fun clearLibraryOffset() {
_libraryOffset.postValue(null)
preferences.saveLibraryOffset(null)
}
fun fetchLibraryOffset(): Int {
val offset = preferences.getLibraryOffset() ?: return 0
val currentLibraryId = preferences.getPreferredLibrary()?.id ?: return 0
return when(currentLibraryId == offset.libraryId) {
true -> offset.offset
false -> 0.also { clearLibraryOffset() }
}
}
fun updateLibraryOffset(offset: Int) {
val currentLibraryId = preferences.getPreferredLibrary()?.id
if (null == currentLibraryId) {
clearLibraryOffset()
return
}
val libraryOffset = LibraryOffset(
libraryId = currentLibraryId,
offset = offset
)
_libraryOffset.postValue(libraryOffset)
preferences.saveLibraryOffset(libraryOffset)
}
fun fetchPreferredLibraryTitle(): String? =
preferences
.getPreferredLibrary()
?.title
fun fetchPreferredLibraryType() =
preferences
.getPreferredLibrary()
?.type
?: LibraryType.UNKNOWN
fun refreshRecentListening() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
fetchRecentListening()
}
}
}
fun refreshLibrary() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
when (searchRequested.value) {
true -> searchPagingSource?.invalidate()
else -> defaultPagingSource?.invalidate()
}
}
}
}
fun fetchRecentListening() {
_recentBookUpdating.postValue(true)
val preferredLibrary =
preferences.getPreferredLibrary()?.id ?: run {
_recentBookUpdating.postValue(false)
return
}
viewModelScope.launch {
mediaChannel
.fetchRecentListenedBooks(preferredLibrary)
.fold(
onSuccess = {
_recentBooks.postValue(it)
_recentBookUpdating.postValue(false)
},
onFailure = {
_recentBookUpdating.postValue(false)
},
)
}
}
companion object {
private const val EMPTY_SEARCH = ""
private const val PAGE_SIZE = 20
private const val PAGE_SEARCH_SIZE = 50
}
}

View File

@@ -0,0 +1,11 @@
package org.grakovne.lissen.lib.domain
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class LibraryOffset(
val libraryId: String,
val offset: Int
)