Skip to content

Commit 438f26c

Browse files
committed
fix(android): lazy-init EncryptedSharedPreferences with Keystore recovery fallback
1 parent be0ae90 commit 438f26c

1 file changed

Lines changed: 39 additions & 24 deletions

File tree

  • android/app/src/main/java/org/onebusaway/vehiclepositions/util

android/app/src/main/java/org/onebusaway/vehiclepositions/util/TokenManager.kt

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,71 @@
11
package org.onebusaway.vehiclepositions.util
22

33
import android.content.Context
4+
import android.content.SharedPreferences
5+
import android.util.Log
46
import androidx.security.crypto.EncryptedSharedPreferences
57
import androidx.security.crypto.MasterKey
68
import dagger.hilt.android.qualifiers.ApplicationContext
79
import javax.inject.Inject
810
import javax.inject.Singleton
911

1012
/**
11-
* TokenManager
12-
* ─────────────────────────────────────────────────────────────────────────────
13-
* Secure storage for JWT access token and refresh token using
14-
* EncryptedSharedPreferences backed by Android Keystore (AES-256).
15-
*
13+
* Secure storage for JWT and refresh tokens.
1614
* IMPORTANT: This class is a storage facade only.
17-
* It never seeds or generates tokens.
1815
* The login flow (Milestone 2) is solely responsible for writing the
1916
* initial token via saveToken() after a successful /auth/login response.
2017
*/
2118
@Singleton
2219
class TokenManager @Inject constructor(
2320
@ApplicationContext private val context: Context
2421
) {
25-
private val masterKey = MasterKey.Builder(context)
26-
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
27-
.build()
28-
29-
private val prefs = EncryptedSharedPreferences.create(
30-
context,
31-
"secure_prefs",
32-
masterKey,
33-
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
34-
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
35-
)
36-
3722
companion object {
38-
private const val KEY_JWT = "jwt_token"
23+
private const val TAG = "TokenManager"
24+
private const val KEY_JWT = "jwt_token"
3925
private const val KEY_REFRESH_TOKEN = "refresh_token"
40-
private const val TAG = "TokenManager"
26+
private const val PREFS_FILE = "secure_prefs"
27+
}
28+
29+
// lazy init with keystore recovery fallback
30+
// MasterKey.Builder and EncryptedSharedPreferences can throw on
31+
// corrupted keystore - a well-documented issue on many devices
32+
private val prefs: SharedPreferences by lazy {
33+
try {
34+
val masterKey = MasterKey.Builder(context)
35+
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
36+
.build()
37+
EncryptedSharedPreferences.create(
38+
context,
39+
PREFS_FILE,
40+
masterKey,
41+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
42+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
43+
)
44+
} catch (e: Exception) {
45+
// Keystore corrupted - clear and retry once
46+
Log.e(TAG, "Keystore error — attempting recovery: ${e.message}")
47+
context.deleteSharedPreferences(PREFS_FILE)
48+
val masterKey = MasterKey.Builder(context)
49+
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
50+
.build()
51+
EncryptedSharedPreferences.create(
52+
context,
53+
PREFS_FILE,
54+
masterKey,
55+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
56+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
57+
)
58+
}
4159
}
4260

4361
fun saveToken(token: String) {
4462
prefs.edit().putString(KEY_JWT, token).apply()
45-
// Never log the token value — it is a security credential
46-
android.util.Log.d(TAG, "Access token saved")
4763
}
4864

4965
fun getToken(): String? = prefs.getString(KEY_JWT, null)
5066

5167
fun saveRefreshToken(token: String) {
5268
prefs.edit().putString(KEY_REFRESH_TOKEN, token).apply()
53-
android.util.Log.d(TAG, "Refresh token saved")
5469
}
5570

5671
fun getRefreshToken(): String? = prefs.getString(KEY_REFRESH_TOKEN, null)
@@ -60,7 +75,7 @@ class TokenManager @Inject constructor(
6075
.remove(KEY_JWT)
6176
.remove(KEY_REFRESH_TOKEN)
6277
.apply()
63-
android.util.Log.d(TAG, "JWT and refresh token cleared from device")
78+
Log.d(TAG, "JWT cleared from device")
6479
}
6580

6681
fun isLoggedIn(): Boolean = getToken() != null

0 commit comments

Comments
 (0)