diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49052dfe..a296fda3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "org.grakovne.lissen" minSdk = 28 targetSdk = 35 - versionCode = 24 - versionName = "1.0.23" + versionCode = 25 + versionName = "1.0.24" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannel.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannel.kt index 215b5884..2adced8c 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannel.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannel.kt @@ -63,17 +63,6 @@ class AudiobookshelfChannel @Inject constructor( .appendQueryParameter("token", preferences.getToken()) .build() - override fun provideBookCoverUri( - bookId: String - ): Uri = Uri.parse(preferences.getHost()) - .buildUpon() - .appendPath("api") - .appendPath("items") - .appendPath(bookId) - .appendPath("cover") - .appendQueryParameter("token", preferences.getToken()) - .build() - override suspend fun syncProgress( sessionId: String, progress: PlaybackProgress diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/ApiClientConfig.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/ApiClientConfig.kt new file mode 100644 index 00000000..6b6ff597 --- /dev/null +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/ApiClientConfig.kt @@ -0,0 +1,9 @@ +package org.grakovne.lissen.channel.audiobookshelf.api + +import org.grakovne.lissen.domain.connection.ServerRequestHeader + +data class ApiClientConfig( + val host: String?, + val token: String?, + val customHeaders: List? +) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfDataRepository.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfDataRepository.kt index 8952912f..cc8ce4a3 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfDataRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfDataRepository.kt @@ -27,11 +27,12 @@ import javax.inject.Singleton @Singleton class AudioBookshelfDataRepository @Inject constructor( private val loginResponseConverter: LoginResponseConverter, - private val preferences: LissenSharedPreferences + private val preferences: LissenSharedPreferences, + private val requestHeadersProvider: RequestHeadersProvider ) { - @Volatile - private var secureClient: AudiobookshelfApiClient? = null + private var configCache: ApiClientConfig? = null + private var clientCache: AudiobookshelfApiClient? = null suspend fun fetchLibraries(): ApiResult = safeApiCall { getClientInstance().fetchLibraries() } @@ -101,8 +102,6 @@ class AudioBookshelfDataRepository @Inject constructor( username: String, password: String ): ApiResult { - secureClient = null - if (host.isBlank() || !urlPattern.matches(host)) { return ApiResult.Error(ApiError.InvalidCredentialsHost) } @@ -110,7 +109,11 @@ class AudioBookshelfDataRepository @Inject constructor( lateinit var apiService: AudiobookshelfApiClient try { - val apiClient = ApiClient(host = host) + val apiClient = ApiClient( + host = host, + requestHeaders = requestHeadersProvider.fetchRequestHeaders() + ) + apiService = apiClient.retrofit.create(AudiobookshelfApiClient::class.java) } catch (e: Exception) { return ApiResult.Error(ApiError.InternalError) @@ -118,14 +121,16 @@ class AudioBookshelfDataRepository @Inject constructor( val response: ApiResult = safeApiCall { apiService.login(LoginRequest(username, password)) } - return response.fold( - onSuccess = { - loginResponseConverter - .apply(it) - .let { ApiResult.Success(it) } - }, - onFailure = { ApiResult.Error(it.code) } - ) + + return response + .fold( + onSuccess = { + loginResponseConverter + .apply(it) + .let { ApiResult.Success(it) } + }, + onFailure = { ApiResult.Error(it.code) } + ) } private suspend fun safeApiCall( @@ -158,16 +163,48 @@ class AudioBookshelfDataRepository @Inject constructor( val host = preferences.getHost() val token = preferences.getToken() + val cache = ApiClientConfig( + host = host, + token = token, + customHeaders = requestHeadersProvider.fetchRequestHeaders() + ) + + val currentClientCache = clientCache + + return when (currentClientCache == null || cache != configCache) { + true -> { + val instance = createClientInstance() + configCache = cache + clientCache = instance + instance + } + + else -> currentClientCache + } + } + + private fun createClientInstance(): AudiobookshelfApiClient { + val host = preferences.getHost() + val token = preferences.getToken() + if (host.isNullOrBlank() || token.isNullOrBlank()) { throw IllegalStateException("Host or token is missing") } - return secureClient ?: run { - val apiClient = ApiClient(host = host, token = token) - apiClient.retrofit.create(AudiobookshelfApiClient::class.java) - } + return apiClient(host, token) + .retrofit + .create(AudiobookshelfApiClient::class.java) } + private fun apiClient( + host: String, + token: String? + ): ApiClient = ApiClient( + host = host, + token = token, + requestHeaders = requestHeadersProvider.fetchRequestHeaders() + ) + companion object { val urlPattern = Regex("^(http|https)://.*\$") diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfMediaRepository.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfMediaRepository.kt index bbda1b2a..85fdf110 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfMediaRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/AudioBookshelfMediaRepository.kt @@ -14,11 +14,12 @@ import javax.inject.Singleton @Singleton class AudioBookshelfMediaRepository @Inject constructor( - private val preferences: LissenSharedPreferences + private val preferences: LissenSharedPreferences, + private val requestHeadersProvider: RequestHeadersProvider ) { - @Volatile - private var secureClient: AudiobookshelfMediaClient? = null + private var configCache: ApiClientConfig? = null + private var clientCache: AudiobookshelfMediaClient? = null suspend fun fetchBookCover(itemId: String): ApiResult = safeApiCall { getClientInstance().getItemCover(itemId) } @@ -45,7 +46,6 @@ class AudioBookshelfMediaRepository @Inject constructor( } catch (e: IOException) { ApiResult.Error(ApiError.NetworkError) } catch (e: Exception) { - println(e) ApiResult.Error(ApiError.InternalError) } } @@ -54,13 +54,45 @@ class AudioBookshelfMediaRepository @Inject constructor( val host = preferences.getHost() val token = preferences.getToken() + val cache = ApiClientConfig( + host = host, + token = token, + customHeaders = requestHeadersProvider.fetchRequestHeaders() + ) + + val currentClientCache = clientCache + + return when (currentClientCache == null || cache != configCache) { + true -> { + val instance = createClientInstance() + configCache = cache + clientCache = instance + instance + } + + else -> currentClientCache + } + } + + private fun createClientInstance(): AudiobookshelfMediaClient { + val host = preferences.getHost() + val token = preferences.getToken() + if (host.isNullOrBlank() || token.isNullOrBlank()) { throw IllegalStateException("Host or token is missing") } - return secureClient ?: run { - val apiClient = BinaryApiClient(host, token) - apiClient.retrofit.create(AudiobookshelfMediaClient::class.java) - } + return apiClient(host, token) + .retrofit + .create(AudiobookshelfMediaClient::class.java) } + + private fun apiClient( + host: String, + token: String + ): BinaryApiClient = BinaryApiClient( + host = host, + token = token, + requestHeaders = requestHeadersProvider.fetchRequestHeaders() + ) } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/RequestHeadersProvider.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/RequestHeadersProvider.kt new file mode 100644 index 00000000..d26fea6f --- /dev/null +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/api/RequestHeadersProvider.kt @@ -0,0 +1,20 @@ +package org.grakovne.lissen.channel.audiobookshelf.api + +import org.grakovne.lissen.channel.common.USER_AGENT +import org.grakovne.lissen.domain.connection.ServerRequestHeader +import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RequestHeadersProvider @Inject constructor( + private val preferences: LissenSharedPreferences +) { + + fun fetchRequestHeaders(): List { + val usersHeaders = preferences.getCustomHeaders() + + val userAgent = ServerRequestHeader("User-Agent", USER_AGENT) + return usersHeaders + userAgent + } +} diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/LoginResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/LoginResponse.kt index b4f5380b..c17d8ac3 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/LoginResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/LoginResponse.kt @@ -7,10 +7,5 @@ data class LoginResponse( data class User( val id: String, - val username: String, - val email: String?, - val type: String, - val token: String, - val isActive: Boolean, - val isLocked: Boolean + val token: String ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/MediaMetadataResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/MediaMetadataResponse.kt index 3615bdab..6dcd9a92 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/MediaMetadataResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/model/MediaMetadataResponse.kt @@ -2,17 +2,7 @@ package org.grakovne.lissen.channel.audiobookshelf.model data class MediaMetadataResponse( val title: String, - val subtitle: String?, - val authors: List?, - val publishedYear: Int?, - val publishedDate: String?, - val publisher: String?, - val description: String?, - val isbn: String?, - val asin: String?, - val language: String?, - val explicit: Boolean, - val abridged: Boolean + val authors: List? ) data class Author( diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt index 4094a9e7..935e9d1a 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt @@ -4,12 +4,14 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.logging.HttpLoggingInterceptor +import org.grakovne.lissen.domain.connection.ServerRequestHeader import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit class ApiClient( host: String, + requestHeaders: List?, token: String? = null ) { @@ -27,7 +29,11 @@ class ApiClient( requestBuilder.header("Authorization", "Bearer $token") } - requestBuilder.header("User-Agent", USER_AGENT) + requestHeaders + ?.filter { it.name.isNotEmpty() } + ?.filter { it.value.isNotEmpty() } + ?.forEach { requestBuilder.header(it.name, it.value) } + val request: Request = requestBuilder.build() chain.proceed(request) } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt b/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt index a032fd75..27cc8ffb 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt @@ -3,11 +3,13 @@ package org.grakovne.lissen.channel.common import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import org.grakovne.lissen.domain.connection.ServerRequestHeader import retrofit2.Retrofit import java.util.concurrent.TimeUnit class BinaryApiClient( host: String, + requestHeaders: List?, token: String ) { @@ -22,9 +24,13 @@ class BinaryApiClient( .request() .newBuilder() .header("Authorization", "Bearer $token") - .header("User-Agent", USER_AGENT) - .build() - chain.proceed(request) + + requestHeaders + ?.filter { it.name.isNotEmpty() } + ?.filter { it.value.isNotEmpty() } + ?.forEach { request.header(it.name, it.value) } + + chain.proceed(request.build()) } .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt b/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt index f59d11ed..d794c9b9 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt @@ -20,10 +20,6 @@ interface MediaChannel { fileId: String ): Uri - fun provideBookCoverUri( - bookId: String - ): Uri - suspend fun syncProgress( sessionId: String, progress: PlaybackProgress diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt b/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt index 798a01a6..1056e674 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt @@ -1,6 +1,5 @@ package org.grakovne.lissen.channel.common import android.os.Build -import org.grakovne.lissen.BuildConfig -val USER_AGENT = "Lissen/${BuildConfig.VERSION_NAME} (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) ExoPlayer/1.4.1" +val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.106 Mobile Safari/537.36" diff --git a/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt b/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt index 21717eba..6494c454 100644 --- a/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt +++ b/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt @@ -40,9 +40,7 @@ class LissenMediaProvider @Inject constructor( true -> localCacheRepository .provideFileUri(libraryItemId, chapterId) - ?.let { - ApiResult.Success(it) - } + ?.let { ApiResult.Success(it) } ?: ApiResult.Error(ApiError.InternalError) false -> @@ -55,17 +53,6 @@ class LissenMediaProvider @Inject constructor( } } - fun provideBookCoverUri( - bookId: String - ): Uri { - Log.d(TAG, "Fetching Cover URI for $bookId") - - return when (cacheConfiguration.localCacheUsing()) { - true -> localCacheRepository.provideBookCover(bookId) - false -> providePreferredChannel().provideBookCoverUri(bookId) - } - } - suspend fun syncProgress( sessionId: String, bookId: String, diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/BookCachingService.kt b/app/src/main/java/org/grakovne/lissen/content/cache/BookCachingService.kt index 9a365aff..6d19d296 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/BookCachingService.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/BookCachingService.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext +import org.grakovne.lissen.channel.audiobookshelf.api.RequestHeadersProvider import org.grakovne.lissen.channel.common.MediaChannel import org.grakovne.lissen.content.cache.api.CachedBookRepository import org.grakovne.lissen.content.cache.api.CachedLibraryRepository @@ -30,7 +31,8 @@ class BookCachingService @Inject constructor( @ApplicationContext private val context: Context, private val bookRepository: CachedBookRepository, private val libraryRepository: CachedLibraryRepository, - private val properties: CacheBookStorageProperties + private val properties: CacheBookStorageProperties, + private val requestHeadersProvider: RequestHeadersProvider ) { fun cacheBook( @@ -88,13 +90,20 @@ class BookCachingService @Inject constructor( val downloads = book .files .map { file -> - Request(channel.provideFileUri(book.id, file.id)) + val uri = channel.provideFileUri(book.id, file.id) + + val downloadRequest = Request(uri) .setTitle(file.name) .setNotificationVisibility(VISIBILITY_VISIBLE) .setDestinationUri(properties.provideMediaCachePatch(book.id, file.id).toUri()) .setAllowedOverMetered(true) .setAllowedOverRoaming(true) - .let { downloadManager.enqueue(it) } + + requestHeadersProvider + .fetchRequestHeaders() + .forEach { downloadRequest.addRequestHeader(it.name, it.value) } + + downloadRequest.let { downloadManager.enqueue(it) } } return awaitDownloadProgress(downloads, downloadManager) diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt index ea0a709b..0cbffdbe 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt @@ -1,7 +1,6 @@ package org.grakovne.lissen.content.cache import android.net.Uri -import android.net.Uri.parse import androidx.core.net.toFile import org.grakovne.lissen.channel.common.ApiError import org.grakovne.lissen.channel.common.ApiResult @@ -29,12 +28,6 @@ class LocalCacheRepository @Inject constructor( .provideFileUri(libraryItemId, fileId) .takeIf { it.toFile().exists() } - fun provideBookCover(bookId: String): Uri = - cachedBookRepository - .provideBookCover(bookId) - .toString() - .let { parse(it) } - /** * For the local cache we avoiding to create intermediary entity like Session and using BookId * as a Playback Session Key diff --git a/app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt b/app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt new file mode 100644 index 00000000..589b60ef --- /dev/null +++ b/app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt @@ -0,0 +1,29 @@ +package org.grakovne.lissen.domain.connection + +import java.util.UUID + +data class ServerRequestHeader( + val name: String, + val value: String, + val id: UUID = UUID.randomUUID() +) { + + companion object { + fun empty() = ServerRequestHeader("", "") + + fun ServerRequestHeader.clean(): ServerRequestHeader { + val name = this.name.clean() + val value = this.value.clean() + + return this.copy(name = name, value = value) + } + + private fun String.clean(): String { + var sanitized = this.replace(Regex("[\\r\\n]"), "") + sanitized = sanitized.replace(Regex("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]"), "") + sanitized = sanitized.trim() + + return sanitized + } + } +} diff --git a/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index bec081f9..e0df51b8 100644 --- a/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -5,6 +5,8 @@ import android.content.SharedPreferences import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -13,6 +15,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import org.grakovne.lissen.channel.common.ChannelCode import org.grakovne.lissen.common.ColorScheme import org.grakovne.lissen.domain.Library +import org.grakovne.lissen.domain.connection.ServerRequestHeader import java.security.KeyStore import java.util.UUID import javax.crypto.Cipher @@ -148,6 +151,24 @@ class LissenSharedPreferences @Inject constructor(@ApplicationContext context: C return decrypt(encrypted) } + fun saveCustomHeaders(headers: List) { + val editor = sharedPreferences.edit() + + val json = gson.toJson(headers) + editor.putString(KEY_CUSTOM_HEADERS, json) + editor.apply() + } + + fun getCustomHeaders(): List { + val json = sharedPreferences.getString(KEY_CUSTOM_HEADERS, null) + val type = object : TypeToken>() {}.type + + return when (json == null) { + true -> emptyList() + false -> gson.fromJson(json, type) + } + } + companion object { private const val KEY_ALIAS = "secure_key_alias" @@ -165,6 +186,8 @@ class LissenSharedPreferences @Inject constructor(@ApplicationContext context: C private const val KEY_PREFERRED_COLOR_SCHEME = "preferred_color_scheme" + private const val KEY_CUSTOM_HEADERS = "custom_headers" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val TRANSFORMATION = "AES/GCM/NoPadding" @@ -213,5 +236,7 @@ class LissenSharedPreferences @Inject constructor(@ApplicationContext context: C null } } + + private val gson = Gson() } } diff --git a/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt b/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt index d644ac57..ddc082bf 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt @@ -7,7 +7,6 @@ import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import dagger.Module @@ -30,12 +29,6 @@ object MediaModule { .setSeekBackIncrementMs(10_000) .setSeekForwardIncrementMs(30_000) .setHandleAudioBecomingNoisy(true) - .setLoadControl( - DefaultLoadControl - .Builder() - .setPrioritizeTimeOverSizeThresholds(true) - .build() - ) .setAudioAttributes( AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt index c411f68e..cde354cd 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt @@ -6,8 +6,12 @@ import androidx.annotation.OptIn import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.MediaMetadata.PICTURE_TYPE_FRONT_COVER import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import dagger.hilt.android.AndroidEntryPoint @@ -18,6 +22,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.grakovne.lissen.channel.audiobookshelf.api.RequestHeadersProvider import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.domain.BookFile import org.grakovne.lissen.domain.DetailedBook @@ -46,6 +51,9 @@ class PlaybackService : MediaSessionService() { @Inject lateinit var channelProvider: LissenMediaProvider + @Inject + lateinit var requestHeadersProvider: RequestHeadersProvider + private val playerServiceScope = MainScope() @Suppress("DEPRECATION") @@ -117,35 +125,51 @@ class PlaybackService : MediaSessionService() { withContext(Dispatchers.IO) { val prepareQueue = async { - val coverUri = mediaChannel.provideBookCoverUri(book.id) + val cover: ByteArray? = channelProvider + .fetchBookCover(bookId = book.id) + .fold( + onSuccess = { + try { + it.readBytes() + } catch (ex: Exception) { + null + } + }, + onFailure = { null } + ) + + val sourceFactory = buildDataSourceFactory() val playingQueue = book .files .mapNotNull { file -> - mediaChannel .provideFileUri(book.id, file.id) .fold( - onSuccess = { - MediaItem.Builder() + onSuccess = { request -> + val mediaData = MediaMetadata.Builder() + .setTitle(file.name) + .setArtist(book.title) + + cover?.let { mediaData.setArtworkData(it, PICTURE_TYPE_FRONT_COVER) } + + val mediaItem = MediaItem.Builder() .setMediaId(file.id) - .setUri(it) + .setUri(request) .setTag(book) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(file.name) - .setArtist(book.title) - .setArtworkUri(coverUri) - .build() - ) + .setMediaMetadata(mediaData.build()) .build() + + ProgressiveMediaSource + .Factory(sourceFactory) + .createMediaSource(mediaItem) }, onFailure = { null } ) } withContext(Dispatchers.Main) { - exoPlayer.setMediaItems(playingQueue) + exoPlayer.setMediaSources(playingQueue) setPlaybackProgress(book.files, book.progress) } } @@ -204,6 +228,22 @@ class PlaybackService : MediaSessionService() { progress: MediaProgress? ) = seek(chapters, progress?.currentTime) + @OptIn(UnstableApi::class) + private fun buildDataSourceFactory(): DefaultDataSource.Factory { + val requestHeaders = requestHeadersProvider + .fetchRequestHeaders() + .associate { it.name to it.value } + + val networkDatasourceFactory = DefaultHttpDataSource + .Factory() + .setDefaultRequestProperties(requestHeaders) + + return DefaultDataSource.Factory( + baseContext, + networkDatasourceFactory + ) + } + companion object { const val ACTION_PLAY = "org.grakovne.lissen.player.service.PLAY" diff --git a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt index a1948b2e..a4eed3a5 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt @@ -19,6 +19,7 @@ import org.grakovne.lissen.ui.screens.library.LibraryScreen import org.grakovne.lissen.ui.screens.login.LoginScreen import org.grakovne.lissen.ui.screens.player.PlayerScreen import org.grakovne.lissen.ui.screens.settings.SettingsScreen +import org.grakovne.lissen.ui.screens.settings.advanced.CustomHeadersSettingsScreen @Composable fun AppNavHost( @@ -81,6 +82,12 @@ fun AppNavHost( navController = navigationService ) } + + composable("settings_screen/custom_headers") { + CustomHeadersSettingsScreen( + onBack = { navController.popBackStack() } + ) + } } } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt index e8cc5768..c51e9444 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt @@ -28,6 +28,10 @@ class AppNavigationService( host.navigate("settings_screen") } + fun showCustomHeadersSettings() { + host.navigate("settings_screen/custom_headers") + } + fun showLogin() { host.navigate("login_screen") { popUpTo(0) { diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt index 1953c66e..ede45a75 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt @@ -6,21 +6,27 @@ import android.widget.Toast import android.widget.Toast.LENGTH_SHORT import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -173,15 +179,56 @@ fun LoginScreen( .padding(vertical = 4.dp) ) - Button( - onClick = { - viewModel.login() - }, + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 32.dp) + .padding(top = 32.dp) ) { - Text(text = stringResource(R.string.login_screen_connect_button_text)) + Button( + onClick = { + viewModel.login() + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape( + topStart = 16.dp, + bottomStart = 16.dp, + topEnd = 0.dp, + bottomEnd = 0.dp + ) + ) { + Spacer(modifier = Modifier.width(28.dp)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.login_screen_connect_button_text), + fontSize = 18.sp + ) + } + } + + Spacer(modifier = Modifier.width(1.dp)) + + Button( + onClick = { + navController.showSettings() + }, + modifier = Modifier.width(56.dp), + shape = RoundedCornerShape( + topStart = 0.dp, + bottomStart = 0.dp, + topEnd = 16.dp, + bottomEnd = 16.dp + ), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } } CircularProgressIndicator( @@ -199,7 +246,7 @@ fun LoginScreen( .alpha(0.5f) .padding(bottom = 32.dp), text = stringResource(R.string.audiobookshelf_server_is_required), - style = MaterialTheme.typography.bodySmall.copy( + style = typography.bodySmall.copy( fontSize = 10.sp, fontWeight = FontWeight.Normal, color = colorScheme.onBackground, diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt index 0621987a..387d29d1 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -29,8 +31,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import org.grakovne.lissen.R import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.ui.screens.settings.composable.AdditionalComposable +import org.grakovne.lissen.ui.screens.settings.composable.AdvancedSettingsItemComposable import org.grakovne.lissen.ui.screens.settings.composable.GeneralSettingsComposable -import org.grakovne.lissen.ui.screens.settings.composable.ServerComposable +import org.grakovne.lissen.ui.screens.settings.composable.ServerSettingsComposable import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -40,7 +43,7 @@ fun SettingsScreen( navController: AppNavigationService ) { val viewModel: SettingsViewModel = hiltViewModel() - val titleTextStyle = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + val host by viewModel.host.observeAsState("") LaunchedEffect(Unit) { viewModel.fetchLibraries() @@ -52,7 +55,7 @@ fun SettingsScreen( title = { Text( text = stringResource(R.string.settings_screen_title), - style = titleTextStyle, + style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), color = colorScheme.onSurface ) }, @@ -84,8 +87,15 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - ServerComposable(navController, viewModel) + if (host?.isNotEmpty() == true) { + ServerSettingsComposable(navController, viewModel) + } GeneralSettingsComposable(viewModel) + AdvancedSettingsItemComposable( + title = stringResource(R.string.settings_screen_custom_headers_title), + description = stringResource(R.string.settings_screen_custom_header_hint), + onclick = { navController.showCustomHeadersSettings() } + ) } AdditionalComposable() } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt new file mode 100644 index 00000000..15d83e27 --- /dev/null +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt @@ -0,0 +1,78 @@ +package org.grakovne.lissen.ui.screens.settings.advanced + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.grakovne.lissen.R +import org.grakovne.lissen.domain.connection.ServerRequestHeader + +@Composable +fun CustomHeaderComposable( + header: ServerRequestHeader, + onChanged: (ServerRequestHeader) -> Unit, + onDelete: (ServerRequestHeader) -> Unit +) { + Card( + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(colorScheme.background), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = header.name, + onValueChange = { onChanged(header.copy(name = it, value = header.value)) }, + label = { Text(stringResource(R.string.custom_header_hint_name)) }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) + + OutlinedTextField( + value = header.value, + onValueChange = { onChanged(header.copy(name = header.name, value = it)) }, + label = { Text(stringResource(R.string.custom_header_hint_value)) }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) + } + + IconButton( + onClick = { onDelete(header) } + ) { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = null, + tint = colorScheme.error, + modifier = Modifier.size(32.dp) + ) + } + } + } +} diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt new file mode 100644 index 00000000..72dae5f1 --- /dev/null +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt @@ -0,0 +1,150 @@ +package org.grakovne.lissen.ui.screens.settings.advanced + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.launch +import org.grakovne.lissen.R +import org.grakovne.lissen.domain.connection.ServerRequestHeader +import org.grakovne.lissen.viewmodel.SettingsViewModel +import kotlin.math.max + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomHeadersSettingsScreen( + onBack: () -> Unit +) { + val settingsViewModel: SettingsViewModel = hiltViewModel() + val headers = settingsViewModel.customHeaders.observeAsState(emptyList()) + + val fabHeight = 56.dp + val additionalPadding = 16.dp + + val state = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.custom_headers_title), + style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = colorScheme.onSurface + ) + }, + navigationIcon = { + IconButton(onClick = { + onBack() + }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = colorScheme.onSurface + ) + } + } + ) + }, + modifier = Modifier + .systemBarsPadding() + .fillMaxHeight(), + content = { innerPadding -> + LazyColumn( + state = state, + contentPadding = PaddingValues( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding() + fabHeight + additionalPadding + ), + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val customHeaders = when (headers.value.isEmpty()) { + true -> listOf(ServerRequestHeader.empty()) + false -> headers.value + } + + itemsIndexed(customHeaders) { index, header -> + CustomHeaderComposable( + header = header, + onChanged = { newPair -> + val updatedList = customHeaders.toMutableList() + updatedList[index] = newPair + + settingsViewModel.updateCustomHeaders(updatedList) + }, + onDelete = { pair -> + val updatedList = customHeaders.toMutableList() + updatedList.remove(pair) + + if (updatedList.isEmpty()) { + updatedList.add(ServerRequestHeader.empty()) + } + + settingsViewModel.updateCustomHeaders(updatedList) + } + ) + + if (index < customHeaders.size - 1) { + HorizontalDivider( + modifier = Modifier + .height(1.dp) + .padding(horizontal = 24.dp) + ) + } + } + } + }, + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + FloatingActionButton( + containerColor = colorScheme.primary, + shape = CircleShape, + onClick = { + val updatedList = headers.value.toMutableList() + updatedList.add(ServerRequestHeader.empty()) + settingsViewModel.updateCustomHeaders(updatedList) + + coroutineScope.launch { + state.scrollToItem(max(0, updatedList.size - 1)) + } + } + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add" + ) + } + } + ) +} diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt new file mode 100644 index 00000000..d82547cf --- /dev/null +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt @@ -0,0 +1,56 @@ +package org.grakovne.lissen.ui.screens.settings.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun AdvancedSettingsItemComposable( + title: String, + description: String, + onclick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onclick() } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = description, + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Arrow Icon", + tint = colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GeneralSettingsComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GeneralSettingsComposable.kt index c5a104ea..a47b7676 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GeneralSettingsComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GeneralSettingsComposable.kt @@ -31,31 +31,35 @@ fun GeneralSettingsComposable(viewModel: SettingsViewModel) { val preferredLibrary by viewModel.preferredLibrary.observeAsState() val preferredColorScheme by viewModel.preferredColorScheme.observeAsState() + val host by viewModel.host.observeAsState("") var preferredLibraryExpanded by remember { mutableStateOf(false) } var colorSchemeExpanded by remember { mutableStateOf(false) } val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { preferredLibraryExpanded = true } - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_screen_preferred_library_title), - style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 4.dp) - ) - Text( - text = preferredLibrary?.title ?: stringResource(R.string.library_is_not_available), - style = typography.bodyMedium, - color = when (preferredLibrary?.title) { - null -> colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - else -> colorScheme.onSurfaceVariant - } - ) + if (host?.isNotEmpty() == true) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { preferredLibraryExpanded = true } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_screen_preferred_library_title), + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = preferredLibrary?.title + ?: stringResource(R.string.library_is_not_available), + style = typography.bodyMedium, + color = when (preferredLibrary?.title) { + null -> colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + else -> colorScheme.onSurfaceVariant + } + ) + } } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt similarity index 98% rename from app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerComposable.kt rename to app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt index 0b3c8aa9..17aa10a4 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt @@ -29,7 +29,7 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable -fun ServerComposable( +fun ServerSettingsComposable( navController: AppNavigationService, viewModel: SettingsViewModel ) { @@ -66,6 +66,7 @@ fun ServerComposable( overflow = TextOverflow.Ellipsis ) } + username?.let { } Text( modifier = Modifier.padding(start = 10.dp, top = 4.dp), text = stringResource( diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt index aed323bd..f57a3f01 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -9,6 +9,8 @@ import org.grakovne.lissen.channel.common.ApiResult import org.grakovne.lissen.common.ColorScheme import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.domain.Library +import org.grakovne.lissen.domain.connection.ServerRequestHeader +import org.grakovne.lissen.domain.connection.ServerRequestHeader.Companion.clean import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import javax.inject.Inject @@ -33,6 +35,9 @@ class SettingsViewModel @Inject constructor( private val _preferredColorScheme = MutableLiveData(preferences.getColorScheme()) val preferredColorScheme = _preferredColorScheme + private val _customHeaders = MutableLiveData(preferences.getCustomHeaders()) + val customHeaders = _customHeaders + fun logout() { preferences.clearPreferences() @@ -74,4 +79,16 @@ class SettingsViewModel @Inject constructor( _preferredColorScheme.value = colorScheme preferences.saveColorScheme(colorScheme) } + + fun updateCustomHeaders(headers: List) { + _customHeaders.value = headers + + val meaningfulHeaders = headers + .map { it.clean() } + .distinctBy { it.name } + .filterNot { it.name.isEmpty() } + .filterNot { it.value.isEmpty() } + + preferences.saveCustomHeaders(meaningfulHeaders) + } } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9a2ab83e..854bd263 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -47,4 +47,9 @@ Список библиотек недоступен Поиск по автору или названию Сейчас играет + Прокси-заголовки + Заголовки для подключения к серверу + Ключ + Значение + Прокси-заголовки \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 862d33ba..a43d942f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,4 +47,9 @@ Library is not available Search by title or author Listening now + Custom Headers + Specify headers for server connections + Key + Value + Custom Headers \ No newline at end of file