Hi everyone,
I’m Nirvan Jain (nirvan_jain on IRC, nirvan73 on GitHub), I’m currently a pre-final year student at IIIT Jabalpur. This summer I was offered this opportunity to work with MetaBrainz through Google Summer of Code on migrating the ListenBrainz android app to Kotlin Multiplatform and Compose Multiplatform, so that most of the same codebase can eventually run on iOS under the mentorship of Jasjeet Singh (jasje on IRC).
This post covers what the project was, everything I worked on, the parts that went wrong, what’s still left, and what the summer was actually like.
How I got here
I started contributing to ListenBrainz in December 2025. It was the first external open source project I’d worked on. Everything before that was coursework, side projects, or intra-college events.
My first PRs were small work which helped me a lot to understand the codebase from the ground, the shimmer effect across the Feed and Profile tabs, then extending search to cover playlists, artists, albums and tracks, plus some bug fixes. Small changes, but they taught me the codebase and how review works when nobody knows you and the code has to stand on its own.
I picked ListenBrainz because music is a constant in my day, whether I’m coding or not, and because I like that it treats your listening history as something you own.
The Problem
ListenBrainz has an Android app but no iOS version. A previous Swift attempt just duplicated the codebase, so every bug fix had to be written twice.
This project solves that by migrating to Kotlin Multiplatform, one shared codebase for both platforms. A large part of the work is replacing Android-only libraries with multiplatform equivalents across navigation, dependency injection and paging. Media playback is the harder case. ExoPlayer and WorkManager are tied too closely to Android to share, so both go behind common interfaces, with ExoPlayer on Android and AVPlayer on iOS underneath.
Getting this foundation right is what makes the rest cheap. Once it’s in place a feature or a bug fix is written once, instead of the same business logic being implemented twice and two apps being kept in sync by hand.
One thing genuinely can’t move. The notification listener that reads what’s playing in Spotify depends on an Android-only permission, and iOS has no equivalent to offer. Those screens stay Android-only. Everything else runs from one codebase on both platforms.
Project overview
ListenBrainz is a platform for tracking your listening history, sharing what you’re into, and building a community around music. The Android app is its mobile face, and this project was about rebuilding its foundations so that face isn’t Android-only forever.
The main goals were:
- Shared logging with Kermit – Replacing the Android-only logger, with file writing and log submission split across platform implementations (#742)
- Realtime events in the shared module – Moving off the Android-only socket.io-client so listen and playing_now work from commonMain on both platforms (#731)
- ViewModels, repositories and the BrainzPlayer & ListensSubmission database into shared – Roughly twenty separate migrations, on top of the KMP lifecycle dependencies (#743 and #744 through #765)
- A shared work manager – with listen submission and its data models and utilities moved into the shared module (#767)
- Permissions in shared – with configuration for both Android and iOS (#770)
- Screen migration – Starting with onboarding, which is where I am now
Each of these gets its own section below, with what broke, what review caught, and where I diverged from the original proposal.
Community bonding
I spent bonding mostly auditing rather than coding. The shared module scaffold and the DataStore work already existed, so the useful question wasn’t how to start but what exactly was still Android-only and what would replace each thing.
That meant going through the dependency list one by one. The logger, socket.io, WorkManager, ExoPlayer, Accompanist, Lottie, the shimmer library, the WebView clients, the paging setup. For each one: is there a KMP-compatible replacement, does it need an interface with two implementations, or can it simply not exist on iOS and therefore has to stay Android-only forever?
I also discussed with my maintainer and agreed on a migration order during this period. We settled on logging and sockets first, then ViewModels, then services and background work, then UI, so that each layer already had its dependencies migrated by the time it needed them.
Coding Period
Shared Logging: replacing the Android logger with Kermit
Where it started
The app used com.limurse.logger (Logger-Android) behind a thin Log interface:
// app/util/Log.kt — before
interface Log {
fun e(message: Any?, tag: String? = null)
fun d(message: Any?)
fun w(message: Any?)
companion object : Log {
override fun e(message: Any?, tag: String?) = Logger.e(tag, msg = message.toString())
override fun d(message: Any?) = Logger.d(msg = message.toString())
override fun w(message: Any?) = Logger.w(msg = message.toString())
}
}
Two problems. It’s a JVM library, so it can’t cross into commonMain. And because Log is a companion object called statically from many files, every single one of those files had a hard, untestable dependency on Android. This one had been started by another contributor PR – #728 before I picked it up, the Kermit swap and a first pass at file logging existed.
What I did was take it apart and rebuild the layering, because the first version had every platform’s DI wiring duplicated and the file writer had almost all of its logic sitting in androidMain even though most of it was plain Kotlin.
The interface stays, the backend changes
The first decision was to keep the Log interface and its companion. It’s called from everywhere, changing the call sites would have made the diff unreadable and the review impossible. So Log moved to shared/util/Log.kt, kept its shape, gained i/v/log and a throwable parameter, and started delegating to a Kermit Logger pulled out of Koin.
interface Log {
fun e(message: Any?, tag: String? = null, throwable: Throwable? = null)
fun d(message: Any?, tag: String? = null)
// w, i, v, log ...
companion object : Log, KoinComponent {
private val logger: Logger = get()
override fun e(
message: Any?,
tag: String?,
throwable: Throwable?
) {
logger.withTag(tag ?: "ListenBrainz").e(throwable) { message.toString() }
}
// ...
}
}
KoinComponent on the companion is what makes a static-looking API injectable. The object stays a singleton, but what it is now decided by DI, per platform.
Splitting the file writer
The old file writer was one Android class doing five things: formatting timestamps, collecting device metadata, serialising writes, formatting log lines, and actually touching the filesystem. Only the last one is platform-specific.
So SharedFileLogWriter is an abstract class in commonMain that extends Kermit’s LogWriter and does everything except the write. Click to see the implementation
abstract class SharedFileLogWriter(private val buildConfig: BuildInfo): LogWriter() {
private val loggerQueue = Channel<String>(capacity = Channel.UNLIMITED)
protected fun initBlock() {
loggerScope.launch(Dispatchers.IO) {
for (entry in loggerQueue) writeLineToFile(entry)
}
}
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val level = when (severity) {
/* Verbose -> "VERBOSE", ... */
}
writeToFile(message, tag, level)
throwable?.let { writeToFile(it.stackTraceToString(), tag, level) }
}
protected abstract suspend fun writeLineToFile(entry: String)
}
The Channel is the interesting bit. Logging happens on whatever thread the caller is on, and file I/O has to be serialised or you get interleaved half-lines. An unlimited channel consumed by a single Dispatchers.IO coroutine gives you a non-blocking log() call and a strictly-ordered writer, with no locks.
As for the iOS implementation, I haven’t added it in this PR yet. I wanted to get the base shared setup merged first so the rest of the PRs isn’t blocked on moving logger-dependent files into the shared module. Once every file involving logger in their code is migrated over, I’ll follow up with the actual iOS file writer in a separate PR.
I made one BuildInfo, a plain data class which consists of -> application id, version code, version name, build type, bound in DI by the app. Since commonMain cannot see BuildConfig, so anything that wants build metadata takes a BuildInfo instead.
Log submission
“Submit logs” means:- zip everything in the log directory. I have created an interface named LogSubmitter, a contract in common which only have one method as:-
interface LogSubmitter {
suspend fun submitLogs()
}
AndroidLogSubmitter is one of the extension of LogSubmitter in androidMain which zips the .txt files, exposes the archive through FileProvider, and fires an ACTION_SEND chooser. Two review-driven fixes landed here: –
- Passed the
PlatformContextasapplicationContextthrough the parameters of the class - Handled thread switching explicitly, file compression runs on
Dispatchers.IO, while building and launching the chooser intent switches back towithContext(Dispatchers.Main)
The iOS side (IosLogSubmitter, IosFileLogWriter) is not implemented yet and will be added in a follow-up.
The DI shape, and what review changed
My first version had a SharedAppModule.android.kt and a SharedAppModule.ios.kt for all the platform specific modules, each defining a full Koin module. Review corrected this:- it means every new binding has to be added twice, and the two modules drift. The fix was to have one module in commonMain under platformModule and make only the factories expect/actual like provideLogger. Click to see the example of the same
// commonMain/Platform.kt
expect fun provideLogger(buildInfo: BuildInfo): Logger
expect fun provideLogSubmitter(buildInfo: BuildInfo): LogSubmitter
// commonMain/di/SharedAppModule.kt
val platformModule = module {
single<Logger> {
provideLogger(get<BuildInfo>())
}
single<LogSubmitter> {
provideLogSubmitter(get<BuildInfo>())
}
// ...
}
// androidMain/Platform.android.kt
actual fun provideLogger(buildInfo: BuildInfo): Logger {
val writers = mutableListOf(platformLogWriter())
applicationContext.getExternalFilesDir(null)?.let { dir ->
val logDir = File(dir, ANDROID_LOG_DIR_NAME).apply { mkdirs() }
writers.add(AndroidFileLogWriter(logDir.path, buildInfo))
}
return Logger(
StaticConfig(
minSeverity = Severity.Debug,
logWriterList = writers
),
tag = "ListenBrainz"
)
}
// iosMain/Platform.ios.kt
actual fun provideLogger(buildInfo: BuildInfo): Logger {
val writers = mutableListOf(platformLogWriter())
(NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory,
NSUserDomainMask,
true
).firstOrNull() as? String)?.let {
writers.add(IosFileLogWriter(it, buildInfo))
}
return Logger(
StaticConfig(
minSeverity = Severity.Debug,
logWriterList = writers
),
tag = "ListenBrainz"
)
}
Same module, same binding, one line of difference per platform. This became the template for every other platform-backed dependency in the project -RemotePlaybackHandler, PermissionHandler, ListensRepository, the Room builder. Platform.kt is now the single file where you can read off everything the shared module needs a platform to supply.
Realtime Events in the Shared Module
“Listening Now” is fed by a Socket.IO connection to listenbrainz.org that pushes two events, listen and playing_now. The existing implementation used io.socket:socket.io-client, which is JVM-only and pulls in org.json:
// before — app module
private val socket: Socket = IO.socket(
"https://listenbrainz.org/",
IO.Options.builder().setPath("/socket.io/").build()
)
socket.on("playing_now") {
json.decodeFromString<Listen>(it[0] as String)
}
Attempt one: Engine.IO by hand
Ktor’s websocket client is multiplatform, so my first instinct was to drop the Socket.IO library entirely and talk the protocol directly. Socket.IO over websockets is really Engine.IO framing:
My first implementation did it the hard way. I wrote the Engine.io and Socket.io protocol handling by hand…. on top of ktor-client-websockets.
– manual handshake with the 40 frame to open the session
– heartbeat by answering every incoming 2 with a 3
– and event subscription parsing JSON payloads out of the frames myself.
Click to see the implementation
// the version that worked, and that I threw away :(
httpClient.webSocket("wss://listenbrainz.org/socket.io/?EIO=4&transport=websocket") {
send(Frame.Text("40"))
send(Frame.Text("""42["json",{"user": "$username"}]"""))
for (frame in incoming) {
val data = (frame as? Frame.Text)?.readText() ?: continue
when {
data == "2" -> send(Frame.Text("3")) // heartbeat
data.startsWith("42") -> {
val array = json.parseToJsonElement(data.removePrefix("42")) as JsonArray
when (array[0].jsonPrimitive.content) {
"playing_now", "listen" -> trySendBlocking(
json.decodeFromString<Listen>(array[1].jsonPrimitive.content)
)
}
}
}
}
}
It connected. It received listens. It was also, on reflection a bad idea, reimplementing a wire protocol inside an application repository means the app now owns a protocol implementation it has to maintain and debug and keep in sync with a spec it doesn’t control. Thats why I have to switch to using a KMP SocketIO – OSS library.
Attempt two: kmp-socketio
I switched to com.piasy:kmp-socketio, which is a real Socket.IO client for KMP and crucially lets you hand it your own Ktor HttpClient, so realtime traffic goes through the same configured, logged, engine-agnostic client as everything else. Click to see the implementation
class SocketRepositoryImpl(
private val httpClient: HttpClient,
private val json: Json,
private val logger: Log = Log,
) : SocketRepository {
override fun listen(usernameProvider: suspend () -> String) = callbackFlow {
val username = usernameProvider()
val options = IO.Options().apply {
httpClient = this@SocketRepositoryImpl.httpClient
transports = listOf("websocket")
}
var activeSocket: Socket? = null
IO.socket("https://listenbrainz.org", options) { socket ->
activeSocket = socket
socket.on(Socket.EVENT_CONNECT) {
socket.emit("json", buildJsonObject { put("user", username) })
}
listOf("playing_now", "listen").forEach { event ->
socket.on(event) { data ->
runCatching {
val payload = data.firstOrNull()?.toString() ?: return@on
trySendBlocking(json.decodeFromString<Listen>(payload))
}.onFailure { logger.e("SocketRepository: $event error ${it.message}") }
}
}
socket.open()
}
awaitClose { activeSocket?.close() }
}
}
The callbackFlow + awaitClose shape survived the rewrite unchanged, which is the nice thing about having had a decent boundary in the first place, SocketRepository.listen() returns a Flow<Listen?>, and neither the ViewModel nor the UI ever knew which library was underneath.
KMP Lifecycle, ViewModels and Repositories
This is where most of the raw hours went and it’s the least interesting part to write about, which is exactly why it’s worth writing about. This is the bulk of the project by volume: ~20 PRs, one feature each.
The dependency swap that unlocks everything
AndroidX lifecycle ships two artifact families: lifecycle-viewmodel-ktx (Android-only) and lifecycle-viewmodel (multiplatform since 2.8). They have the same API. Switching the whole project to the base artifacts is a three-line diff in the version catalog and is the entire technical prerequisite for shared ViewModels.
The KMP lifecycle dependencies had to land first so migrated ViewModels had a ViewModel base and a viewModelScope that exist on both platforms. Then, one at a time: Settings, Artist, Album, Song, Playlist ,etc ViewModels, the remote playback handler, the BrainzPlayer database, and SocialRepository.
One PR each, about twenty of them.
After migrating the viewmodels, repositories and network services to shared module, they needed to be wired up through Koin. I organized these dependencies into dedicated shared modules so the platform app module could easily consume them without caring about their internal package structure:
SharedViewModelModule-> Registers all shared ViewModels.SharedRepositoryModule-> Binds the shared repository layer.SharedNetworkServiceModule-> Configures shared API clients and network services.SharedAppModule-> Handles core utility implementations (like RemotePlaybackHandler).
Retrofit to Ktorfit, OkHttp to engine-agnostic
Services became Ktorfit interfaces, which look almost identical:
interface ListensService {
@GET("user/{user_name}/listens")
suspend fun getUserListens(
@Path("user_name") username: String,
@Query("count") count: Int,
@Query("max_ts") maxTs: Long? = null,
): Listens
@POST("submit-listens")
suspend fun submitListen(@Body body: ListenSubmitBody?): PostResponse
}
I created an expect fun of the engine with its platform-specific actual fun implementation. Click to preview
// commonMain
expect fun getPlatformNetworkEngine(): HttpClientEngineFactory<*>
expect fun configPlatformEngine(config: HttpClientEngineConfig, context: PlatformContext)
// androidMain — OkHttp, plus Chucker in debug builds
actual fun getPlatformNetworkEngine(): HttpClientEngineFactory<*> = OkHttp
actual fun configPlatformEngine(config: HttpClientEngineConfig, context: PlatformContext) {
if (config is OkHttpConfig && BuildKonfig.DEBUG) {
config.addInterceptor(ChuckerInterceptor(context))
}
}
// iosMain — Darwin
actual fun getPlatformNetworkEngine(): HttpClientEngineFactory<*> = Darwin
The awkward cases
Not everything I migrated went cleanly. Those are the parts I found interesting, so I’m giving them the most space here.
String resources in ViewModels
Several ViewModels emit user-facing success messages by resource id, and commonMain has no R. I could have moved res/, but that’s a much larger change than I wanted to make inside a feature PR, so I wrote small provider interfaces keyed by enum instead:
// shared/util
enum class StringResource {
TRACK_ADDED_SUCCESSFULLY,
PLAYLIST_DUPLICATED_SUCCESSFULLY,
/* ... */
}
interface StringProvider {
fun getString(res: StringResource): Int
}
The app implements it as a one-line when mapping enum to R.string.*, and I bound it in Koin. I wrote three of these: StringProvider, DrawableProvider, ArrayProvider. I made them deliberately boring. They let me migrate a feature today and let someone migrate the resources later, on their own schedule.
Repositories that really are platform-specific
ListensRepository needs getPackageLabel(pkgName), which turns com.spotify.music into “Spotify”. That’s a PackageManager call with no iOS analogue.
Rather than leak it into common, I kept the interface common and picked the implementation with an expect fun, so I ended up with an AndroidListensRepositoryImpl and an IosListensRepositoryImpl sharing a common parent. I did the same thing for RemotePlaybackHandler, which is Spotify App Remote on Android and a stub I wrote on iOS.
The BrainzPlayer database
My proposal listed the local databases as a migration target, and i did the work. Room Multiplatform, schema in commonMain, builder behind expect. The PR consists the moving of four entities, four DAOs, TypeConverter and Transformer across, and deleting the app-side database and converter files outright.
The boundary turned out narrower then I expected, Only the builder is platform-specific. The driver, the migration list and build() all stay in common DI, so the part I’d most hate to see diverge across platforms is written exactly once.
// commonMain - the entire platform surface of the database
expect fun getBrainzPlayerDatabase(context: PlatformContext): RoomDatabase.Builder<BrainzPlayerDatabase>
single<BrainzPlayerDatabase> {
getBrainzPlayerDatabase(get())
.setDriver(BundledSQLiteDriver())
.addMigration(Migrations.MIGRATION_1_2,Migrations.MIGRATION_2_3)
.build()
}
Android supplies a Context and getDatabasePath().absolutePath. iOS supplies a path from NSFileManager. One line of diff , each platform. BundledSQLiteDriver() in common means both platforms ship the same SQLite built. This is the same “one module in common, only the factories are expect/actual“, temp late I settled on during the logging migration, and it held up on a much heavier target.
Room’s KMP mode also made me do something I’d never done on Android, The database object needs a generated constructor. Click to preview
@Database(entities = [SongEntity::class, AlbumEntity::class, ArtistEntity::class, PlaylistEntity::class], version = 3)
@TypeConverters(TypeConverter::class)
@ConstructedBy(BrainzPlayerDatabaseConstructor::class)
abstract class BrainzPlayerDatabase : RoomDatabase() {
/* ... */
}
@Suppress("KotlinNoActualForExpect")
expect object BrainzPlayerDatabaseConstructor : RoomDatabaseConstructor<BrainzPlayerDatabase>
That @Suppress isn’t style. KSP generates the actual, but the Kotlin compiler goes looking for it before KSP has run.
A drawable resource living in a database column
The interesting problem in this PR was PlaylistEntity.art. It was an @DrawableRes Int, and commonMain has no R:
// shared — no idea what a drawable is
val art: String = "ic_queue_music"
// app
fun getPlaylistArtMapper(art: String): Int = when (art) { ... }
That’s the same resource-indirection trick as my StringProvider and DrawableProvider, but with two costs those don’t carry. The column is persisted, so changing its type forced a schema migration. And it’s stringly-typed, so the compiler stops checking my work. Both costs came due. The migration showed up in review, the lost type safety showed up right after.
SQLite can’t ALTER COLUMN, so MIGRATION_2_3 rebuilds the table. The part I actually thought about is that it back-fills the new keys from the row ids rather than defaulting every playlist to the generic icon, and then repairs sqlite_sequence so AUTOINCREMENT doesn’t restart into ids that still exist.
Click to see the SQLite code
INSERT INTO `PLAYLISTS_TEMP` (`id`,`title`,`items`,`art`)
SELECT `id`, `title`, `items`,
CASE WHEN `id` = -1 THEN 'ic_queue_music_playing'
WHEN `id` = 0 THEN 'ic_liked'
ELSE 'ic_queue_music' END
FROM `PLAYLISTS`
Where my plan changed
Two weeks after this merged, the plan changed underneath it, Since the local music player used ExoPlayer, an android specific component and was tightly coupled to UI components and viewModels that shouldn’t have been concerned with, complicating our migration to CMP.
So my migration turned into a deletion. including the database I had just finished moving. That meant the app also had to clean up after itself for users upgrading from a version that had it:
private fun cleanupBrainzPlayerResources() {
context.getDatabasePath("brainzplayer_database")
.takeIf { it.exists() }
?.let { context.deleteDatabase("brainzplayer_database")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager)
?.deleteNotificationChannel("Music")
}
}
Shared work manager
This was the one of the trickiest part of my project and the PR for the same is under review and not merged yet, because listen submission is the one piece of the app that is inherently Android (it works by listening to other apps’ media notifications) and yet almost none of its logic actually is.
Here’s the pipeline I ended up with:
ListenSubmissionService (app, NotificationListenerService — Android-only entry point)
ListenSessionListener (shared/androidMain - MediaSessionManager callbacks)
ListenServiceManagerImpl (shared/androidMain — whitelist)
ListenSubmissionState (shared/androidMain — timers, builds PlayingTrack, schedules work)
ListenSubmissionWorker (shared/androidMain — KMP worker: POST, or persist for retry)
I left only the first box in :app, and only because NotificationListenerService has to be declared in the app manifest. I moved everything downstream of it.
From androidx.work to KmpWorkManager
For background work I replaced Android’s WorkManager with KmpWorkManager, updated ListenSubmissionWorker to match, and removed the native WorkManager DI wiring. This is one of the places where i diverged from my proposal, which described hand-rolling a BackgroundTaskScheduler interface with WorkManager on Android and BGTaskScheduler on iOS. Using an existing KMP library meant less code to write and less to maintain for the same result.
I annotate my workers and let KSP discover them:
@Worker("ListenSubmissionWorker")
class ListenSubmissionWorker : AndroidWorker, KoinComponent {
private val appPreferences: AppPreferences by inject()
private val repository: ListensRepository by inject()
private val pendingListensDao: PendingListensDao by inject()
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult { /* ... */ }
}
I schedule by string name and JSON payload, because a KMP scheduler can’t take a Class<*> or an androidx Data:
scheduler.enqueue(
id = "listen-${playingTrack.id}-${System.currentTimeMillis()}",
trigger = TaskTrigger.OneTime(initialDelayMs = 0),
workerClassName = "ListenSubmissionWorker",
inputJson = Json.encodeToString(ListenWorkerInput(playingTrack, ListenType.SINGLE)),
constraints = Constraints(requiresNetwork = true),
)
so my input model is just:
@Serializable
data class ListenWorkerInput(
val track: PlayingTrack,
val listenType: ListenType
)
plus an explicit KmpWorkManager.initialize(context, AndroidWorkerFactoryGenerated()) alongside startKoin initialising KMP WorkManager.
Then ListenSubmissionState moved into the shared module under androidMain, rewired to use the shared notification manager and shared work manager, with the data models and utility functions it depends on coming across with it. ListenSubmissionService in the app module was updated to consume the shared utilities and services, and the Koin registry updated accordingly.
Permissions in the shared module
The PR for the same is under review and not merged yet. Last year’s contributor built a PermissionEnum that put all permission logic in one place: title, rationale, permanently-declined copy, image, minSdk, maxSdk, and the raw permission string. I think it’s a genuinely good design, and it made the onboarding screens trivial to write. It’s also, top to bottom, Android:
enum class PermissionEnum(
val permission: String, // "android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
val image: Int, // R.drawable.*
val minSdk: Int, // Build.VERSION_CODES
val maxSdk: Int? = null,
)
Every field except the copy is Android-specific. I couldn’t make a single shared enum work, and I didn’t want to duplicate the whole thing per platform.
Splitting the enum without losing the enum
What I settled on was making the contract an interface, and letting each platform contribute its own enum implementing it. Click to see the implementation
// commonMain
interface AppPermission {
val id: String
val title: String
val permanentlyDeclinedRationale: String
val rationaleText: String
val image: DrawableResource // enum, not an Int
}
// commonMain — permissions that exist everywhere
enum class SharedPermissionEnum(..) : AppPermission {
SEND_NOTIFICATIONS(
id = "send_notification",
title = "Send Notifications",
rationaleText = "Needed to send updates on activity, recommendations, and system alerts…",
image = DrawableResource.IC_NOTIFICATION,
)
}
// androidMain — permissions that only exist here
enum class AndroidPermissionEnum(
...,
val systemPermission: String,
val minSdk: Int,
val maxSdk: Int? = null
) : AppPermission {
READ_NOTIFICATIONS(systemPermission = "android.permission.BIND_NOTIFICATION_LISTENER_SERVICE", minSdk = 33, ..),
BATTERY_OPTIMIZATION(systemPermission = "android.settings.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", minSdk = 23, ..),
}
I made image a DrawableResource enum resolved by the DrawableProvider, so my shared code can name an icon without knowing what an R.drawable is. That left me one problem. SEND_NOTIFICATIONS lives in common but still needs an Android manifest string and a minSdk on Android.
I solved it with an extension in androidMain that supplies the Android facts for shared constants
The handler
I moved everything behavioural behind one interface:
interface PermissionHandler {
fun getAllRelevantPermissions(): List<AppPermission>
fun getPermissionsForPermissionScreen(): List<AppPermission>
fun isPermissionApplicable(permission: AppPermission): Boolean
suspend fun isGranted(permission: AppPermission): Boolean
fun storageKey(permission: AppPermission): String
suspend fun requestPermission(
permission: AppPermission,
activity: Any? = null,
permissionRequestedOnce: List<String> = emptyList(),
dangerousPermissionLauncher: (permission: String) -> Unit = {},
): Boolean?
}
activity: Any? is the one compromise in my whole design, and I want to be upfront about it. commonMain cannot name Activity, and Android’s rationale APIs (shouldShowRequestPermissionRationale) genuinely require one. So my Android implementation casts (activity as? Activity) and degrades gracefully to a FLAG_ACTIVITY_NEW_TASK intent when it’s null.
I kept all of last year’s special cases in the Android implementation, including the two “permissions” that aren’t runtime permissions at all.
My iOS handler is much smaller, and that’s the point. It only has to answer for the permissions iOS actually has, which is just SEND_NOTIFICATIONS since iOS sandbox security and privacy rules don’t allow the other permissions anyway.
One detail which is storageKey(). Whether a permission has been requested once is persisted in DataStore, and the obvious key is the enum constant’s name, which means renaming a constant would silently reset everyone’s onboarding state. On Android I use the system permission string instead, which is stable by definition. On iOS I use the explicit id field, and that’s the only reason I put id on the interface at all.
Screen migration: onboarding
I’m currently working through the UI layer starting with onboarding.
Onboarding is a reasonable place to start for two reasons. It’s the first thing a user sees, so regressions are visible immediately and get caught fast.
The back half is different. Listen submission setup and the third-party app selector both depend on NotificationListenerService, so by the same reasoning as above those screens stay Android-only and won’t exist in an iOS build. The shared onboarding graph walks both platforms through the first three screens, Android then hands off to its own two, and iOS ends the flow there.
A side quest: token login
While working on the auth screens, I added a direct-token login path alongside the existing WebView flow. Due to ongoing server-side auth migrations, we needed a more direct and temp, resilient fallback while keeping the door open for multiplatform support. Click to see the implementation
fun submitToken(onLoginFinished: () -> Unit) {
val token = uiState.value.token.trim()
if (token.isBlank()) return setError("Token cannot be empty")
if (uiState.value.loginState is TempLoginState.VerifyingToken) return
viewModelScope.launch {
val result = withContext(ioDispatcher) { listensRepository.validateToken(token) }
val validation = result.data
if (result.isSuccess && validation?.valid == true) {
appPreferences.username.set(validation.username ?: "")
appPreferences.lbAccessToken.set(token)
onLoginFinished()
} else {
setError(validation?.message ?: "Invalid token. Please check and try again.")
}
}
}
What’s left
I’d rather say this plainly than bury it. My proposal was ambitious and several sections of it are still open:
- iOS file logging and log submission are
TODO(). The shared machinery (queueing, formatting, startup metadata, theLogSubmittercontract) is all done and platform-neutral, the two iOS leaves are stubs. - System bar control away from
accompanist-systemuicontroller. - Browser and external URL handling via
expect/actual. - Lottie to Compottie.
- Shimmer to a KMP shimmer implementation.
- Onboarding screen migration and all other composable screens migration to CMP, started but not complete.
- Testing migration to Kotlin Test with shared tests in commonTest
Two reasons for the gap. The foundational work, logging, sockets, ViewModels, notification and work manager, permissions, took considerably longer than I estimated, largely because each piece went through multiple review rounds that improved it but slowed it down. And the OAuth login change arrived mid-project and cost time I hadn’t planned for.
Post-GSoC plan
I’m not going anywhere. The unfinished list above is my roadmap, roughly in this order.
- The UI dependency cluster:- WebView, system bars, browser handling, Lottie, shimmer. These are individually small and mostly independent, so they can be done steadily instead of needing a large block of time.
- Then finishing the screen migration, continuing from onboarding through the home navigation shell, settings, dashboard and feed.
- Then the testing migration, moving platform-independent tests into commonTest so the shared module has an actual safety net. This one matters most for whoever picks the project up after me. A migration without tests is a liability handed to the next person.
- And eventually the real OAuth login flow, replacing the temporary token-paste screen with something users shouldn’t have to put up with.
Outside the migration I still want to build the playlist sorting feature from my proposal, and keep fixing bugs.
What I learned
The clever solution was usually the wrong one. My instinct on the socket migration was to build the impressive thing, hand-rolled protocol handling and all. The right answer was the boring maintainable one, and I now read the urge to be clever as a sign I haven’t thought hard enough about who maintains this in two years.
Almost everything I actually learned came out of review. Thread safety, DI hygiene, why println doesn’t belong in production code. Having changes requested three times on one PR taught me more than any PR that merged first try, though getting comfortable with that, treating requested changes as information rather than as a verdict, took a few weeks.
Final Thoughts
I am truly grateful for this amazing opportunity and thanks Jasjeet for his constant guidance and reviewing an enormous amount of my code carefully and for pushing back every time I was overcomplicating something. A fair amount of this post is his review comments with more words around them.
More than anything, this project showed me the gap between code that works and code that lasts. I learned to think in terms of clean contracts, edge cases, and long-term maintainability rather than quick fixes. It gave me real confidence in navigating complex codebases, embracing tough reviews, and contributing to industry-scale projects with purpose.
And Finally, thanks to the MetaBrainz community for their support. This was my first serious open source project and it’s a welcoming place to have started. I’ll be around well past this summer.