GSoC 2025: Onboarding Revamp in ListenBrainz Android

Hi everyone,

I’m Hemang Mishra (hemang-mishra on IRC and hemang-mishra on GitHub). I’m currently a pre-final year student at IIIT Jabalpur, India. This summer, I had the opportunity to participate in Google Summer of Code with MetaBrainz. My mentor for the program was Jasjeet Singh (jasje on IRC).

I contributed to ListenBrainz Android, where I worked on revamping the onboarding experience, improving login, adding listen submission apps, integrating Listening Now, and setting up app updates. The journey has been both exciting and full of learning, and I’m truly grateful for this opportunity.

Project Overview

ListenBrainz is a powerful platform that helps track listening history, share music tastes, and build a community around music.

The main goals of my project were:

  • Revamping onboarding – introducing users to the app’s core features and handling permissions with clear rationale.
  • Improving login –replacing simple web pages with a custom Compose-based UI, and experimenting with the DOM tree of the web page to automate form submissions and token extraction in the background.
  • Listen submission apps – prompting users during onboarding to select which apps to collect listens from, preventing unwanted submissions.
  • Listening Now integration – adding “Listening Now” into BrainzPlayer.
  • App updates – enabling updates for both Play Store and non-Play Store (F-Droid or sideloaded) releases.

What I did

Community Bonding Period

During the community bonding period, I worked on Figma designs for the project. These designs went through several iterations with aerozol, which really helped refine the final look and flow. Alongside this, I explored some newly released libraries, such as the new Nav3 API. This API provided deeper access to the backstack, which turned out to be crucial in creating smoother animations and handling tricky edge cases throughout the onboarding process.

Coding Period

Onboarding Revamp

Revamping the onboarding experience with smoother and more intuitive designs was one of the most important parts of the project. Onboarding is the very first interaction a user has with the app, and it needs to clearly introduce the core features.

To achieve this, I also implemented a HorizontalPager for seamless transitions between screens.

Core Challenges

The biggest challenge was handling navigation across different scenarios. For example:

  • A new user going through onboarding for the first time.
  • A returning user who is already logged in.
  • Permissions that might already be granted (in which case, those screens should be skipped).
  • Most importantly, handling backward movement—allowing the user to go back smoothly after completing onboarding.

    Solution
To solve this, I designed a queue system that works alongside the existing backstack (a stack). This queue is initialized right at the start, keeping all edge cases in mind. Whenever a user presses the back button, the corresponding screen is added back into this queue, ensuring backward navigation is handled effectively. Click to view the queue snippet.
fun onboardingNavigationSetup(dashBoardViewModel: DashBoardViewModel) {
    if (!dashBoardViewModel.appPreferences.onboardingCompleted) {
        onboardingScreensQueue.addAll(
            listOf(
                NavigationItem.OnboardingScreens.IntroductionScreen,
                NavigationItem.OnboardingScreens.LoginConsentScreen,
                NavigationItem.OnboardingScreens.LoginScreen,
                NavigationItem.OnboardingScreens.PermissionScreen,
                NavigationItem.OnboardingScreens.ListeningAppScreen
            )
        )
    }
    // Handling all edge cases here
}

For efficient and scalable permission handling, I created a Permission Enum that contains all permission-related logic in one place. This way, adding a new permission only requires updating the enum, not the UI. There’s also an option to skip explicit mentions if a permission doesn’t need to be shown on screen.

enum class PermissionEnum(
    val permission: String,
    val title: String,
    val permanentlyDeclinedRationale: String,
    val rationaleText: String,
    val image: Int,
    val minSdk: Int,
    val maxSdk: Int? = null
) {}

Here’s a visual representation of the same:

Improved Login

The login flow in the earlier versions of the app was… let’s just say functional but not friendly. It relied entirely on MusicBrainz authentication, redirected the user to the ListenBrainz settings page, and tokens were extracted from there. It worked, but it wasn’t the smoothest experience, especially because users had to manually go through WebViews.

What Changed

The core authentication process is still the same, but I completely rebuilt the UI using Jetpack Compose. The big improvement is that now, instead of forcing users through clunky WebViews, those WebViews are handled quietly in the background with JavaScript.

What the user sees is just a clean Compose-based login screen, while all the redirects and token extractions happen invisibly behind the scenes.

Handling the Login Flow

I created a sealed class to represent the different states of login. Since I was handling so many background events with JavaScript, having clear states was the only way to manage everything gracefully. Click to view the code.
sealed class LoginState {  
    data object Idle : LoginState()  
    data class Loading(val message: String) : LoginState()  
    data object SubmittingCredentials : LoginState()  
    data object AuthenticatingWithServer : LoginState()  
    data object VerifyingToken : LoginState()  
    data class Error(val message: String) : LoginState()  
    data class Success(val message: String) : LoginState()  
}

The flow now looks like this:

  1. User starts at https://musicbrainz.org/login and submits credentials. (At this point, they’re not authenticated with ListenBrainz yet.)
  2. They’re redirected to a consent screen. Since I already show my own consent screen inside the app, I quietly skip past this step by redirecting to https://musicbrainz.org/login/musicbrainz . Now the user is authenticated to ListenBrainz as well.
  3. After this, user is automatically redirected to ListenBrainz home page, and I redirect to https://listenbrainz.org/settings.
  4. From there, the app extracts the auth tokens directly.
Here’s a snippet of the logic. Click to view.
private fun handleListenBrainzNavigation(view: WebView?, uri: Uri) {  
    when {  
        // Step 1: Redirect to login endpoint  
        !hasTriedRedirectToLoginEndpoint -> {  
            Logger.d(TAG, "Redirecting to login endpoint")  
            hasTriedRedirectToLoginEndpoint = true  
            view?.loadUrl("https://listenbrainz.org/login/musicbrainz")  
        }  

        // Step 2: Navigate to settings to get token  
        !hasTriedSettingsNavigation -> {  
            Logger.d(TAG, "Navigating to settings page")  
            hasTriedSettingsNavigation = true  
            view?.postDelayed({ view.loadUrl("https://listenbrainz.org/settings") }, 2000)  
        }  

        // Step 3: Extract token from settings page  
        uri.path?.contains("/settings") == true -> {  
            onLoad(Resource.loading())  
            Logger.d(TAG, "Extracting token from settings page")  

            view?.postDelayed({  
                extractToken(view)  
            }, 2000)  
        }  
    }  
}

Smoother User Experience

To make things feel more transparent, the UI now shows exactly what’s happening:

  • when credentials are being submitted,
  • when the server is authenticating,
  • when the token is being extracted,
  • and when validation succeeds or fails.

I even added a timeout option so if something goes wrong (like a network hiccup), users don’t just sit there forever — they can report the issue or retry.

Automating the WebView

To streamline login without leaving the app, we automate interactions inside a WebView. This lets us securely handle the real MusicBrainz website, detect when pages are ready, and programmatically manage inputs and redirects, all while keeping the process seamless for the user.

  1. When the user taps “Login,” the app opens the official MusicBrainz login URL inside a WebView. This is the real website displayed within the app, not a fake screen. Using the WebView ensures a secure, familiar login experience while allowing the app to interact programmatically with the page as needed.
  2. Each time a webpage finishes loading in the WebView, the onPageFinished callback triggers. This acts as a clear signal that the page is fully ready for interaction. By listening to this event, the app knows exactly when to proceed with the next step, like injecting scripts or monitoring page redirects.
  3. After the page loads, JavaScript is injected into the DOM (Document Object Model). This allows the app to interact programmatically with page elements, such as filling in the username, entering the password, or clicking the “Login” button. It simulates a user’s actions while keeping everything automated and seamless.
  4. Once login completes, MusicBrainz redirects through intermediary pages until reaching the authorization success screen. At this point, the injected script captures the authorization code or token directly from the DOM. This process stays fully automated while still relying on the real website, ensuring authentication is secure and standards-compliant.
Here’s a little glimpse of the script that runs in the background. It automatically fills in the login form and submits it, so users never have to deal with the raw MusicBrainz login page themselves. Click to view.
val loginScript = """
    (function(){
    try {
        var formContainer = document.getElementById('page');
        if(!formContainer) return "Error: Form container not found";

        var usernameField = document.getElementById('id-username');
        var passwordField = document.getElementById('id-password');

        if (!usernameField) return "Error: Username field not found";
        if (!passwordField) return "Error: Password field not found";

        usernameField.value = '$username';
        passwordField.value = '$password';

        var form = formContainer.querySelector('form');
        if (!form) return "Error: Form not found";

        form.submit();
        return "Login submitted";
    } catch (e) {
        return "Error: " + e.message;
    }
    })();
""".trimIndent()

Consent Screen

To stay consistent with ListenBrainz itself, I also implemented a consent screen inside the app. The content of this screen is fetched directly from the ListenBrainz website, so it’s always up to date and doesn’t require hardcoding.

Listening Apps Selection

One of the biggest issues in the earlier version of the app was that the listening apps selection wasn’t part of onboarding at all. This led to a lot of unwanted submissions and confusion.

On top of that, there were a few more headaches:

  • Permissions for reading notifications and handling battery optimization weren’t explained well.
  • Users weren’t given the choice to completely disable listen submission at the time of onboarding.
  • The list of available apps wasn’t even ready at startup, which felt clunky.

The Fix

To solve this, I redesigned the onboarding flow. Now, right at the start, users are:

  1. Introduced to what Listen Submission is.
  2. Asked if they want to enable it.
  3. Prompted for the necessary permissions in a clean, step-by-step manner.

This way, users are in control from the beginning.

Behind the scenes, I used two DataStore preferences to keep things clear:

val listeningWhitelist: DataStorePreference<List<String>>  
val listeningApps: DataStorePreference<List<String>>
  • listeningApps → all the apps that the system recognizes as music apps.
  • listeningWhitelist → the smaller list of apps the user actually chooses to allow.

So the decision of what counts as a “listenable” app is left completely to the user.

How We Detect Music Apps

Fetching music apps reliably turned out to be trickier than it looks. I ended up using a two-step approach:

  1. Check for specific media services "android.media.browse.MediaBrowserService" "android.media.session.MediaSessionService" These are good indicators that an app is a music player.
  2. Check the app category if (category == ApplicationInfo.CATEGORY_AUDIO || category == ApplicationInfo.CATEGORY_VIDEO) This helps catch media apps that don’t explicitly expose the services above.
Even with these two checks, some apps still slip through. So as a fallback, I also query all installed apps using an intent. Click to view.
val intent = Intent(Intent.ACTION_MAIN).apply {  
    addCategory(Intent.CATEGORY_LAUNCHER)  
}  
packageManager.queryIntentActivities(intent, 0).forEach { resolveInfo ->  
    try {  
        val appInfo = packageManager.getApplicationInfo(resolveInfo.activityInfo.packageName, 0)  
        apps.add(appInfo)  
    } catch (e: Exception) {  
        Log.e("Could not fetch ApplicationInfo for ${resolveInfo.activityInfo.packageName}")  
    }  
}

From there, users can open a bottom sheet that lists all apps, search through them, and select multiple at once if needed. This wasn’t possible earlier, and it makes the experience much smoother.

Permissions and Rationale

For listen submission to work properly, the app needs two critical permissions:

  • Read Notifications permission = "android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" This lets ListenBrainz detect songs from other apps and submit them automatically. Without it, automatic tracking simply won’t work.
  • Ignore Battery Optimization permission = "android.settings.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" With optimization enabled, background submissions might fail or get delayed. Disabling it ensures that listens are sent reliably in the background.

In the new onboarding, these permissions are explained with clear rationale screens, so users understand why they’re being asked. If permissions are denied or permanently declined, the app guides the user gently, instead of just failing silently.

Listening Now Integration into BrainzPlayer

For anyone new to ListenBrainz, Listening Now is a feature that shows what a user is currently playing in real time. I wanted to bring this into the existing BrainzPlayer so users can see their live playback sync right inside the app.

To get this working, I first used the already available socket repository. To initialize the state, I made a simple API call:

https://api.listenbrainz.org/1/user/hemang-mishra/playing-now

This returns the currently playing track for a user. Once I had that, I set up a connection with the ListenBrainz socket API and kept it alive so the player stays in sync with any new listens.

For connecting to the socket, we did something like this:

private val socket: Socket = IO.socket(  
    "https://listenbrainz.org/",  
    IO.Options.builder().setPath("/socket.io/").build()  
)

We then listen for three main events: "connect", "listen", and "playing-now".

On top of just showing the track, I wanted to make the UI feel alive. I used the Palette library to extract colors from the album art and apply them as dynamic backgrounds. This was inspired by the mobile version of the web player. To do this, I created a util function so it could be reused anywhere in the app. The bitmaps were fetched using Coil and then passed into Palette:

val palette = Palette.from(bitmap).generate()  
val lightColor = palette.vibrantSwatch?.rgb ?: palette.mutedSwatch?.rgb

So the player background now shifts its mood based on the song you’re listening to .

The integration itself sits on top of the existing BackdropScaffold, which exposes two states: concealed and revealed. One important rule we made was that if BrainzPlayer is already playing a song, it overrides the Listening Now screen.

I also animated the bottom app bar so it feels smooth when switching between modes. Figuring out the right animation logic took me a bit of trial and error. At first, I tied AnimatedVisibility’s targetState directly to the current state of the BackdropScaffold, but that didn’t behave correctly. After some fiddling, I realized I should be checking the target value instead of the current value, which finally made the transitions smooth.

That little switch from currentState to targetValue made all the difference in getting animations to feel natural.

App Updates

This was honestly the trickiest part of the entire project for me. The main challenge was that the app can be installed in three different ways — through the Play Store, F-Droid, or by sideloading. Each of these had to be handled differently, so I had to think carefully about the update flow.

The first step was figuring out where the app was installed from. For that, I used the package manager to check the installer package name. Click to view the implementation.
val installerPackageName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    val installSourceInfo = packageManager.getInstallSourceInfo(packageName)
    installSourceInfo.installingPackageName
} else {
    packageManager.getInstallerPackageName(packageName)
}

If the installer package name was com.android.vending or com.google.android.feedback, I knew the app came from the Play Store. Otherwise, I had to treat it as a non-Play Store install.

Play Store Updates

For Play Store installs, I used the Play Core API, which luckily makes in-app updates a lot easier. I added three main functionalities around flexible updates:
suspend fun checkPlayStoreUpdate(activity: ComponentActivity): Boolean  

suspend fun startPlayStoreFlexibleUpdate(  
    activity: ComponentActivity,  
    onUpdateProgress: (Int) -> Unit,  
    onUpdateDownloaded: () -> Unit,  
    onUpdateError: (String) -> Unit  
): Boolean  

suspend fun completePlayStoreFlexibleUpdate(activity: ComponentActivity): Boolean
  1. Checking for updates – I used AppUpdateManager to get an appUpdateInfo object. Since the API is callback-based, I wrapped it with suspendCancellableCoroutine so I could work with it more cleanly in a flow-based setup.
  2. Starting the update – here I used appUpdateManager.startUpdateFlowForResult() to kick things off.
  3. Completing the update – finally, appUpdateManager.completeUpdate() is used to finish the update process once everything’s downloaded.

This flow gives a smooth, native experience for users updating through the Play Store.

Non-Play Store Updates

Now, this part was… tricky. 😅 For non-Play Store installs (F-Droid or sideloaded), I had to come up with a completely custom flow.

I used the GitHub API to check if a newer version was available. Once a release is found, the app compares it with the current version. If the new version is higher, the user is prompted to update. I also added an option for users to opt into pre-releases, which meant I had to fetch all releases and then run my comparison logic.

Once the user agrees to update, I trigger a download using the Download Manager. To track progress, I set up a BroadcastReceiver. One issue I hit was: what if the user leaves the app mid-download? To handle that, I cached the download ID locally. On the next startup, the app checks if an update was already in progress or if the APK was already downloaded.

Another important piece here was install permissions. On Android O and above, apps need explicit permission to install other apps. So I added a check. Click to view the code snippet.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {  
    val packageManager = getApplication<Application>().packageManager  
    val hasInstallPermission = packageManager.canRequestPackageInstalls()  
    _uiState.update {  
        it.copy(isInstallPermissionGranted = hasInstallPermission)  
    }  
} else {  
    // For older versions, permission is granted by default  
    _uiState.update {  
        it.copy(isInstallPermissionGranted = true)  
    }  
}

If the permission isn’t granted, I show a dialog prompting the user to enable it. Once granted, the update can proceed.

Finally, for the actual installation, I trigger an install intent:

val intent = Intent(Intent.ACTION_VIEW).apply {  
    setDataAndType(uri, "application/vnd.android.package-archive")  
    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)  
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)  
}

This flow ensures that no matter how the app was installed, users get proper update support. And honestly, getting this part to work smoothly felt super rewarding because I had to juggle so many edge cases.

I also made sure updates are checked both at app startup and through a manual “Check for Updates” option in settings, so users always have control.

PR Links

Here are the PRs I worked on during GSoC:

  1. Revamped Onboarding Screens: https://github.com/metabrainz/listenbrainz-android/pull/567
  2. Listening Apps Implementation: https://github.com/metabrainz/listenbrainz-android/pull/569
  3. Listening Apps Settings Update: https://github.com/metabrainz/listenbrainz-android/pull/574
  4. Listening Now Integration: https://github.com/metabrainz/listenbrainz-android/pull/575
  5. App Updates: https://github.com/metabrainz/listenbrainz-android/pull/576

I also worked on a few improvements and fixes:

  1. Efficiently fetching cover art using semaphores: https://github.com/metabrainz/listenbrainz-android/pull/570
  2. Switch style change to Material 3: https://github.com/metabrainz/listenbrainz-android/pull/568

What’s Left

One important piece that’s still pending is thorough testing of the app updates, especially for the Play Store side. This is a bit tricky since it requires creating release APKs through a Play Console account.

Apart from that, I’d also like to work on my post-GSoC plans:

  • Adding a feature to delete unwanted listens directly from the app (which isn’t available in the Android app yet).
  • Building a playlist search feature to make it easier for users to find playlists quickly.

Final Thoughts

I’m truly grateful for this amazing opportunity and for the constant guidance from jasje, whose mentorship made a huge difference throughout the program.

This experience taught me how to write professional-quality code—code that puts the user’s perspective first and pays attention to the smallest details. It also gave me a clearer picture of how industry-level software works and how to contribute to it effectively.

Finally, I want to thank the entire MetaBrainz community for their warmth, support, and encouragement during this journey. I really hope our users enjoy these updates as much as I enjoyed building them!