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!!
+ }
+}