Remove custom permission, fix client lifecycle and encrypted prefs

Remove USE_LCK_CONTROL permission from service to fix binding failures
caused by Android revoking custom permissions on app reinstall. Add
auto-cleanup of dead clients via RemoteCallbackList.onCallbackDied and
handle corrupted EncryptedSharedPreferences keyset gracefully. Lower
SDK minSdk to 32.
This commit is contained in:
2026-02-27 22:03:23 +01:00
parent 5b8ede3a19
commit ada06c6240
5 changed files with 53 additions and 19 deletions

View File

@@ -7,12 +7,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<permission
android:name="com.omixlab.lckcontrol.permission.USE_LCK_CONTROL"
android:label="Access LCK Control Service"
android:description="@string/permission_use_lck_control_desc"
android:protectionLevel="normal" />
<application <application
android:name=".LckControlApp" android:name=".LckControlApp"
android:allowBackup="true" android:allowBackup="true"
@@ -71,7 +65,6 @@
<service <service
android:name=".service.LckControlService" android:name=".service.LckControlService"
android:exported="true" android:exported="true"
android:permission="com.omixlab.lckcontrol.permission.USE_LCK_CONTROL"
android:foregroundServiceType="connectedDevice"> android:foregroundServiceType="connectedDevice">
<intent-filter> <intent-filter>
<action android:name="com.omixlab.lckcontrol.BIND" /> <action android:name="com.omixlab.lckcontrol.BIND" />

View File

@@ -1,6 +1,8 @@
package com.omixlab.lckcontrol.data.local package com.omixlab.lckcontrol.data.local
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys import androidx.security.crypto.MasterKeys
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -13,13 +15,22 @@ class TokenStore @Inject constructor(
) { ) {
private val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) private val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
private val prefs = EncryptedSharedPreferences.create( private val prefs: SharedPreferences = try {
"lck_control_tokens", createEncryptedPrefs(context)
masterKey, } catch (e: Exception) {
context, Log.w("TokenStore", "Corrupted keyset, clearing and recreating", e)
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().clear().commit()
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, createEncryptedPrefs(context)
) }
private fun createEncryptedPrefs(context: Context): SharedPreferences =
EncryptedSharedPreferences.create(
PREFS_NAME,
masterKey,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun getJwt(): String? = fun getJwt(): String? =
prefs.getString(KEY_JWT, null) prefs.getString(KEY_JWT, null)
@@ -44,6 +55,7 @@ class TokenStore @Inject constructor(
fun isLoggedIn(): Boolean = getJwt() != null fun isLoggedIn(): Boolean = getJwt() != null
companion object { companion object {
private const val PREFS_NAME = "lck_control_tokens"
private const val KEY_JWT = "session_jwt" private const val KEY_JWT = "session_jwt"
private const val KEY_REFRESH_TOKEN = "session_refresh_token" private const val KEY_REFRESH_TOKEN = "session_refresh_token"
} }

View File

@@ -7,6 +7,7 @@ data class ConnectedClient(
val clientId: String, val clientId: String,
val clientName: String, val clientName: String,
val packageName: String, val packageName: String,
val callingUid: Int = 0,
val activePlanId: String? = null, val activePlanId: String? = null,
val connectedAt: Long = System.currentTimeMillis(), val connectedAt: Long = System.currentTimeMillis(),
) )
@@ -15,18 +16,33 @@ class ClientTracker {
private val clients = ConcurrentHashMap<String, ConnectedClient>() private val clients = ConcurrentHashMap<String, ConnectedClient>()
fun register(clientName: String, packageName: String): String { fun register(clientName: String, packageName: String, callingUid: Int): String {
// Remove existing client with same packageName to prevent duplicates on reconnect
clients.entries.removeIf { it.value.packageName == packageName }
val clientId = UUID.randomUUID().toString() val clientId = UUID.randomUUID().toString()
clients[clientId] = ConnectedClient( clients[clientId] = ConnectedClient(
clientId = clientId, clientId = clientId,
clientName = clientName, clientName = clientName,
packageName = packageName, packageName = packageName,
callingUid = callingUid,
) )
return clientId return clientId
} }
fun unregister(clientId: String): ConnectedClient? = clients.remove(clientId) fun unregister(clientId: String): ConnectedClient? = clients.remove(clientId)
fun unregisterByUid(uid: Int): List<ConnectedClient> {
val removed = mutableListOf<ConnectedClient>()
clients.entries.removeIf { entry ->
if (entry.value.callingUid == uid) {
removed.add(entry.value)
true
} else false
}
return removed
}
fun setActivePlan(clientId: String, planId: String?) { fun setActivePlan(clientId: String, planId: String?) {
clients.computeIfPresent(clientId) { _, client -> clients.computeIfPresent(clientId) { _, client ->
client.copy(activePlanId = planId) client.copy(activePlanId = planId)

View File

@@ -57,7 +57,18 @@ class LckControlService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val clientTracker = ClientTracker() private val clientTracker = ClientTracker()
private val callbacks = RemoteCallbackList<ILckControlCallback>() private val callbacks = object : RemoteCallbackList<ILckControlCallback>() {
override fun onCallbackDied(callback: ILckControlCallback, cookie: Any?) {
val uid = cookie as? Int ?: return
serviceScope.launch {
val removed = clientTracker.unregisterByUid(uid)
for (client in removed) {
Log.d(TAG, "Auto-unregistered client ${client.clientId} (${client.packageName}) - process died")
broadcastClientUnregistered(client.clientId)
}
}
}
}
private val binder = object : ILckControlService.Stub() { private val binder = object : ILckControlService.Stub() {
@@ -146,7 +157,8 @@ class LckControlService : Service() {
// ── Clients ───────────────────────────────────────── // ── Clients ─────────────────────────────────────────
override fun registerClient(clientName: String, packageName: String): String { override fun registerClient(clientName: String, packageName: String): String {
val clientId = clientTracker.register(clientName, packageName) val uid = android.os.Binder.getCallingUid()
val clientId = clientTracker.register(clientName, packageName, uid)
broadcastClientRegistered(clientId) broadcastClientRegistered(clientId)
return clientId return clientId
} }
@@ -174,7 +186,8 @@ class LckControlService : Service() {
// ── Callbacks ─────────────────────────────────────── // ── Callbacks ───────────────────────────────────────
override fun registerCallback(callback: ILckControlCallback) { override fun registerCallback(callback: ILckControlCallback) {
callbacks.register(callback) val uid = android.os.Binder.getCallingUid()
callbacks.register(callback, uid)
} }
override fun unregisterCallback(callback: ILckControlCallback) { override fun unregisterCallback(callback: ILckControlCallback) {

View File

@@ -7,7 +7,7 @@ android {
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
minSdk = 33 minSdk = 32
} }
compileOptions { compileOptions {