Handle radios when logged in anonymously.

On top this fix, this commit adds support for "My content" and
"Favorites" instance radios (fixes #51), as well as clearly separates instance
radios from user radios.

Radios were a bit unusable when not logged in with an actual authorized
user account, this commit fixes the following elements:

 * Anonymous users get a transient session cookie when starting a radio
   session that was not stored and forwarded on playback, meaning no
   radios would play;
 * Anonymous users do not have their own own content. Thus, only the
   "Random" radio makes sense in that context. This commit only display
   the instance radios that are relevant to your authentication status.

"My content" radios needs the user ID to function properly, this commit
also adds retrieving it from the /api/v1/users/users/me/ endpoint, which
now may be used in the future for other purposes.
This commit is contained in:
Antoine POPINEAU 2020-06-21 13:36:42 +02:00
parent 18e981fba5
commit 490de25b05
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
13 changed files with 201 additions and 47 deletions

View File

@ -8,6 +8,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.LoginDialog import com.github.apognu.otter.fragments.LoginDialog
import com.github.apognu.otter.utils.AppContext import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Userinfo
import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf import com.github.kittinunf.fuel.gson.gsonDeserializerOf
@ -103,11 +104,15 @@ class LoginActivity : AppCompatActivity() {
setString("access_token", result.get().token) setString("access_token", result.get().token)
} }
Userinfo.get()?.let {
dialog.dismiss() dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java)) startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish() finish()
} }
throw Exception(getString(R.string.login_error_userinfo))
}
is Result.Failure -> { is Result.Failure -> {
dialog.dismiss() dialog.dismiss()

View File

@ -88,6 +88,10 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, PlayerService::class.java)) startService(Intent(this, PlayerService::class.java))
DownloadService.start(this, PinService::class.java) DownloadService.start(this, PinService::class.java)
GlobalScope.launch(IO) {
Userinfo.get()
}
now_playing_toggle.setOnClickListener { now_playing_toggle.setOnClickListener {
CommandBus.send(Command.ToggleState) CommandBus.send(Command.ToggleState)
} }

View File

@ -7,11 +7,11 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Event import com.github.apognu.otter.utils.*
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.views.LoadingImageView import com.github.apognu.otter.views.LoadingImageView
import com.preference.PowerPreference
import kotlinx.android.synthetic.main.row_radio.view.* import kotlinx.android.synthetic.main.row_radio.view.*
import kotlinx.android.synthetic.main.row_radio_header.view.*
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -22,20 +22,77 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
fun onClick(holder: ViewHolder, radio: Radio) fun onClick(holder: ViewHolder, radio: Radio)
} }
override fun getItemCount() = data.size enum class RowType {
Header,
InstanceRadio,
UserRadio
}
private val instanceRadios: List<Radio> by lazy {
context?.let {
return@lazy when (val username = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("actor_username")) {
"" -> listOf(
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description))
)
else -> listOf(
Radio(0, "actor_content", context.getString(R.string.radio_your_content_title), context.getString(R.string.radio_your_content_description), username),
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)),
Radio(0, "favorites", context.getString(R.string.favorites), context.getString(R.string.radio_favorites_description)),
Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description))
)
}
}
listOf<Radio>()
}
private fun getRadioAt(position: Int): Radio {
return when (getItemViewType(position)) {
RowType.InstanceRadio.ordinal -> instanceRadios[position - 1]
else -> data[position - instanceRadios.size - 2]
}
}
override fun getItemCount() = instanceRadios.size + data.size + 2
override fun getItemId(position: Int) = data[position].id.toLong() override fun getItemId(position: Int) = data[position].id.toLong()
override fun getItemViewType(position: Int): Int {
return when {
position == 0 || position == instanceRadios.size + 1 -> RowType.Header.ordinal
position <= instanceRadios.size -> RowType.InstanceRadio.ordinal
else -> RowType.UserRadio.ordinal
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RadiosAdapter.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RadiosAdapter.ViewHolder {
return when (viewType) {
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false) val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
return ViewHolder(view, listener).also { ViewHolder(view, listener).also {
view.setOnClickListener(it) view.setOnClickListener(it)
} }
} }
else -> ViewHolder(LayoutInflater.from(context).inflate(R.layout.row_radio_header, parent, false), null)
}
}
override fun onBindViewHolder(holder: RadiosAdapter.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RadiosAdapter.ViewHolder, position: Int) {
val radio = data[position] when (getItemViewType(position)) {
RowType.Header.ordinal -> {
context?.let {
when (position) {
0 -> holder.label.text = context.getString(R.string.radio_instance_radios)
instanceRadios.size + 1 -> holder.label.text = context.getString(R.string.radio_user_radios)
}
}
}
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
val radio = getRadioAt(position)
holder.art.visibility = View.VISIBLE holder.art.visibility = View.VISIBLE
holder.name.text = radio.name holder.name.text = radio.name
@ -43,6 +100,8 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
context?.let { context -> context?.let { context ->
val icon = when (radio.radio_type) { val icon = when (radio.radio_type) {
"actor_content" -> R.drawable.library
"favorites" -> R.drawable.favorite
"random" -> R.drawable.shuffle "random" -> R.drawable.shuffle
"less-listened" -> R.drawable.sad "less-listened" -> R.drawable.sad
else -> null else -> null
@ -57,8 +116,11 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
} }
} }
} }
}
}
inner class ViewHolder(view: View, private val listener: OnRadioClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener { inner class ViewHolder(view: View, private val listener: OnRadioClickListener?) : RecyclerView.ViewHolder(view), View.OnClickListener {
val label = view.label
val art = view.art val art = view.art
val name = view.name val name = view.name
val description = view.description val description = view.description
@ -66,7 +128,7 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
var native = false var native = false
override fun onClick(view: View?) { override fun onClick(view: View?) {
listener.onClick(this, data[layoutPosition]) listener?.onClick(this, getRadioAt(layoutPosition))
} }
fun spin() { fun spin() {

View File

@ -12,7 +12,6 @@ import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent import android.view.KeyEvent
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C

View File

@ -6,6 +6,7 @@ import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.Repository import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.Gson import com.google.gson.Gson
@ -18,7 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
data class RadioSessionBody(val radio_type: String, var custom_radio: Int? = null) data class RadioSessionBody(val radio_type: String, var custom_radio: Int? = null, var related_object_id: String? = null)
data class RadioSession(val id: Int) data class RadioSession(val id: Int)
data class RadioTrackBody(val session: Int) data class RadioTrackBody(val session: Int)
data class RadioTrack(val position: Int, val track: RadioTrackID) data class RadioTrack(val position: Int, val track: RadioTrackID)
@ -29,6 +30,7 @@ class RadioPlayer(val context: Context) {
private var currentRadio: Radio? = null private var currentRadio: Radio? = null
private var session: Int? = null private var session: Int? = null
private var cookie: String? = null
private val favoritedRepository = FavoritedRepository(context) private val favoritedRepository = FavoritedRepository(context)
@ -36,8 +38,11 @@ class RadioPlayer(val context: Context) {
Cache.get(context, "radio_type")?.readLine()?.let { radio_type -> Cache.get(context, "radio_type")?.readLine()?.let { radio_type ->
Cache.get(context, "radio_id")?.readLine()?.toInt()?.let { radio_id -> Cache.get(context, "radio_id")?.readLine()?.toInt()?.let { radio_id ->
Cache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session -> Cache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
Cache.get(context, "radio_cookie")?.readLine()?.let { radio_cookie ->
currentRadio = Radio(radio_id, radio_type, "", "") currentRadio = Radio(radio_id, radio_type, "", "")
session = radio_session session = radio_session
cookie = radio_cookie
}
} }
} }
} }
@ -59,6 +64,7 @@ class RadioPlayer(val context: Context) {
Cache.delete(context, "radio_type") Cache.delete(context, "radio_type")
Cache.delete(context, "radio_id") Cache.delete(context, "radio_id")
Cache.delete(context, "radio_session") Cache.delete(context, "radio_session")
Cache.delete(context, "radio_cookie")
} }
fun isActive() = currentRadio != null && session != null fun isActive() = currentRadio != null && session != null
@ -66,24 +72,26 @@ class RadioPlayer(val context: Context) {
private suspend fun createSession() { private suspend fun createSession() {
currentRadio?.let { radio -> currentRadio?.let { radio ->
try { try {
val request = RadioSessionBody(radio.radio_type).apply { val request = RadioSessionBody(radio.radio_type, related_object_id = radio.related_object_id).apply {
if (radio_type == "custom") { if (radio_type == "custom") {
custom_radio = radio.id custom_radio = radio.id
} }
} }
val body = Gson().toJson(request) val body = Gson().toJson(request)
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/")) val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
.authorize() .authorize()
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(body) .body(body)
.awaitObjectResult(gsonDeserializerOf(RadioSession::class.java)) .awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
session = result.get().id session = result.get().id
cookie = response.header("set-cookie").joinToString(";")
Cache.set(context, "radio_type", radio.radio_type.toByteArray()) Cache.set(context, "radio_type", radio.radio_type.toByteArray())
Cache.set(context, "radio_id", radio.id.toString().toByteArray()) Cache.set(context, "radio_id", radio.id.toString().toByteArray())
Cache.set(context, "radio_session", session.toString().toByteArray()) Cache.set(context, "radio_session", session.toString().toByteArray())
Cache.set(context, "radio_cookie", cookie.toString().toByteArray())
prepareNextTrack(true) prepareNextTrack(true)
} catch (e: Exception) { } catch (e: Exception) {
@ -101,6 +109,11 @@ class RadioPlayer(val context: Context) {
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/")) val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
.authorize() .authorize()
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.apply {
cookie?.let {
header("cookie", it)
}
}
.body(body) .body(body)
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java)) .awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))

View File

@ -1,7 +1,6 @@
package com.github.apognu.otter.repositories package com.github.apognu.otter.repositories
import android.content.Context import android.content.Context
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.FunkwhaleResponse import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Radio import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.utils.RadiosCache import com.github.apognu.otter.utils.RadiosCache
@ -19,15 +18,7 @@ class RadiosRepository(override val context: Context?) : Repository<Radio, Radio
override fun onDataFetched(data: List<Radio>): List<Radio> { override fun onDataFetched(data: List<Radio>): List<Radio> {
return data return data
.map { radio -> .map { radio -> radio.apply { radio_type = "custom" } }
radio.apply { radio_type = "custom" }
}
.toMutableList() .toMutableList()
.apply {
context?.let { context ->
add(0, Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)))
add(1, Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description)))
}
}
} }
} }

View File

@ -3,6 +3,10 @@ package com.github.apognu.otter.utils
import com.google.android.exoplayer2.offline.Download import com.google.android.exoplayer2.offline.Download
import com.preference.PowerPreference import com.preference.PowerPreference
data class User(
val full_username: String
)
sealed class CacheItem<D : Any>(val data: List<D>) sealed class CacheItem<D : Any>(val data: List<D>)
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data) class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data) class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
@ -147,7 +151,8 @@ data class Radio(
val id: Int, val id: Int,
var radio_type: String, var radio_type: String,
val name: String, val name: String,
val description: String val description: String,
var related_object_id: String? = null
) )
data class DownloadInfo( data class DownloadInfo(

View File

@ -0,0 +1,34 @@
package com.github.apognu.otter.utils
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.result.Result
import com.preference.PowerPreference
object Userinfo {
suspend fun get(): User? {
try {
val hostname = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val (_, _, result) = Fuel.get("$hostname/api/v1/users/users/me/")
.authorize()
.awaitObjectResponseResult(gsonDeserializerOf(User::class.java))
return when (result) {
is Result.Success -> {
val user = result.get()
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("actor_username", user.full_username)
}
user
}
else -> null
}
} catch (e: Exception) {
return null
}
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,7h-3v5.5c0,1.38 -1.12,2.5 -2.5,2.5S10,13.88 10,12.5s1.12,-2.5 2.5,-2.5c0.57,0 1.08,0.19 1.5,0.51L14,5h4v2zM4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6z"/>
</vector>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorPrimary"
android:padding="8dp">
<TextView
android:id="@+id/label"
style="@style/AppTheme.ListHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -10,6 +10,7 @@
<string name="login_logging_in">Connexion</string> <string name="login_logging_in">Connexion</string>
<string name="login_error_hostname">Cela ne semble pas être un nom d\hôte valide</string> <string name="login_error_hostname">Cela ne semble pas être un nom d\hôte valide</string>
<string name="login_error_hostname_https">Le nom d\'hôte Funkwhale devrait être sécurisé à travers HTTPS</string> <string name="login_error_hostname_https">Le nom d\'hôte Funkwhale devrait être sécurisé à travers HTTPS</string>
<string name="login_error_userinfo">Nous n\'avons pas pu récupérer les informations à propos de votre utilisateur</string>
<string name="toolbar_search">Rechercher</string> <string name="toolbar_search">Rechercher</string>
<string name="title_downloads">Téléchargements</string> <string name="title_downloads">Téléchargements</string>
<string name="title_settings">Paramètres</string> <string name="title_settings">Paramètres</string>
@ -88,6 +89,11 @@
<string name="track_info_details_track_bitrate">Bitrate</string> <string name="track_info_details_track_bitrate">Bitrate</string>
<string name="track_info_details_track_instance">Instance Funkwhale</string> <string name="track_info_details_track_instance">Instance Funkwhale</string>
<string name="radio_playback_error">Une erreur s\'est produite lors de la lecture de cette radio</string> <string name="radio_playback_error">Une erreur s\'est produite lors de la lecture de cette radio</string>
<string name="radio_instance_radios">Radios de l\'instance</string>
<string name="radio_user_radios">Radios des utilisateurs</string>
<string name="radio_your_content_title">Votre contenu</string>
<string name="radio_your_content_description">Une sélection de votre propre bibliothèque.</string>
<string name="radio_favorites_description">Jouez vos morceaux favoris dans une boucle allègre infinie.</string>
<string name="radio_random_title">Aléatoire</string> <string name="radio_random_title">Aléatoire</string>
<string name="radio_random_description">Choix de pistes totalement aléatoires, vous découvrirez peut-être quelque chose ?</string> <string name="radio_random_description">Choix de pistes totalement aléatoires, vous découvrirez peut-être quelque chose ?</string>
<string name="radio_less_listened_title">Moins écoutées</string> <string name="radio_less_listened_title">Moins écoutées</string>

View File

@ -11,6 +11,7 @@
<string name="login_logging_in">Logging in</string> <string name="login_logging_in">Logging in</string>
<string name="login_error_hostname">This could not be understood as a valid URL</string> <string name="login_error_hostname">This could not be understood as a valid URL</string>
<string name="login_error_hostname_https">The Funkwhale hostname should be secure through HTTPS</string> <string name="login_error_hostname_https">The Funkwhale hostname should be secure through HTTPS</string>
<string name="login_error_userinfo">We could not retrieve information about your user</string>
<string name="toolbar_search">Search</string> <string name="toolbar_search">Search</string>
<string name="title_downloads">Downloads</string> <string name="title_downloads">Downloads</string>
<string name="title_settings">Settings</string> <string name="title_settings">Settings</string>
@ -89,6 +90,11 @@
<string name="track_info_details_track_bitrate">Bitrate</string> <string name="track_info_details_track_bitrate">Bitrate</string>
<string name="track_info_details_track_instance">Funkwhale instance</string> <string name="track_info_details_track_instance">Funkwhale instance</string>
<string name="radio_playback_error">There was an error while trying to play this radio</string> <string name="radio_playback_error">There was an error while trying to play this radio</string>
<string name="radio_instance_radios">Instance radios</string>
<string name="radio_user_radios">User radios</string>
<string name="radio_your_content_title">Your content</string>
<string name="radio_your_content_description">Picks from your own libraries</string>
<string name="radio_favorites_description"> Play your favorites tunes in a never-ending happiness loop.</string>
<string name="radio_random_title">Random</string> <string name="radio_random_title">Random</string>
<string name="radio_random_description">Totally random picks, maybe you\'ll discover new things?</string> <string name="radio_random_description">Totally random picks, maybe you\'ll discover new things?</string>
<string name="radio_less_listened_title">Less listened</string> <string name="radio_less_listened_title">Less listened</string>

View File

@ -93,4 +93,10 @@
<item name="android:textColor">@color/controlColor</item> <item name="android:textColor">@color/controlColor</item>
</style> </style>
<style name="AppTheme.ListHeader">
<item name="android:fontFamily">sans-serif-medium</item>
<item name="android:textSize">14sp</item>
<item name="android:textColor">@android:color/white</item>
</style>
</resources> </resources>