diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index c8e68ad9..c996f88e 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -40,6 +40,17 @@
+
+
+
+
+
+
+
+
composePainterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon)
+ IntegrationLogo.Simkl -> simklBrandPainter(SimklBrandAsset.Glyph)
IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo)
IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon)
}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt
new file mode 100644
index 00000000..a2ccc03c
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt
@@ -0,0 +1,15 @@
+package com.nuvio.app.features.simkl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import com.nuvio.app.R
+
+@Composable
+actual fun simklBrandPainter(asset: SimklBrandAsset): Painter =
+ painterResource(
+ id = when (asset) {
+ SimklBrandAsset.Glyph -> R.drawable.simkl_logo_glyph
+ SimklBrandAsset.Wordmark -> R.drawable.simkl_logo_wordmark
+ },
+ )
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt
new file mode 100644
index 00000000..897c5760
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt
@@ -0,0 +1,127 @@
+package com.nuvio.app.features.simkl
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import com.nuvio.app.core.storage.ProfileScopedKey
+import java.security.KeyStore
+import java.security.MessageDigest
+import java.security.SecureRandom
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+internal actual object SimklPlatformClock {
+ actual fun nowEpochMs(): Long = System.currentTimeMillis()
+}
+
+internal actual object SimklPkceCrypto {
+ private val secureRandom = SecureRandom()
+
+ actual fun secureRandomBytes(size: Int): ByteArray =
+ ByteArray(size).also(secureRandom::nextBytes)
+
+ actual fun sha256(value: ByteArray): ByteArray =
+ MessageDigest.getInstance("SHA-256").digest(value)
+}
+
+internal actual object SimklAuthStorage {
+ private const val PREFERENCES_NAME = "nuvio_simkl_auth"
+ private const val METADATA_KEY = "simkl_auth_metadata"
+ private const val ACCESS_TOKEN_KEY = "simkl_access_token"
+ private const val CODE_VERIFIER_KEY = "simkl_code_verifier"
+ private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
+ private const val KEY_ALIAS = "nuvio.simkl.credentials.v1"
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
+ private const val GCM_TAG_BITS = 128
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ }
+
+ actual fun loadMetadataPayload(): String? =
+ preferences?.getString(ProfileScopedKey.of(METADATA_KEY), null)
+
+ actual fun saveMetadataPayload(payload: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(METADATA_KEY), payload)?.apply()
+ }
+
+ actual fun loadAccessToken(): String? = loadEncrypted(ACCESS_TOKEN_KEY)
+
+ actual fun saveAccessToken(value: String?) = saveEncrypted(ACCESS_TOKEN_KEY, value)
+
+ actual fun loadCodeVerifier(): String? = loadEncrypted(CODE_VERIFIER_KEY)
+
+ actual fun saveCodeVerifier(value: String?) = saveEncrypted(CODE_VERIFIER_KEY, value)
+
+ actual fun removeProfile(profileId: Int) {
+ preferences?.edit()
+ ?.remove(ProfileScopedKey.of(METADATA_KEY, profileId))
+ ?.remove(ProfileScopedKey.of(ACCESS_TOKEN_KEY, profileId))
+ ?.remove(ProfileScopedKey.of(CODE_VERIFIER_KEY, profileId))
+ ?.apply()
+ }
+
+ private fun loadEncrypted(key: String): String? {
+ val scopedKey = ProfileScopedKey.of(key)
+ val stored = preferences?.getString(scopedKey, null) ?: return null
+ return runCatching { decrypt(stored) }
+ .onFailure { preferences?.edit()?.remove(scopedKey)?.apply() }
+ .getOrNull()
+ }
+
+ private fun saveEncrypted(key: String, value: String?) {
+ val scopedKey = ProfileScopedKey.of(key)
+ val editor = preferences?.edit() ?: return
+ if (value.isNullOrBlank()) {
+ editor.remove(scopedKey).apply()
+ } else {
+ editor.putString(scopedKey, encrypt(value)).apply()
+ }
+ }
+
+ private fun encrypt(value: String): String {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
+ val ciphertext = cipher.doFinal(value.encodeToByteArray())
+ return "${cipher.iv.toBase64()}.${ciphertext.toBase64()}"
+ }
+
+ private fun decrypt(value: String): String {
+ val separator = value.indexOf('.')
+ require(separator > 0 && separator < value.lastIndex) { "Invalid encrypted credential" }
+ val iv = value.substring(0, separator).fromBase64()
+ val ciphertext = value.substring(separator + 1).fromBase64()
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
+ return cipher.doFinal(ciphertext).decodeToString()
+ }
+
+ private fun getOrCreateSecretKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
+ (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER).run {
+ init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build(),
+ )
+ generateKey()
+ }
+ }
+
+ private fun ByteArray.toBase64(): String =
+ Base64.encodeToString(this, Base64.NO_WRAP)
+
+ private fun String.fromBase64(): ByteArray =
+ Base64.decode(this, Base64.NO_WRAP)
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt
new file mode 100644
index 00000000..798cf20d
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt
@@ -0,0 +1,29 @@
+package com.nuvio.app.features.simkl
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.nuvio.app.core.storage.ProfileScopedKey
+
+internal actual object SimklSyncStorage {
+ private const val PREFERENCES_NAME = "nuvio_simkl_sync"
+ private const val PAYLOAD_KEY = "simkl_sync_snapshot"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ }
+
+ actual fun loadPayload(): String? =
+ preferences?.getString(ProfileScopedKey.of(PAYLOAD_KEY), null)
+
+ actual fun savePayload(payload: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(PAYLOAD_KEY), payload)?.apply()
+ }
+
+ actual fun removeProfile(profileId: Int) {
+ preferences?.edit()
+ ?.remove(ProfileScopedKey.of(PAYLOAD_KEY, profileId))
+ ?.apply()
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt
index d694069b..07d766ea 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt
@@ -23,4 +23,11 @@ internal actual object TraktAuthStorage {
?.putString(ProfileScopedKey.of(payloadKey, profileId), payload)
?.apply()
}
+
+ actual fun removeProfile(profileId: Int) {
+ preferences
+ ?.edit()
+ ?.remove(ProfileScopedKey.of(payloadKey, profileId))
+ ?.apply()
+ }
}
diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml
new file mode 100644
index 00000000..f53fead9
--- /dev/null
+++ b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml
new file mode 100644
index 00000000..e1d934cc
--- /dev/null
+++ b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg
new file mode 100644
index 00000000..e16507c2
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg
@@ -0,0 +1 @@
+
diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg
new file mode 100644
index 00000000..3860c456
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg
@@ -0,0 +1 @@
+
diff --git a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg
index 15d12e90..b7ecf23f 100644
--- a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg
+++ b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg
@@ -1,4 +1,4 @@
-