mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-31 07:37:22 +02:00
Merge master
This commit is contained in:
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 112
|
||||
versionName "0.9.81-beta"
|
||||
versionCode 113
|
||||
versionName "0.10.0-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
|
||||
@@ -14,6 +14,7 @@ data class AudioTrack(
|
||||
var metadata: FileMetadata?,
|
||||
var isLocal: Boolean,
|
||||
var localFileId: String?,
|
||||
// TODO: This should no longer be necessary
|
||||
var serverIndex: Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
|
||||
@@ -46,15 +46,18 @@ enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||
DESC
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ServerConnectionConfig(
|
||||
var id: String,
|
||||
var index: Int,
|
||||
var name: String,
|
||||
var address: String,
|
||||
var userId: String,
|
||||
var username: String,
|
||||
var token: String,
|
||||
var customHeaders: Map<String, String>?
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var name:String,
|
||||
var address:String,
|
||||
// version added after 0.9.81-beta
|
||||
var version:String?,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String,
|
||||
var customHeaders:Map<String, String>?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -53,6 +53,11 @@ class LibraryItem(
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
// As of v2.17.0 token is not needed with cover image requests
|
||||
if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) {
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover")
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,16 @@ class PlaybackSession(
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun checkIsServerVersionGte(compareVersion: String): Boolean {
|
||||
// Safety check this playback session is the same one currently connected (should always be)
|
||||
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(ctx: Context): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
@@ -168,12 +178,27 @@ class PlaybackSession(
|
||||
|
||||
if (coverPath == null)
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
|
||||
// As of v2.17.0 token is not needed with cover image requests
|
||||
if (checkIsServerVersionGte("2.17.0")) {
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||
}
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getContentUri(audioTrack: AudioTrack): Uri {
|
||||
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
||||
// As of v2.22.0 tracks use a different endpoint
|
||||
// See: https://github.com/advplyr/audiobookshelf/pull/4263
|
||||
if (checkIsServerVersionGte("2.22.0")) {
|
||||
return if (isDirectPlay) {
|
||||
Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}")
|
||||
} else {
|
||||
// Transcode uses HlsRouter on server
|
||||
Uri.parse("$serverAddress${audioTrack.contentUrl}")
|
||||
}
|
||||
}
|
||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@@ -264,14 +289,16 @@ class PlaybackSession(
|
||||
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
||||
)
|
||||
|
||||
// As of v2.17.0 token is not needed with cover image requests
|
||||
val coverUri = if (checkIsServerVersionGte("2.17.0")) {
|
||||
Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||
} else {
|
||||
Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
// Cast always uses server cover uri
|
||||
coverPath?.let {
|
||||
castMetadata.addImage(
|
||||
WebImage(
|
||||
Uri.parse(
|
||||
"$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"
|
||||
)
|
||||
)
|
||||
)
|
||||
castMetadata.addImage(WebImage(coverUri))
|
||||
}
|
||||
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||
|
||||
@@ -41,6 +41,7 @@ object DeviceManager {
|
||||
get() = serverConnectionConfig?.userId ?: ""
|
||||
val token
|
||||
get() = serverConnectionConfig?.token ?: ""
|
||||
val serverVersion get() = serverConnectionConfig?.version ?: ""
|
||||
val isConnectedToServer
|
||||
get() = serverConnectionConfig != null
|
||||
|
||||
@@ -111,6 +112,41 @@ object DeviceManager {
|
||||
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently connected server version is >= compareVersion
|
||||
* Abs server only uses major.minor.patch
|
||||
* Note: Version is returned in Abs auth payloads starting v2.6.0
|
||||
* Note: Version is saved with the server connection config starting after v0.9.81
|
||||
*
|
||||
* @example
|
||||
* serverVersion=2.25.1
|
||||
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
|
||||
*
|
||||
* serverVersion=2.26.1
|
||||
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
|
||||
*/
|
||||
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
|
||||
if (serverVersion == "") return false
|
||||
if (compareVersion == "") return true
|
||||
|
||||
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
|
||||
// Compare major, minor, and patch components
|
||||
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
|
||||
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
|
||||
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
|
||||
|
||||
if (serverVersionComponent < compareVersionComponent) {
|
||||
return false // Server version is less than compareVersion
|
||||
} else if (serverVersionComponent > compareVersionComponent) {
|
||||
return true // Server version is greater than compareVersion
|
||||
}
|
||||
}
|
||||
|
||||
return true // versions are equal in major, minor, and patch
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the network connectivity status.
|
||||
* @param ctx The context to use for checking connectivity.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class SecureStorage(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "SecureStorage"
|
||||
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val IV_LENGTH = 12
|
||||
private const val TAG_LENGTH = 128
|
||||
}
|
||||
|
||||
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and stores a refresh token for a specific server connection
|
||||
*/
|
||||
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
|
||||
return try {
|
||||
val key = getOrCreateKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
|
||||
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||
val combined = cipher.iv + encryptedBytes
|
||||
|
||||
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
|
||||
|
||||
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and decrypts a refresh token for a specific server connection
|
||||
*/
|
||||
fun getRefreshToken(serverConnectionId: String): String? {
|
||||
return try {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
|
||||
|
||||
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||
val iv = combined.copyOfRange(0, IV_LENGTH)
|
||||
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
|
||||
|
||||
val key = getOrCreateKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(TAG_LENGTH, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
||||
|
||||
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||
String(decryptedBytes, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a refresh token for a specific server connection
|
||||
*/
|
||||
fun removeRefreshToken(serverConnectionId: String): Boolean {
|
||||
return try {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
|
||||
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a refresh token exists for a specific server connection
|
||||
*/
|
||||
fun hasRefreshToken(serverConnectionId: String): Boolean {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
return sharedPrefs.contains("refresh_token_$serverConnectionId")
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
return if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||
} else {
|
||||
createKey()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createKey(): SecretKey {
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||
val keyGenSpec = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setUserAuthenticationRequired(false)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
|
||||
keyGenerator.init(keyGenSpec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val tag = "MediaManager"
|
||||
@@ -175,21 +176,20 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
* Load personalized shelves from server for all libraries.
|
||||
* [cb] resolves when all libraries are processed
|
||||
*/
|
||||
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit ) {
|
||||
serverLibraries.forEach {
|
||||
libraryPersonalizationsDone++
|
||||
Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
||||
populatePersonalizedDataForLibrary(it.id) {
|
||||
Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
||||
libraryPersonalizationsDone--
|
||||
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
|
||||
val remaining = AtomicInteger(serverLibraries.size)
|
||||
|
||||
serverLibraries.forEach { lib ->
|
||||
Log.d(tag, "Loading personalization for library ${lib.name}")
|
||||
populatePersonalizedDataForLibrary(lib.id) {
|
||||
Log.d(tag, "Loaded personalization for library ${lib.name}")
|
||||
if (remaining.decrementAndGet() == 0) {
|
||||
Log.d(tag, "Finished loading all library personalization data")
|
||||
allLibraryPersonalizationsDone = true
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (libraryPersonalizationsDone > 0) { }
|
||||
|
||||
Log.d(tag, "Finished loading all library personalization data")
|
||||
allLibraryPersonalizationsDone = true
|
||||
cb()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+52
-12
@@ -1087,6 +1087,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||
private val CONTINUE_ROOT = "__CONTINUE__"
|
||||
private lateinit var browseTree: BrowseTree
|
||||
private val browseTreeInitListeners = mutableListOf<() -> Unit>()
|
||||
|
||||
private fun waitForBrowseTree(cb: () -> Unit)
|
||||
{
|
||||
if (this::browseTree.isInitialized)
|
||||
{
|
||||
cb()
|
||||
}
|
||||
else
|
||||
{
|
||||
browseTreeInitListeners += cb
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBrowseTreeInitialized()
|
||||
{
|
||||
// Called after browseTree is assigned for the first time
|
||||
browseTreeInitListeners.forEach { it.invoke() }
|
||||
browseTreeInitListeners.clear()
|
||||
}
|
||||
|
||||
// Only allowing android auto or similar to access media browser service
|
||||
// normal loading of audiobooks is handled in webview (not natively)
|
||||
@@ -1257,6 +1277,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaManager.serverLibraries,
|
||||
mediaManager.allLibraryPersonalizationsDone
|
||||
)
|
||||
onBrowseTreeInitialized()
|
||||
val children =
|
||||
browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "Found top menu item: ${item.description.title}")
|
||||
@@ -1291,6 +1312,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaManager.serverLibraries,
|
||||
mediaManager.allLibraryPersonalizationsDone
|
||||
)
|
||||
onBrowseTreeInitialized()
|
||||
val children =
|
||||
browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "Found top menu item: ${item.description.title}")
|
||||
@@ -1303,22 +1325,40 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
AbsLogger.info(tag, "onLoadChildren: Android auto data loaded")
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) {
|
||||
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT)
|
||||
{
|
||||
Log.d(tag, "First load done: $firstLoadDone")
|
||||
if (!firstLoadDone) {
|
||||
if (!firstLoadDone)
|
||||
{
|
||||
result.sendResult(null)
|
||||
return
|
||||
}
|
||||
// Wait until top-menu is initialized
|
||||
while (!this::browseTree.isInitialized) {}
|
||||
val children =
|
||||
browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||
MediaBrowserCompat.MediaItem(
|
||||
item.description,
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
}
|
||||
|
||||
if (!this::browseTree.isInitialized)
|
||||
{
|
||||
// ✅ good: detach and wait for init
|
||||
result.detach()
|
||||
waitForBrowseTree {
|
||||
val children = browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||
MediaBrowserCompat.MediaItem(
|
||||
item.description,
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Already initialized: just return
|
||||
val children = browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||
MediaBrowserCompat.MediaItem(
|
||||
item.description,
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||
Log.d(tag, "Loading items for library $parentMediaId")
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
@@ -22,17 +23,21 @@ class AbsDatabase : Plugin() {
|
||||
val tag = "AbsDatabase"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var apiHandler: ApiHandler
|
||||
private lateinit var mainActivity: MainActivity
|
||||
private lateinit var apiHandler: ApiHandler
|
||||
private lateinit var secureStorage: SecureStorage
|
||||
|
||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map<String,String>?)
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
|
||||
|
||||
secureStorage = SecureStorage(mainActivity)
|
||||
|
||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||
@@ -120,7 +125,9 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
val userId = serverConfigPayload.userId
|
||||
val username = serverConfigPayload.username
|
||||
val token = serverConfigPayload.token
|
||||
val serverVersion = serverConfigPayload.version
|
||||
val accessToken = serverConfigPayload.token
|
||||
val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (serverConnectionConfig == null) { // New Server Connection
|
||||
@@ -129,7 +136,16 @@ class AbsDatabase : Plugin() {
|
||||
// Create new server connection config
|
||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
||||
|
||||
// Store refresh token securely if provided
|
||||
val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) {
|
||||
secureStorage.storeRefreshToken(sscId, refreshToken)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
Log.d(tag, "Refresh token secured = $hasRefreshToken")
|
||||
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders)
|
||||
|
||||
// Add and save
|
||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||
@@ -137,14 +153,21 @@ class AbsDatabase : Plugin() {
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
} else {
|
||||
var shouldSave = false
|
||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) {
|
||||
serverConnectionConfig?.userId = userId
|
||||
serverConnectionConfig?.username = username
|
||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
||||
serverConnectionConfig?.token = token
|
||||
serverConnectionConfig?.version = serverVersion
|
||||
serverConnectionConfig?.token = accessToken
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
// Update refresh token if provided
|
||||
if (!refreshToken.isNullOrEmpty()) {
|
||||
val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken)
|
||||
Log.d(tag, "Refresh token secured = $stored")
|
||||
}
|
||||
|
||||
// Set last connection config
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||
@@ -163,6 +186,10 @@ class AbsDatabase : Plugin() {
|
||||
fun removeServerConnectionConfig(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
// Remove refresh token if it exists
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
|
||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
@@ -175,6 +202,42 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getRefreshToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (refreshToken != null) {
|
||||
val result = JSObject()
|
||||
result.put("refreshToken", refreshToken)
|
||||
call.resolve(result)
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearRefreshToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
val result = JSObject()
|
||||
result.put("success", refreshToken)
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getAccessToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
||||
val token = serverConnectionConfig?.token ?: ""
|
||||
val ret = JSObject()
|
||||
ret.put("token", token)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun logout(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -33,9 +34,19 @@ import java.util.concurrent.TimeUnit
|
||||
class ApiHandler(var ctx:Context) {
|
||||
val tag = "ApiHandler"
|
||||
|
||||
companion object {
|
||||
// For sending data back to the Webview frontend
|
||||
lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit
|
||||
|
||||
fun checkAbsDatabaseNotifyListenersInitted():Boolean {
|
||||
return ::absDatabaseNotifyListeners.isInitialized
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultClient = OkHttpClient()
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var secureStorage = SecureStorage(ctx)
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -110,6 +121,13 @@ class ApiHandler(var ctx:Context) {
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.code == 401) {
|
||||
// Handle 401 Unauthorized by attempting token refresh
|
||||
AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh")
|
||||
handleTokenRefresh(request, httpClient, cb)
|
||||
return
|
||||
}
|
||||
|
||||
if (!it.isSuccessful) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Unexpected code $response")
|
||||
@@ -142,6 +160,269 @@ class ApiHandler(var ctx:Context) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles token refresh when a 401 Unauthorized response is received
|
||||
* This function will:
|
||||
* 1. Get the refresh token from secure storage for the current server connection
|
||||
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||
* 3. Update the stored tokens with the new access token
|
||||
* 4. Retry the original request with the new access token
|
||||
* 5. If refresh fails, handle logout
|
||||
*
|
||||
* @param originalRequest The original request that failed with 401
|
||||
* @param httpClient The HTTP client to use for the request
|
||||
* @param callback The callback to return the response
|
||||
*/
|
||||
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||
|
||||
// Get current server connection config ID
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No server connection available")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
// Get refresh token from secure storage
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (refreshToken.isNullOrEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No refresh token available")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||
|
||||
// Create refresh token request
|
||||
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
||||
val refreshRequest = Request.Builder()
|
||||
.url(refreshEndpoint)
|
||||
.addHeader("x-refresh-token", refreshToken)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(EMPTY_REQUEST)
|
||||
.build()
|
||||
|
||||
// Make the refresh request
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(refreshRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
try {
|
||||
val responseJson = JSONObject(bodyString)
|
||||
val userObj = responseJson.optJSONObject("user")
|
||||
|
||||
if (userObj == null) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
val newAccessToken = userObj.optString("accessToken")
|
||||
val newRefreshToken = userObj.optString("refreshToken")
|
||||
|
||||
if (newAccessToken.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
|
||||
|
||||
// Update tokens in secure storage and device manager
|
||||
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||
|
||||
// Retry the original request with the new access token
|
||||
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
|
||||
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored tokens with new access and refresh tokens
|
||||
*
|
||||
* @param newAccessToken The new access token
|
||||
* @param newRefreshToken The new refresh token (or existing one if not provided)
|
||||
*/
|
||||
private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
|
||||
try {
|
||||
// Update the refresh token in secure storage if it's new
|
||||
if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) {
|
||||
secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken)
|
||||
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||
}
|
||||
|
||||
// Update the access token in the current server connection config
|
||||
DeviceManager.serverConnectionConfig?.let { config ->
|
||||
config.token = newAccessToken
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
||||
}
|
||||
|
||||
// Send access token to Webview frontend
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("accessToken", newAccessToken)
|
||||
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||
} else {
|
||||
// Can happen if Webview is never run
|
||||
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token")
|
||||
}
|
||||
AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "updateTokens: Failed to update tokens", e)
|
||||
AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the original request with the new access token
|
||||
*
|
||||
* @param originalRequest The original request to retry
|
||||
* @param newAccessToken The new access token to use
|
||||
* @param httpClient The HTTP client to use
|
||||
* @param callback The callback to return the response
|
||||
*/
|
||||
private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
// Create a new request with the updated authorization header
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.removeHeader("Authorization")
|
||||
.addHeader("Authorization", "Bearer $newAccessToken")
|
||||
.build()
|
||||
|
||||
Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}")
|
||||
|
||||
// Make the retry request
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(newRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "retryOriginalRequest: Failed to retry request", e)
|
||||
AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Failed to retry request after token refresh")
|
||||
callback(errorObj)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}")
|
||||
AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Retry request failed with status ${it.code}")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
if (bodyString == "OK") {
|
||||
callback(JSObject())
|
||||
} else {
|
||||
try {
|
||||
var jsonObj = JSObject()
|
||||
if (bodyString.startsWith("[")) {
|
||||
val array = JSArray(bodyString)
|
||||
jsonObj.put("value", array)
|
||||
} else {
|
||||
jsonObj = JSObject(bodyString)
|
||||
}
|
||||
callback(jsonObj)
|
||||
} catch(je:JSONException) {
|
||||
Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Invalid response body")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e)
|
||||
AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Failed to retry request")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the case when token refresh fails
|
||||
* This will clear the current session and notify the callback
|
||||
*
|
||||
* @param callback The callback to return the error
|
||||
*/
|
||||
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
||||
|
||||
// Clear the current server connection
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Remove refresh token from secure storage
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isNotEmpty()) {
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
}
|
||||
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed - please login again")
|
||||
callback(errorObj)
|
||||
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("error", "Token refresh failed")
|
||||
if (serverConnectionConfigId.isNotEmpty()) {
|
||||
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
|
||||
}
|
||||
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
||||
} else {
|
||||
// Can happen if Webview is never run
|
||||
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
|
||||
Reference in New Issue
Block a user