diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 98842277..414ff717 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -72,12 +72,10 @@ body: description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* multiple: true options: + - 'Android App - 0.10.0' + - 'iOS App - 0.10.0' - 'Android App - 0.9.81' - 'iOS App - 0.9.81' - - 'Android App - 0.9.80' - - 'iOS App - 0.9.80' - - 'Android App - 0.9.79' - - 'iOS App - 0.9.79' validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4b38385a..fa6c9e9e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -43,12 +43,10 @@ body: description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* multiple: true options: + - 'Android App - 0.10.0' + - 'iOS App - 0.10.0' - 'Android App - 0.9.81' - 'iOS App - 0.9.81' - - 'Android App - 0.9.80' - - 'iOS App - 0.9.80' - - 'Android App - 0.9.79' - - 'iOS App - 0.9.79' validations: required: true - type: textarea diff --git a/android/app/build.gradle b/android/app/build.gradle index ea4b69d7..dce3b2e3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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" diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt index f77adbf6..086175d3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt @@ -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 ) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 401bc457..063ef5cb 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -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? + 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? ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 7b2dcf70..0b08f1aa 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -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}") } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index cde9318e..2a4b8b30 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -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 ?: "") diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index d31891ff..de931ca1 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -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. diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt new file mode 100644 index 00000000..c294f52e --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 39b3c25f..db3ee4ad 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -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() } /** diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index e37e7147..5e3af81e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -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?) } - } 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?) + } + 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?) } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library Log.d(tag, "Loading items for library $parentMediaId") diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index bcf0e34d..bb96e554 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -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) data class LocalLibraryItemsPayload(val value:List) data class LocalFoldersPayload(val value:List) - 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?) + 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?) 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 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) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 4086d6b7..d25e7446 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -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, 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")) { diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index 4f8bfb97..903fa337 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -4,14 +4,14 @@ - + arrow_back
-
+
+
@@ -21,19 +21,19 @@ -
+
+ - + search -
- menu -
+ @@ -100,14 +100,14 @@ export default { this.isCastAvailable = data && data.value } }, - mounted() { + async mounted() { AbsAudioPlayer.getIsCastAvailable().then((data) => { this.isCastAvailable = data && data.value }) - this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate) + this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate) }, beforeDestroy() { - if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove() + this.onCastAvailableUpdateListener?.remove() } } diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 7684fb90..ec129319 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -140,13 +140,6 @@ export default { readyTrackWidth: 0, seekedTime: 0, seekLoading: false, - onPlaybackSessionListener: null, - onPlaybackClosedListener: null, - onPlayingUpdateListener: null, - onMetadataListener: null, - onProgressSyncFailing: null, - onProgressSyncSuccess: null, - onPlaybackSpeedChangedListener: null, touchStartY: 0, touchStartTime: 0, playerSettings: { @@ -883,14 +876,14 @@ export default { async init() { await this.loadPlayerSettings() - this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) - this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) - this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) - this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) - this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata) - this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) - this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) - this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) + AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) + AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) + AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) + AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) + AbsAudioPlayer.addListener('onMetadata', this.onMetadata) + AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) + AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) + AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) }, async screenOrientationChange() { if (this.isRefreshingUI) return @@ -984,14 +977,9 @@ export default { document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchmove', this.touchmove) - if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove() - if (this.onMetadataListener) this.onMetadataListener.remove() - if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove() - if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove() - if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove() - if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove() - if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove() - if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove() + if (AbsAudioPlayer.removeAllListeners) { + AbsAudioPlayer.removeAllListeners() + } clearInterval(this.playInterval) } } diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 958fc968..1040e61b 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -351,11 +351,11 @@ export default { } } }, - mounted() { - this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate) - this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) - this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) - this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) + async mounted() { + this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate) + this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) + this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) + this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) @@ -370,10 +370,10 @@ export default { this.$eventBus.$on('device-focus-update', this.deviceFocused) }, beforeDestroy() { - if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() - if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() - if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() - if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() + this.onLocalMediaProgressUpdateListener?.remove() + this.onSleepTimerEndedListener?.remove() + this.onSleepTimerSetListener?.remove() + this.onMediaPlayerChangedListener?.remove() this.$eventBus.$off('abs-ui-ready', this.onReady) this.$eventBus.$off('play-item', this.playLibraryItem) diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index ee788317..58ce9979 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -11,11 +11,11 @@