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.fragments.LoginDialog
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.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
@ -103,9 +104,13 @@ class LoginActivity : AppCompatActivity() {
setString("access_token", result.get().token)
}
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
Userinfo.get()?.let {
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
}
throw Exception(getString(R.string.login_error_userinfo))
}
is Result.Failure -> {

View File

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

View File

@ -7,11 +7,11 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.utils.*
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_header.view.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect
@ -22,43 +22,105 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
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 onCreateViewHolder(parent: ViewGroup, viewType: Int): RadiosAdapter.ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
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
}
}
return ViewHolder(view, listener).also {
view.setOnClickListener(it)
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)
ViewHolder(view, listener).also {
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) {
val radio = data[position]
holder.art.visibility = View.VISIBLE
holder.name.text = radio.name
holder.description.text = radio.description
context?.let { context ->
val icon = when (radio.radio_type) {
"random" -> R.drawable.shuffle
"less-listened" -> R.drawable.sad
else -> null
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)
}
}
}
icon?.let {
holder.native = true
RowType.InstanceRadio.ordinal, RowType.UserRadio.ordinal -> {
val radio = getRadioAt(position)
holder.art.setImageDrawable(context.getDrawable(icon))
holder.art.alpha = 0.7f
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
holder.art.visibility = View.VISIBLE
holder.name.text = radio.name
holder.description.text = radio.description
context?.let { context ->
val icon = when (radio.radio_type) {
"actor_content" -> R.drawable.library
"favorites" -> R.drawable.favorite
"random" -> R.drawable.shuffle
"less-listened" -> R.drawable.sad
else -> null
}
icon?.let {
holder.native = true
holder.art.setImageDrawable(context.getDrawable(icon))
holder.art.alpha = 0.7f
holder.art.setColorFilter(context.getColor(R.color.controlForeground))
}
}
}
}
}
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 name = view.name
val description = view.description
@ -66,7 +128,7 @@ class RadiosAdapter(val context: Context?, private val listener: OnRadioClickLis
var native = false
override fun onClick(view: View?) {
listener.onClick(this, data[layoutPosition])
listener?.onClick(this, getRadioAt(layoutPosition))
}
fun spin() {

View File

@ -12,7 +12,6 @@ import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
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.utils.*
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.gson.gsonDeserializerOf
import com.google.gson.Gson
@ -18,7 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
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 RadioTrackBody(val session: Int)
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 session: Int? = null
private var cookie: String? = null
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_id")?.readLine()?.toInt()?.let { radio_id ->
Cache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
currentRadio = Radio(radio_id, radio_type, "", "")
session = radio_session
Cache.get(context, "radio_cookie")?.readLine()?.let { radio_cookie ->
currentRadio = Radio(radio_id, radio_type, "", "")
session = radio_session
cookie = radio_cookie
}
}
}
}
@ -59,6 +64,7 @@ class RadioPlayer(val context: Context) {
Cache.delete(context, "radio_type")
Cache.delete(context, "radio_id")
Cache.delete(context, "radio_session")
Cache.delete(context, "radio_cookie")
}
fun isActive() = currentRadio != null && session != null
@ -66,24 +72,26 @@ class RadioPlayer(val context: Context) {
private suspend fun createSession() {
currentRadio?.let { radio ->
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") {
custom_radio = radio.id
}
}
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()
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResult(gsonDeserializerOf(RadioSession::class.java))
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
session = result.get().id
cookie = response.header("set-cookie").joinToString(";")
Cache.set(context, "radio_type", radio.radio_type.toByteArray())
Cache.set(context, "radio_id", radio.id.toString().toByteArray())
Cache.set(context, "radio_session", session.toString().toByteArray())
Cache.set(context, "radio_cookie", cookie.toString().toByteArray())
prepareNextTrack(true)
} catch (e: Exception) {
@ -101,6 +109,11 @@ class RadioPlayer(val context: Context) {
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
.authorize()
.header("Content-Type", "application/json")
.apply {
cookie?.let {
header("cookie", it)
}
}
.body(body)
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))

View File

@ -1,7 +1,6 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Radio
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> {
return data
.map { radio ->
radio.apply { radio_type = "custom" }
}
.map { radio -> radio.apply { radio_type = "custom" } }
.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.preference.PowerPreference
data class User(
val full_username: String
)
sealed class CacheItem<D : Any>(val data: List<D>)
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
@ -147,7 +151,8 @@ data class Radio(
val id: Int,
var radio_type: String,
val name: String,
val description: String
val description: String,
var related_object_id: String? = null
)
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_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_userinfo">Nous n\'avons pas pu récupérer les informations à propos de votre utilisateur</string>
<string name="toolbar_search">Rechercher</string>
<string name="title_downloads">Téléchargements</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_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_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_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>

View File

@ -11,6 +11,7 @@
<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_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="title_downloads">Downloads</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_instance">Funkwhale instance</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_description">Totally random picks, maybe you\'ll discover new things?</string>
<string name="radio_less_listened_title">Less listened</string>

View File

@ -93,4 +93,10 @@
<item name="android:textColor">@color/controlColor</item>
</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>