Skip to content

Commit 2ba92a0

Browse files
committed
implement refresh token handling and update authentication flow; improve network security configuration
1 parent 85bae09 commit 2ba92a0

22 files changed

Lines changed: 266 additions & 58 deletions

src/app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ android {
1414
minSdk = 21
1515
targetSdk = 35
1616
versionCode = 11
17-
versionName = "1.6.2"
17+
versionName = "1.7.0"
1818

1919
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2020
vectorDrawables {

src/app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
android:roundIcon="@mipmap/ic_launcher_round"
2222
android:supportsRtl="true"
2323
android:theme="@style/Theme.MelodeeAndroidAuto"
24-
android:usesCleartextTraffic="true"
24+
android:networkSecurityConfig="@xml/network_security_config"
25+
android:usesCleartextTraffic="false"
2526
tools:targetApi="31">
2627

2728

@@ -122,4 +123,4 @@
122123
android:name="android.app.searchable"
123124
android:resource="@xml/searchable" />
124125
</application>
125-
</manifest>
126+
</manifest>

src/app/src/main/java/com/melodee/autoplayer/data/AuthenticationManager.kt

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ class AuthenticationManager(private val context: Context) {
2525
NetworkModule.setAuthenticationFailureCallback {
2626
handleAuthenticationFailure()
2727
}
28+
29+
// Persist refreshed tokens when NetworkModule updates them
30+
NetworkModule.setTokenUpdateCallback { token, refresh ->
31+
settingsManager.authToken = token
32+
settingsManager.refreshToken = refresh
33+
}
2834
}
2935

3036
private fun checkExistingAuthentication() {
@@ -65,8 +71,8 @@ class AuthenticationManager(private val context: Context) {
6571
Log.d("AuthenticationManager", "Setting NetworkModule base URL: ${settingsManager.serverUrl}")
6672
NetworkModule.setBaseUrl(settingsManager.serverUrl)
6773

68-
Log.d("AuthenticationManager", "Setting NetworkModule auth token")
69-
NetworkModule.setAuthToken(settingsManager.authToken)
74+
Log.d("AuthenticationManager", "Setting NetworkModule auth/refresh tokens")
75+
NetworkModule.setTokens(settingsManager.authToken, settingsManager.refreshToken)
7076

7177
// Verify NetworkModule state
7278
val networkAuthenticated = NetworkModule.isAuthenticated()
@@ -91,17 +97,29 @@ class AuthenticationManager(private val context: Context) {
9197
userEmail: String,
9298
username: String,
9399
serverUrl: String,
100+
refreshToken: String = "",
101+
refreshTokenExpiresAt: String = "",
94102
thumbnailUrl: String = "",
95103
imageUrl: String = ""
96104
) {
97105
Log.d("AuthenticationManager", "Saving authentication for user: $username")
98106

99107
// Save to persistent storage
100-
settingsManager.saveAuthenticationData(token, userId, userEmail, username, serverUrl, thumbnailUrl, imageUrl)
108+
settingsManager.saveAuthenticationData(
109+
token,
110+
userId,
111+
userEmail,
112+
username,
113+
serverUrl,
114+
refreshToken,
115+
refreshTokenExpiresAt,
116+
thumbnailUrl,
117+
imageUrl
118+
)
101119

102120
// Update NetworkModule
103121
NetworkModule.setBaseUrl(serverUrl)
104-
NetworkModule.setAuthToken(token)
122+
NetworkModule.setTokens(token, refreshToken)
105123

106124
// Update state
107125
_isAuthenticated.value = true

src/app/src/main/java/com/melodee/autoplayer/data/SettingsManager.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ class SettingsManager(context: Context) {
4343
prefs.edit().putString(KEY_AUTH_TOKEN, value).apply()
4444
}
4545

46+
var refreshToken: String
47+
get() = prefs.getString(KEY_REFRESH_TOKEN, "") ?: ""
48+
set(value) = prefs.edit().putString(KEY_REFRESH_TOKEN, value).apply()
49+
50+
var refreshTokenExpiresAt: String
51+
get() = prefs.getString(KEY_REFRESH_TOKEN_EXPIRES_AT, "") ?: ""
52+
set(value) = prefs.edit().putString(KEY_REFRESH_TOKEN_EXPIRES_AT, value).apply()
53+
4654
// Check if user is currently authenticated
4755
fun isAuthenticated(): Boolean {
4856
val hasToken = authToken.isNotEmpty()
@@ -64,6 +72,8 @@ class SettingsManager(context: Context) {
6472
userEmail: String,
6573
username: String,
6674
serverUrl: String,
75+
refreshToken: String = "",
76+
refreshTokenExpiresAt: String = "",
6777
thumbnailUrl: String = "",
6878
imageUrl: String = ""
6979
) {
@@ -75,9 +85,12 @@ class SettingsManager(context: Context) {
7585
Log.i("SettingsManager", "Server URL: $serverUrl")
7686
Log.i("SettingsManager", "Thumbnail URL: $thumbnailUrl")
7787
Log.i("SettingsManager", "Image URL: $imageUrl")
88+
Log.i("SettingsManager", "Refresh token present: ${refreshToken.isNotEmpty()}")
7889

7990
val editor = prefs.edit()
8091
editor.putString(KEY_AUTH_TOKEN, token)
92+
editor.putString(KEY_REFRESH_TOKEN, refreshToken)
93+
editor.putString(KEY_REFRESH_TOKEN_EXPIRES_AT, refreshTokenExpiresAt)
8194
editor.putString(KEY_USER_ID, userId)
8295
editor.putString(KEY_USER_EMAIL, userEmail)
8396
editor.putString(KEY_USER_NAME, username)
@@ -106,6 +119,8 @@ class SettingsManager(context: Context) {
106119
.remove(KEY_USER_THUMBNAIL_URL)
107120
.remove(KEY_USER_IMAGE_URL)
108121
.remove(KEY_AUTH_TOKEN)
122+
.remove(KEY_REFRESH_TOKEN)
123+
.remove(KEY_REFRESH_TOKEN_EXPIRES_AT)
109124
.apply()
110125
}
111126

@@ -118,5 +133,7 @@ class SettingsManager(context: Context) {
118133
private const val KEY_USER_THUMBNAIL_URL = "user_thumbnail_url"
119134
private const val KEY_USER_IMAGE_URL = "user_image_url"
120135
private const val KEY_AUTH_TOKEN = "auth_token"
136+
private const val KEY_REFRESH_TOKEN = "refresh_token"
137+
private const val KEY_REFRESH_TOKEN_EXPIRES_AT = "refresh_token_expires_at"
121138
}
122-
}
139+
}

src/app/src/main/java/com/melodee/autoplayer/data/api/MusicApi.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ interface MusicApi {
99
@Body credentials: LoginModel
1010
): AuthenticationResponse
1111

12+
@GET("api/v1/system/info")
13+
suspend fun getSystemInfo(): ServerInfo
14+
15+
@POST("api/v1/auth/refresh")
16+
suspend fun refresh(
17+
@Body request: RefreshRequest,
18+
@Header("X-Refresh-Request") skipAuthHeader: Boolean = true
19+
): AuthenticationResponse
20+
1221
// Fetch current authenticated user profile
1322
@GET("api/v1/user/me")
1423
suspend fun getCurrentUser(): User

src/app/src/main/java/com/melodee/autoplayer/data/api/NetworkModule.kt

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import com.google.gson.stream.JsonReader
77
import com.google.gson.stream.JsonToken
88
import com.google.gson.stream.JsonWriter
99
import com.melodee.autoplayer.util.Logger
10+
import com.melodee.autoplayer.domain.model.RefreshRequest
11+
import kotlinx.coroutines.runBlocking
1012
import okhttp3.OkHttpClient
1113
import okhttp3.logging.HttpLoggingInterceptor
1214
import retrofit2.Retrofit
@@ -24,6 +26,7 @@ object NetworkModule {
2426
private var appContext: android.content.Context? = null
2527
private var baseUrl: String = ""
2628
private var authToken: String = ""
29+
private var refreshToken: String = ""
2730
private var retrofit: Retrofit? = null
2831
private var musicApi: MusicApi? = null
2932
private var scrobbleApi: ScrobbleApi? = null
@@ -34,9 +37,12 @@ object NetworkModule {
3437
// Callback for handling authentication failures
3538
@Volatile
3639
private var onAuthenticationFailure: (() -> Unit)? = null
40+
@Volatile
41+
private var onTokenUpdated: ((String, String) -> Unit)? = null
3742

3843
// Thread-safe flag to prevent multiple 401 callbacks
3944
private val handlingAuthFailure = AtomicBoolean(false)
45+
private val refreshLock = Any()
4046

4147
fun init(context: android.content.Context) {
4248
// Keep application context only
@@ -63,21 +69,26 @@ object NetworkModule {
6369
}
6470
}
6571

66-
fun setAuthToken(token: String) {
72+
fun setTokens(token: String, refresh: String) {
6773
configLock.write {
68-
Logger.logAuth("NetworkModule", "Setting auth token (present: ${token.isNotEmpty()})")
69-
74+
Logger.logAuth("NetworkModule", "Setting auth token (present: ${token.isNotEmpty()}); refresh present: ${refresh.isNotEmpty()}")
7075
authToken = token
71-
// Reset auth failure flag when setting new token
76+
refreshToken = refresh
7277
handlingAuthFailure.set(false)
73-
// Recreate the Retrofit instance to update the auth token
74-
Logger.d("NetworkModule", "Recreating Retrofit instance with new token")
78+
Logger.d("NetworkModule", "Recreating Retrofit instance with updated tokens")
7579
createRetrofitInstance()
76-
77-
Logger.logAuth("NetworkModule", "Auth token configured")
80+
Logger.logAuth("NetworkModule", "Tokens configured")
7881
}
7982
}
8083

84+
fun setAuthToken(token: String) {
85+
setTokens(token, refreshToken)
86+
}
87+
88+
fun setRefreshToken(token: String) {
89+
setTokens(authToken, token)
90+
}
91+
8192
fun getAuthToken(): String? {
8293
return configLock.read {
8394
if (authToken.isNotEmpty()) authToken else null
@@ -88,11 +99,16 @@ object NetworkModule {
8899
fun setAuthenticationFailureCallback(callback: () -> Unit) {
89100
onAuthenticationFailure = callback
90101
}
102+
103+
fun setTokenUpdateCallback(callback: (newAccessToken: String, newRefreshToken: String) -> Unit) {
104+
onTokenUpdated = callback
105+
}
91106

92107
// Clear authentication
93108
fun clearAuthentication() {
94109
configLock.write {
95110
authToken = ""
111+
refreshToken = ""
96112
handlingAuthFailure.set(false)
97113
createRetrofitInstance()
98114
}
@@ -101,7 +117,7 @@ object NetworkModule {
101117
// Check if authenticated
102118
fun isAuthenticated(): Boolean {
103119
return configLock.read {
104-
authToken.isNotEmpty() && baseUrl.isNotEmpty()
120+
(authToken.isNotEmpty() || refreshToken.isNotEmpty()) && baseUrl.isNotEmpty()
105121
}
106122
}
107123

@@ -126,23 +142,33 @@ object NetworkModule {
126142
.connectionPool(connectionPool)
127143
.addInterceptor { chain ->
128144
val original = chain.request()
129-
val request = original.newBuilder()
130-
.header("Authorization", "Bearer $authToken")
131-
// Pass through request priority if set by callers via header
132-
// e.g., request.header("X-Request-Priority")
145+
val skipAuth = original.header("X-Refresh-Request") == "true"
146+
val requestBuilder = original.newBuilder()
133147
.method(original.method, original.body)
134-
.build()
135-
136-
val response = chain.proceed(request)
137-
138-
// Handle 401 Unauthorized responses
139-
if (response.code == 401 && handlingAuthFailure.compareAndSet(false, true)) {
140-
Log.w("NetworkModule", "Received 401 Unauthorized - token expired")
141-
// Clear authentication and notify callback
142-
clearAuthentication()
143-
onAuthenticationFailure?.invoke()
148+
149+
if (!skipAuth && authToken.isNotEmpty()) {
150+
requestBuilder.header("Authorization", "Bearer $authToken")
151+
}
152+
153+
var response = chain.proceed(requestBuilder.build())
154+
155+
// Handle 401 Unauthorized responses with a single refresh attempt
156+
if (!skipAuth && response.code == 401) {
157+
response.close()
158+
val refreshed = attemptTokenRefresh()
159+
if (refreshed) {
160+
val retry = original.newBuilder()
161+
.method(original.method, original.body)
162+
.header("Authorization", "Bearer $authToken")
163+
.build()
164+
response = chain.proceed(retry)
165+
} else if (handlingAuthFailure.compareAndSet(false, true)) {
166+
Log.w("NetworkModule", "Received 401 Unauthorized - token expired and refresh failed")
167+
clearAuthentication()
168+
onAuthenticationFailure?.invoke()
169+
}
144170
}
145-
171+
146172
response
147173
}
148174
// Retry with simple exponential backoff for idempotent requests and 429/5xx
@@ -220,6 +246,24 @@ object NetworkModule {
220246
scrobbleApi = retrofit?.create(ScrobbleApi::class.java)
221247
}
222248

249+
private fun attemptTokenRefresh(): Boolean {
250+
synchronized(refreshLock) {
251+
if (refreshToken.isEmpty() || retrofit == null) return false
252+
return try {
253+
val api = retrofit!!.create(MusicApi::class.java)
254+
val refreshResponse = runBlocking {
255+
api.refresh(RefreshRequest(refreshToken), skipAuthHeader = true)
256+
}
257+
setTokens(refreshResponse.token, refreshResponse.refreshToken)
258+
onTokenUpdated?.invoke(refreshResponse.token, refreshResponse.refreshToken)
259+
true
260+
} catch (e: Exception) {
261+
Log.e("NetworkModule", "Token refresh failed", e)
262+
false
263+
}
264+
}
265+
}
266+
223267
fun getMusicApi(): MusicApi {
224268
if (musicApi == null) {
225269
throw IllegalStateException("MusicApi not initialized. Call setBaseUrl first.")

src/app/src/main/java/com/melodee/autoplayer/data/repository/MusicRepository.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.melodee.autoplayer.domain.model.AuthResponse
1010
import com.melodee.autoplayer.domain.model.LoginModel
1111
import com.melodee.autoplayer.domain.model.PaginatedResponse
1212
import com.melodee.autoplayer.domain.model.Playlist
13+
import com.melodee.autoplayer.domain.model.ServerInfo
1314
import com.melodee.autoplayer.domain.model.Song
1415
import com.melodee.autoplayer.domain.model.User
1516
import kotlinx.coroutines.flow.Flow
@@ -27,15 +28,40 @@ class MusicRepository(private val baseUrl: String, private val context: Context)
2728

2829
private val deduplicator = RequestDeduplicator.getInstance()
2930

31+
suspend fun getSystemInfo(): ServerInfo {
32+
return ErrorHandler.handleOperation(context, "getSystemInfo", "MusicRepository") {
33+
api.getSystemInfo()
34+
}
35+
}
36+
37+
suspend fun validateServerVersion(): Boolean {
38+
return try {
39+
val serverInfo = getSystemInfo()
40+
if (!serverInfo.isCompatibleVersion()) {
41+
throw IllegalStateException(
42+
"Server API version ${serverInfo.getVersionString()} is not compatible. " +
43+
"Minimum required version is 1.2.0"
44+
)
45+
}
46+
true
47+
} catch (e: IllegalStateException) {
48+
throw e
49+
} catch (e: Exception) {
50+
throw Exception("Failed to validate server version: ${e.message}", e)
51+
}
52+
}
53+
3054
fun login(emailOrUsername: String, password: String): Flow<AuthResponse> = flow {
55+
validateServerVersion()
56+
3157
val response = ErrorHandler.handleOperation(context, "login", "MusicRepository") {
3258
val loginModel = if (emailOrUsername.contains("@")) {
3359
LoginModel(email = emailOrUsername, password = password)
3460
} else {
3561
LoginModel(userName = emailOrUsername, password = password)
3662
}
3763
val response = api.login(loginModel)
38-
NetworkModule.setAuthToken(response.token)
64+
NetworkModule.setTokens(response.token, response.refreshToken)
3965
response
4066
}
4167
emit(response)

0 commit comments

Comments
 (0)