Twelve: Initial intents handling

Do everything on a view model, activities will react on parsed intents
Our MainActivity will only handle "folders"

Change-Id: Id124d588c2cdf3e449623b45864c33e60cfe1c55

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 30e739c..ecde999 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,6 +42,21 @@
                 <category android:name="android.intent.category.APP_MUSIC" />
             </intent-filter>
 
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <action android:name="android.provider.action.REVIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:mimeType="vnd.android.cursor.item/album" />
+                <data android:mimeType="vnd.android.cursor.item/artist" />
+                <!-- Deprecated, we cannot handle those as we use our internal database -->
+                <!--<data android:mimeType="vnd.android.cursor.item/playlist" />-->
+
+                <data android:scheme="content" />
+            </intent-filter>
+
         </activity>
 
         <service
diff --git a/app/src/main/java/org/lineageos/twelve/MainActivity.kt b/app/src/main/java/org/lineageos/twelve/MainActivity.kt
index a96e54e..ab9f585 100644
--- a/app/src/main/java/org/lineageos/twelve/MainActivity.kt
+++ b/app/src/main/java/org/lineageos/twelve/MainActivity.kt
@@ -7,13 +7,28 @@
 
 import android.content.Intent
 import android.os.Bundle
+import android.util.Log
 import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.util.Consumer
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.navigation.fragment.NavHostFragment
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.fragments.AlbumFragment
+import org.lineageos.twelve.fragments.ArtistFragment
+import org.lineageos.twelve.fragments.PlaylistFragment
+import org.lineageos.twelve.models.MediaType
+import org.lineageos.twelve.viewmodels.IntentsViewModel
 import kotlin.reflect.cast
 
 class MainActivity : AppCompatActivity(R.layout.activity_main) {
+    // View models
+    private val intentsViewModel by viewModels<IntentsViewModel>()
+
     // NavController
     private val navHostFragment by lazy {
         NavHostFragment::class.cast(
@@ -23,7 +38,7 @@
     private val navController by lazy { navHostFragment.navController }
 
     // Intents
-    private val intentListener = Consumer<Intent> { handleIntent(it) }
+    private val intentListener = Consumer<Intent> { intentsViewModel.onIntent(it) }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -31,8 +46,62 @@
         // Enable edge-to-edge
         enableEdgeToEdge()
 
-        handleIntent(intent)
+        intentsViewModel.onIntent(intent)
         addOnNewIntentListener(intentListener)
+
+        lifecycleScope.launch {
+            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                intentsViewModel.parsedIntent.collectLatest { parsedIntent ->
+                    parsedIntent?.handle {
+                        when (it.action) {
+                            IntentsViewModel.ParsedIntent.Action.MAIN -> {
+                                // We don't need to do anything
+                            }
+
+                            IntentsViewModel.ParsedIntent.Action.OPEN_NOW_PLAYING -> {
+                                navController.navigate(R.id.fragment_now_playing)
+                            }
+
+                            IntentsViewModel.ParsedIntent.Action.VIEW -> {
+                                if (it.contents.isEmpty()) {
+                                    Log.i(LOG_TAG, "No content to view")
+                                    return@handle
+                                }
+
+                                val isSingleItem = it.contents.size == 1
+                                if (!isSingleItem) {
+                                    Log.i(LOG_TAG, "Cannot handle multiple items")
+                                    return@handle
+                                }
+
+                                val content = it.contents.first()
+
+                                when (content.type) {
+                                    MediaType.ALBUM -> navController.navigate(
+                                        R.id.fragment_album,
+                                        AlbumFragment.createBundle(content.uri)
+                                    )
+
+                                    MediaType.ARTIST -> navController.navigate(
+                                        R.id.fragment_artist,
+                                        ArtistFragment.createBundle(content.uri)
+                                    )
+
+                                    MediaType.AUDIO -> Log.i(LOG_TAG, "Audio not supported")
+
+                                    MediaType.GENRE -> Log.i(LOG_TAG, "Genre not supported")
+
+                                    MediaType.PLAYLIST -> navController.navigate(
+                                        R.id.fragment_playlist,
+                                        PlaylistFragment.createBundle(content.uri)
+                                    )
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
     }
 
     override fun onDestroy() {
@@ -41,14 +110,9 @@
         super.onDestroy()
     }
 
-    private fun handleIntent(intent: Intent) {
-        // Handle now playing
-        if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
-            navController.navigate(R.id.fragment_now_playing)
-        }
-    }
-
     companion object {
+        private val LOG_TAG = MainActivity::class.simpleName!!
+
         /**
          * Open now playing fragment.
          * Type: [Boolean]
diff --git a/app/src/main/java/org/lineageos/twelve/ext/ClipData.kt b/app/src/main/java/org/lineageos/twelve/ext/ClipData.kt
new file mode 100644
index 0000000..b8c0d41
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/ClipData.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.content.ClipData
+
+fun ClipData.asArray() = mutableListOf<ClipData.Item>().apply {
+    for (i in 0 until itemCount) {
+        this.add(getItemAt(i))
+    }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/utils/MimeUtils.kt b/app/src/main/java/org/lineageos/twelve/utils/MimeUtils.kt
index 30c534c..ed272df 100644
--- a/app/src/main/java/org/lineageos/twelve/utils/MimeUtils.kt
+++ b/app/src/main/java/org/lineageos/twelve/utils/MimeUtils.kt
@@ -7,6 +7,7 @@
 
 import androidx.media3.common.MimeTypes
 import androidx.media3.common.util.UnstableApi
+import org.lineageos.twelve.models.MediaType
 
 object MimeUtils {
     @androidx.annotation.OptIn(UnstableApi::class)
@@ -15,4 +16,26 @@
             ?.substringAfterLast('/')
             ?.uppercase()
     }
+
+    fun mimeTypeToMediaType(mimeType: String) = when {
+        mimeType.startsWith("audio/") -> MediaType.AUDIO
+
+        else -> when (mimeType) {
+            "application/itunes",
+            "application/ogg",
+            "application/vnd.apple.mpegurl",
+            "application/vnd.ms-sstr+xml",
+            "application/x-mpegurl",
+            "application/x-ogg",
+            "vnd.android.cursor.item/audio" -> MediaType.AUDIO
+
+            "vnd.android.cursor.item/album" -> MediaType.ALBUM
+
+            "vnd.android.cursor.item/artist" -> MediaType.ARTIST
+
+            "vnd.android.cursor.item/playlist" -> MediaType.PLAYLIST
+
+            else -> null
+        }
+    }
 }
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/IntentsViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/IntentsViewModel.kt
new file mode 100644
index 0000000..7ef5822
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/IntentsViewModel.kt
@@ -0,0 +1,214 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import android.content.Intent
+import android.net.Uri
+import android.provider.MediaStore
+import android.util.Log
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.lineageos.twelve.MainActivity
+import org.lineageos.twelve.TwelveApplication
+import org.lineageos.twelve.ext.applicationContext
+import org.lineageos.twelve.ext.asArray
+import org.lineageos.twelve.ext.executeAsync
+import org.lineageos.twelve.models.MediaType
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.utils.MimeUtils
+
+/**
+ * A view model used by activities to handle intents.
+ */
+class IntentsViewModel(application: Application) : AndroidViewModel(application) {
+    data class ParsedIntent(
+        val action: Action,
+        val contents: List<Content> = listOf(),
+    ) {
+        enum class Action {
+            /**
+             * Open the app's home page.
+             * No [contents] is required.
+             */
+            MAIN,
+
+            /**
+             * Open the now playing fragment.
+             * No [contents] is required.
+             */
+            OPEN_NOW_PLAYING,
+
+            /**
+             * View a content.
+             * [contents] must contain at least one element.
+             */
+            VIEW,
+        }
+
+        data class Content(
+            val uri: Uri,
+            val type: MediaType,
+        )
+
+        private var handled = false
+
+        suspend fun handle(
+            consumer: suspend (parsedIntent: ParsedIntent) -> Unit,
+        ) = when (handled) {
+            true -> false
+            false -> {
+                consumer(this)
+                handled = true
+                true
+            }
+        }
+    }
+
+    private val mediaRepository = getApplication<TwelveApplication>().mediaRepository
+
+    private val okHttpClient = OkHttpClient.Builder()
+        .build()
+
+    private val currentIntent = MutableStateFlow<Intent?>(null)
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    val parsedIntent = currentIntent
+        .mapLatest { currentIntent ->
+            val intent = currentIntent ?: run {
+                Log.i(LOG_TAG, "No intent")
+                return@mapLatest null
+            }
+
+            val action = when (intent.action) {
+                null,
+                Intent.ACTION_MAIN -> when (
+                    intent.getBooleanExtra(MainActivity.EXTRA_OPEN_NOW_PLAYING, false)
+                ) {
+                    true -> ParsedIntent.Action.OPEN_NOW_PLAYING
+                    false -> ParsedIntent.Action.MAIN
+                }
+
+                Intent.ACTION_VIEW,
+                MediaStore.ACTION_REVIEW,
+                MediaStore.ACTION_REVIEW_SECURE -> ParsedIntent.Action.VIEW
+
+                else -> run {
+                    Log.e(LOG_TAG, "Unknown intent action ${intent.action}")
+                    return@mapLatest null
+                }
+            }
+
+            val contents = mutableListOf<ParsedIntent.Content>().apply {
+                intent.data?.let { data ->
+                    uriToContent(
+                        data,
+                        intent.type?.let { MimeUtils.mimeTypeToMediaType(it) }
+                    )?.let {
+                        add(it)
+                    }
+                }
+
+                intent.clipData?.let { clipData ->
+                    // Do a best effort to get a valid media type from the clip data
+                    var mediaType: MediaType? = null
+                    for (i in 0 until clipData.description.mimeTypeCount) {
+                        val mimeType = clipData.description.getMimeType(i)
+                        MimeUtils.mimeTypeToMediaType(mimeType)?.let { type ->
+                            mediaType = type
+                        }
+                    }
+
+                    clipData.asArray().forEach { item ->
+                        uriToContent(item.uri, mediaType)?.let {
+                            add(it)
+                        }
+                    }
+                }
+            }
+
+            ParsedIntent(
+                action = action,
+                contents = contents,
+            )
+        }
+        .flowOn(Dispatchers.IO)
+        .stateIn(
+            viewModelScope,
+            SharingStarted.WhileSubscribed(),
+            null,
+        )
+
+    fun onIntent(intent: Intent?) {
+        currentIntent.value = intent
+    }
+
+    /**
+     * Given a URI and a pre-parsed media type, get a [ParsedIntent.Content] object.
+     */
+    private suspend fun uriToContent(uri: Uri, mediaType: MediaType?): ParsedIntent.Content? {
+        val type = mediaType ?: uriToType(uri) ?: run {
+            Log.e(LOG_TAG, "Cannot get media type of $uri")
+            return null
+        }
+
+        return ParsedIntent.Content(uri, type)
+    }
+
+    /**
+     * Run the URI over the available data sources and check if one of them understands it.
+     * Get the media type of the URI if found.
+     */
+    private suspend fun uriToType(uri: Uri) = when (val it = mediaRepository.mediaTypeOf(uri)) {
+        is RequestStatus.Loading -> throw Exception("Shouldn't return RequestStatus.Loading")
+
+        is RequestStatus.Success -> it.data
+
+        is RequestStatus.Error -> {
+            Log.i(
+                LOG_TAG,
+                "Cannot get media type of $uri, error: ${it.error}, trying manual fallback"
+            )
+
+            when (uri.scheme) {
+                "content", "file" -> applicationContext.contentResolver.getType(uri)?.let { type ->
+                    MimeUtils.mimeTypeToMediaType(type)
+                }
+
+                "http", "https" -> okHttpClient.newCall(
+                    Request.Builder()
+                        .url(uri.toString())
+                        .head()
+                        .build()
+                ).executeAsync().use { response ->
+                    response.header("Content-Type")?.let { type ->
+                        MimeUtils.mimeTypeToMediaType(type)
+                    }
+                }
+
+                "rtsp" -> MediaType.AUDIO // This is either audio-only or A/V, fine either way
+
+                else -> null
+            } ?: run {
+                Log.e(LOG_TAG, "Cannot get media type of $uri")
+                null
+            }
+        }
+    }
+
+    companion object {
+        private val LOG_TAG = IntentsViewModel::class.simpleName!!
+    }
+}