Merge branch 'feature/48-implement-oauth' into 'develop'

#48: Implement OAuth2 authentication

Closes #48

See merge request funkwhale/funkwhale-android!39
This commit is contained in:
Ryan Harg 2021-07-23 12:10:14 +00:00
commit 8d07349fa8
26 changed files with 838 additions and 439 deletions

View File

@ -51,6 +51,8 @@ android {
versionCode = androidGitVersion.code()
versionName = androidGitVersion.name()
manifestPlaceholders["appAuthRedirectScheme"] = "urn"
}
signingConfigs {
@ -158,4 +160,6 @@ dependencies {
implementation("com.google.code.gson:gson:2.8.7")
implementation("com.squareup.picasso:picasso:2.71828")
implementation("jp.wasabeef:picasso-transformations:2.4.0")
implementation("net.openid:appauth:0.9.1")
}

View File

@ -1,82 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="audio.funkwhale.ffa">
package="audio.funkwhale.ffa">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<application
android:name="audio.funkwhale.ffa.FFA"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/security"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<application
android:name="audio.funkwhale.ffa.FFA"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/security"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".activities.SplashActivity"
android:launchMode="singleInstance"
android:noHistory="true">
<activity
android:name=".activities.SplashActivity"
android:launchMode="singleInstance"
android:noHistory="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</activity>
<activity
android:name=".activities.LoginActivity"
android:configChanges="screenSize|orientation"
android:launchMode="singleInstance" />
<activity
android:name=".activities.LoginActivity"
android:configChanges="screenSize|orientation"
android:launchMode="singleInstance" />
<activity android:name=".activities.MainActivity" />
<activity android:name=".activities.MainActivity" />
<activity
android:name=".activities.SearchActivity"
android:launchMode="singleTop" />
<activity
android:name=".activities.SearchActivity"
android:launchMode="singleTop" />
<activity android:name=".activities.DownloadsActivity" />
<activity android:name=".activities.DownloadsActivity" />
<activity android:name=".activities.SettingsActivity" />
<activity android:name=".activities.SettingsActivity" />
<activity android:name=".activities.LicencesActivity" />
<activity android:name=".activities.LicencesActivity" />
<service
android:name=".playback.PlayerService"
android:foregroundServiceType="mediaPlayback">
<service
android:name=".playback.PlayerService"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
</service>
<service
android:name=".playback.PinService"
android:exported="false">
<service
android:name=".playback.PinService"
android:exported="false">
<intent-filter>
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
</service>
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</application>
</manifest>

View File

@ -70,7 +70,7 @@ class FFA : Application() {
Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler())
FFA.Companion.instance = this
instance = this
when (PowerPreference.getDefaultFile().getString("night_mode")) {
"on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)

View File

@ -4,7 +4,6 @@ import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.doOnLayout
@ -13,12 +12,13 @@ import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.ActivityLoginBinding
import audio.funkwhale.ffa.fragments.LoginDialog
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.Userinfo
import audio.funkwhale.ffa.utils.log
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.google.gson.Gson
import com.preference.PowerPreference
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
@ -29,62 +29,79 @@ class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
limitContainerWidth()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
data?.let {
when (requestCode) {
0 -> {
OAuth.exchange(this, data,
{
PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
.setBoolean("anonymous", false)
lifecycleScope.launch(Main) {
Userinfo.get(this@LoginActivity)?.let {
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
return@launch finish()
}
throw Exception(getString(R.string.login_error_userinfo))
}
},
{ "error".log() }
)
}
}
}
}
override fun onResume() {
super.onResume()
with(binding) {
login.setOnClickListener {
var hostname = hostname.text.toString().trim()
binding.anonymous.setOnCheckedChangeListener { _, isChecked ->
val state = when (isChecked) {
true -> View.GONE
false -> View.VISIBLE
}
try {
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
binding.usernameField.visibility = state
binding.passwordField.visibility = state
}
Uri.parse(hostname).apply {
if (!cleartext.isChecked && scheme == "http") {
throw Exception(getString(R.string.login_error_hostname_https))
}
binding.login?.setOnClickListener {
var hostname = binding.hostname.text.toString().trim()
val username = binding.username.text.toString()
val password = binding.password.text.toString()
try {
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
Uri.parse(hostname).apply {
if (!binding.cleartext.isChecked && scheme == "http") {
throw Exception(getString(R.string.login_error_hostname_https))
}
if (scheme == null) {
hostname = when (binding.cleartext.isChecked) {
true -> "http://$hostname"
false -> "https://$hostname"
if (scheme == null) {
hostname = when (cleartext.isChecked) {
true -> "http://$hostname"
false -> "https://$hostname"
}
}
}
hostnameField.error = ""
when (anonymous.isChecked) {
false -> authedLogin(hostname)
true -> anonymousLogin(hostname)
}
} catch (e: Exception) {
val message =
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
else e.message
hostnameField.error = message
}
binding.hostnameField.error = ""
when (binding.anonymous.isChecked) {
false -> authedLogin(hostname, username, password)
true -> anonymousLogin(hostname)
}
} catch (e: Exception) {
val message =
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
else e.message
binding.hostnameField.error = message
}
}
}
@ -95,65 +112,13 @@ class LoginActivity : AppCompatActivity() {
limitContainerWidth()
}
private fun authedLogin(hostname: String, username: String, password: String) {
val body = mapOf(
"username" to username,
"password" to password
).toList()
private fun authedLogin(hostname: String) {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname)
val dialog = LoginDialog().apply {
show(supportFragmentManager, "LoginDialog")
}
OAuth.init(hostname)
lifecycleScope.launch(Main) {
try {
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
when (result) {
is Result.Success -> {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("hostname", hostname)
setBoolean("anonymous", false)
setString("username", username)
setString("password", password)
setString("access_token", result.get().token)
}
Userinfo.get()?.let {
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
return@launch finish()
}
throw Exception(getString(R.string.login_error_userinfo))
}
is Result.Failure -> {
dialog.dismiss()
val error = Gson().fromJson(String(response.data), FwCredentials::class.java)
binding.hostnameField.error = null
binding.usernameField.error = null
if (error != null && error.non_field_errors?.isNotEmpty() == true) {
binding.usernameField.error = error.non_field_errors[0]
} else {
binding.hostnameField.error = result.error.localizedMessage
}
}
}
} catch (e: Exception) {
dialog.dismiss()
val message =
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
else e.message
binding.hostnameField.error = message
}
OAuth.register {
OAuth.authorize(this)
}
}

View File

@ -103,7 +103,7 @@ class MainActivity : AppCompatActivity() {
CommandBus.send(Command.RefreshService)
lifecycleScope.launch(IO) {
Userinfo.get()
Userinfo.get(this@MainActivity)
}
with(binding) {
@ -630,7 +630,7 @@ class MainActivity : AppCompatActivity() {
try {
Fuel
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
.authorize()
.authorize(this@MainActivity)
.header("Content-Type", "application/json")
.body(Gson().toJson(mapOf("track" to track.id)))
.awaitStringResponse()

View File

@ -78,7 +78,7 @@ class SettingsFragment :
activity?.let { activity ->
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.also { clip ->
Cache.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
clip.setPrimaryClip(ClipData.newPlainText("Otter logs", it))
clip.setPrimaryClip(ClipData.newPlainText("Funkwhale logs", it))
Toast.makeText(
activity,
@ -95,7 +95,7 @@ class SettingsFragment :
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.logout_title))
.setMessage(context.getString(R.string.logout_content))
.setPositiveButton(android.R.string.yes) { _, _ ->
.setPositiveButton(android.R.string.ok) { _, _ ->
CommandBus.send(Command.ClearQueue)
FFA.get().deleteAllData()
@ -103,7 +103,7 @@ class SettingsFragment :
activity?.setResult(MainActivity.ResultCode.LOGOUT.code)
activity?.finish()
}
.setNegativeButton(android.R.string.no, null)
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}

View File

@ -6,6 +6,7 @@ import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.Settings
class SplashActivity : AppCompatActivity() {
@ -13,22 +14,21 @@ class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
when (Settings.hasAccessToken() || Settings.isAnonymous()) {
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE)
.apply {
when (OAuth.isAuthorized(this@SplashActivity) || Settings.isAnonymous()) {
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
}
startActivity(this)
}
false -> Intent(this@SplashActivity, LoginActivity::class.java).apply {
FFA.get().deleteAllData()
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
false -> Intent(this@SplashActivity, LoginActivity::class.java).apply {
FFA.get().deleteAllData()
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
}
}
}
}
}
}

View File

@ -0,0 +1,48 @@
package audio.funkwhale.ffa.playback
import android.content.Context
import android.net.Uri
import audio.funkwhale.ffa.utils.OAuth
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory
import com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.TransferListener
class OAuthDatasource(
private val context: Context,
private val http: HttpDataSource
) : DataSource {
override fun addTransferListener(transferListener: TransferListener?) {
http.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec?): Long {
OAuth.tryRefreshAccessToken(context)
return http.open(dataSpec)
}
override fun read(buffer: ByteArray?, offset: Int, readLength: Int): Int {
return http.read(buffer, offset, readLength)
}
override fun getUri(): Uri? {
return http.uri
}
override fun close() {
http.close()
}
}
class OAuth2DatasourceFactory(
private val context: Context,
private val http: DefaultHttpDataSourceFactory
) : DataSource.Factory {
override fun createDataSource(): DataSource {
return OAuthDatasource(context, http.createDataSource())
}
}

View File

@ -4,7 +4,16 @@ import android.content.Context
import android.net.Uri
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.utils.Cache
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.QueueCache
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
@ -21,16 +30,21 @@ class QueueManager(val context: Context) {
var current = -1
companion object {
fun factory(context: Context): CacheDataSourceFactory {
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
defaultRequestProperties.apply {
if (!Settings.isAnonymous()) {
set("Authorization", "Bearer ${Settings.getAccessToken()}")
val http = DefaultHttpDataSourceFactory(
Util.getUserAgent(context, context.getString(R.string.app_name))
)
.apply {
defaultRequestProperties.apply {
if (!Settings.isAnonymous()) {
set("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
}
}
val playbackCache = CacheDataSourceFactory(FFA.get().exoCache, http)
val playbackCache =
CacheDataSourceFactory(FFA.get().exoCache, OAuth2DatasourceFactory(context, http))
return CacheDataSourceFactory(
FFA.get().exoDownloadCache,
@ -53,7 +67,8 @@ class QueueManager(val context: Context) {
datasources.addMediaSources(metadata.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
ProgressiveMediaSource.Factory(factory).setTag(track.title)
.createMediaSource(Uri.parse(url))
})
}
}

View File

@ -4,7 +4,16 @@ import android.content.Context
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.repositories.FavoritedRepository
import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.utils.Cache
import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import audio.funkwhale.ffa.utils.Radio
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.authorize
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import audio.funkwhale.ffa.utils.toast
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
@ -80,7 +89,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
val body = Gson().toJson(request)
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
.authorize()
.authorize(context)
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
@ -107,7 +116,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
try {
val body = Gson().toJson(RadioTrackBody(session))
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
.authorize()
.authorize(context)
.header("Content-Type", "application/json")
.apply {
cookie?.let {
@ -118,7 +127,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
.authorize()
.authorize(context)
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)

View File

@ -4,12 +4,13 @@ import android.content.Context
import audio.funkwhale.ffa.utils.Album
import audio.funkwhale.ffa.utils.AlbumsCache
import audio.funkwhale.ffa.utils.AlbumsResponse
import audio.funkwhale.ffa.utils.OtterResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class AlbumsRepository(override val context: Context?, artistId: Int? = null) : Repository<Album, AlbumsCache>() {
class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
Repository<Album, AlbumsCache>() {
override val cacheId: String by lazy {
if (artistId == null) "albums"
else "albums-artist-$artistId"
@ -20,7 +21,8 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
if (artistId == null) "/api/v1/albums/?playable=true&ordering=title"
else "/api/v1/albums/?playable=true&artist=$artistId&ordering=release_date"
HttpUpstream<Album, OtterResponse<Album>>(
HttpUpstream(
context!!,
HttpUpstream.Behavior.Progressive,
url,
object : TypeToken<AlbumsResponse>() {}.type
@ -28,5 +30,6 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
}
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
}

View File

@ -9,10 +9,19 @@ import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : Repository<Track, TracksCache>() {
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) :
Repository<Track, TracksCache>() {
override val cacheId = "tracks-artist-$artistId"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken<TracksResponse>() {}.type)
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/tracks/?playable=true&artist=$artistId",
object : TypeToken<TracksResponse>() {}.type
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
}

View File

@ -10,9 +10,17 @@ import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
override val cacheId = "artists"
override val upstream = HttpUpstream<Artist, OtterResponse<Artist>>(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true&ordering=name", object : TypeToken<ArtistsResponse>() {}.type)
override val upstream = HttpUpstream<Artist, OtterResponse<Artist>>(
context,
HttpUpstream.Behavior.Progressive,
"/api/v1/artists/?playable=true&ordering=name",
object : TypeToken<ArtistsResponse>() {}.type
)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
}

View File

@ -2,7 +2,19 @@ package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.utils.Cache
import audio.funkwhale.ffa.utils.FavoritedCache
import audio.funkwhale.ffa.utils.FavoritedResponse
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.TracksCache
import audio.funkwhale.ffa.utils.TracksResponse
import audio.funkwhale.ffa.utils.authorize
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import audio.funkwhale.ffa.utils.untilNetwork
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
@ -15,13 +27,21 @@ import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
override val cacheId = "favorites.v2"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true&ordering=title", object : TypeToken<TracksResponse>() {}.type)
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(
context!!,
HttpUpstream.Behavior.AtOnce,
"/api/v1/tracks/?favorites=true&playable=true&ordering=title",
object : TypeToken<TracksResponse>() {}.type
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
private val favoritedRepository = FavoritedRepository(context)
private val favoritedRepository = FavoritedRepository(context!!)
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
@ -41,50 +61,62 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
}
fun addFavorite(id: Int) {
val body = mapOf("track" to id)
context?.let {
val body = mapOf("track" to id)
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
}
scope.launch(IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
scope.launch(IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
favoritedRepository.update(context, scope)
favoritedRepository.update(context, scope)
}
}
}
fun deleteFavorite(id: Int) {
val body = mapOf("track" to id)
context?.let {
val body = mapOf("track" to id)
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
if (!Settings.isAnonymous()) {
request.header("Authorization", "Bearer ${Settings.getAccessToken()}")
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
request.header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
}
scope.launch(IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
scope.launch(IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
favoritedRepository.update(context, scope)
favoritedRepository.update(context, scope)
}
}
}
}
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
override val cacheId = "favorited"
override val upstream = HttpUpstream<Int, OtterResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken<FavoritedResponse>() {}.type)
override val upstream = HttpUpstream<Int, OtterResponse<Int>>(
context,
HttpUpstream.Behavior.Single,
"/api/v1/favorites/tracks/all/?playable=true",
object : TypeToken<FavoritedResponse>() {}.type
)
override fun cache(data: List<Int>) = FavoritedCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)
fun update(context: Context?, scope: CoroutineScope) {
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->

View File

@ -1,5 +1,6 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import android.net.Uri
import audio.funkwhale.ffa.utils.*
import com.github.kittinunf.fuel.Fuel
@ -19,48 +20,57 @@ import java.lang.reflect.Type
import kotlin.math.ceil
class HttpUpstream<D : Any, R : OtterResponse<D>>(
val context: Context?,
val behavior: Behavior,
private val url: String,
private val type: Type
) : Upstream<D> {
enum class Behavior {
Single, AtOnce, Progressive
Single,
AtOnce,
Progressive
}
private val http = HTTP(context)
override fun fetch(size: Int): Flow<Repository.Response<D>> = flow<Repository.Response<D>> {
if (behavior == Behavior.Single && size != 0) return@flow
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
context?.let {
if (behavior == Behavior.Single && size != 0) return@flow
val url =
Uri.parse(url)
.buildUpon()
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
.appendQueryParameter("page", page.toString())
.appendQueryParameter("scope", Settings.getScopes().joinToString(","))
.build()
.toString()
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
get(url).fold(
{ response ->
val data = response.getData()
val url =
Uri.parse(url)
.buildUpon()
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
.appendQueryParameter("page", page.toString())
.appendQueryParameter("scope", Settings.getScopes().joinToString(" "))
.build()
.toString()
when (behavior) {
Behavior.Single -> emit(networkResponse(data, page, false))
Behavior.Progressive -> emit(networkResponse(data, page, response.next != null))
else -> {
emit(networkResponse(data, page, response.next != null))
if (response.next != null) fetch(size + data.size).collect { emit(it) }
get(it, url).fold(
{ response ->
val data = response.getData()
when (behavior) {
Behavior.Single -> emit(networkResponse(data, page, false))
Behavior.Progressive -> emit(networkResponse(data, page, response.next != null))
else -> {
emit(networkResponse(data, page, response.next != null))
if (response.next != null) fetch(size + data.size).collect { emit(it) }
}
}
},
{ error ->
when (error.exception) {
is RefreshError -> EventBus.send(Event.LogOut)
else -> emit(Repository.Response(Repository.Origin.Network, listOf(), page, false))
}
}
},
{ error ->
when (error.exception) {
is RefreshError -> EventBus.send(Event.LogOut)
else -> emit(networkResponse(listOf(), page, false))
}
}
)
)
}
}.flowOn(IO)
private fun networkResponse(data: List<D>, page: Int, hasMore: Boolean) = Repository.Response(
@ -76,12 +86,10 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(
}
}
suspend fun get(url: String): Result<R, FuelError> {
suspend fun get(context: Context, url: String): Result<R, FuelError> {
return try {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
authorize(context)
}
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
@ -97,20 +105,23 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(
}
private suspend fun retryGet(url: String): Result<R, FuelError> {
return try {
return if (HTTP.refresh()) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
context?.let {
return try {
return if (http.refresh()) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
}
request.awaitObjectResult(GenericDeserializer(type))
} else {
Result.Failure(FuelError.wrap(RefreshError))
request.awaitObjectResult(GenericDeserializer(type))
} else {
Result.Failure(FuelError.wrap(RefreshError))
}
} catch (e: Exception) {
Result.error(FuelError.wrap(e))
}
} catch (e: Exception) {
Result.error(FuelError.wrap(e))
}
throw IllegalStateException("Illegal state: context is null")
}
}

View File

@ -12,12 +12,21 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : Repository<PlaylistTrack, PlaylistTracksCache>() {
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) :
Repository<PlaylistTrack, PlaylistTracksCache>() {
override val cacheId = "tracks-playlist-$playlistId"
override val upstream = HttpUpstream<PlaylistTrack, OtterResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
override val upstream = HttpUpstream<PlaylistTrack, OtterResponse<PlaylistTrack>>(
context,
HttpUpstream.Behavior.Single,
"/api/v1/playlists/$playlistId/tracks/?playable=true",
object : TypeToken<PlaylistTracksResponse>() {}.type
)
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)

View File

@ -1,7 +1,15 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Playlist
import audio.funkwhale.ffa.utils.PlaylistsCache
import audio.funkwhale.ffa.utils.PlaylistsResponse
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.authorize
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
@ -15,85 +23,119 @@ import java.io.BufferedReader
data class PlaylistAdd(val tracks: List<Int>, val allow_duplicates: Boolean)
class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
override val cacheId = "tracks-playlists"
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists/?playable=true&ordering=name", object : TypeToken<PlaylistsResponse>() {}.type)
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(
context!!,
HttpUpstream.Behavior.Progressive,
"/api/v1/playlists/?playable=true&ordering=name",
object : TypeToken<PlaylistsResponse>() {}.type
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
}
class ManagementPlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
class ManagementPlaylistsRepository(override val context: Context?) :
Repository<Playlist, PlaylistsCache>() {
override val cacheId = "tracks-playlists-management"
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(HttpUpstream.Behavior.AtOnce, "/api/v1/playlists/?scope=me&ordering=name", object : TypeToken<PlaylistsResponse>() {}.type)
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/playlists/?scope=me&ordering=name",
object : TypeToken<PlaylistsResponse>() {}.type
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
suspend fun new(name: String): Int? {
val body = mapOf("name" to name, "privacy_level" to "me")
context?.let {
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
val body = mapOf("name" to name, "privacy_level" to "me")
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
val (_, response, result) = request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitObjectResponseResult(gsonDeserializerOf(Playlist::class.java))
if (response.statusCode != 201) return null
return result.get().id
}
val (_, response, result) = request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitObjectResponseResult(gsonDeserializerOf(Playlist::class.java))
if (response.statusCode != 201) return null
return result.get().id
throw IllegalStateException("Illegal state: context is null")
}
fun add(id: Int, tracks: List<Track>) {
val body = PlaylistAdd(tracks.map { it.id }, false)
context?.let {
val body = PlaylistAdd(tracks.map { it.id }, false)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/add/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/add/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
scope.launch(Dispatchers.IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
}
scope.launch(Dispatchers.IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
throw IllegalStateException("Illegal state: context is null")
}
suspend fun remove(id: Int, track: Track, index: Int) {
val body = mapOf("index" to index)
context?.let {
val body = mapOf("index" to index)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/remove/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/remove/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
}
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
fun move(id: Int, from: Int, to: Int) {
val body = mapOf("from" to from, "to" to to)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
scope.launch(Dispatchers.IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
throw IllegalStateException("Illegal state: context is null")
}
fun move(id: Int, from: Int, to: Int) {
context?.let {
val body = mapOf("from" to from, "to" to to)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
scope.launch(Dispatchers.IO) {
request
.header("Content-Type", "application/json")
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
}
}
throw IllegalStateException("Illegal state: context is null")
}
}

View File

@ -10,11 +10,19 @@ import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
override val cacheId = "radios"
override val upstream = HttpUpstream<Radio, OtterResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/?ordering=name", object : TypeToken<RadiosResponse>() {}.type)
override val upstream = HttpUpstream<Radio, OtterResponse<Radio>>(
context,
HttpUpstream.Behavior.Progressive,
"/api/v1/radios/radios/?ordering=name",
object : TypeToken<RadiosResponse>() {}.type
)
override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Radio>): List<Radio> {
return data

View File

@ -2,7 +2,16 @@ package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.utils.Album
import audio.funkwhale.ffa.utils.AlbumsCache
import audio.funkwhale.ffa.utils.AlbumsResponse
import audio.funkwhale.ffa.utils.Artist
import audio.funkwhale.ffa.utils.ArtistsCache
import audio.funkwhale.ffa.utils.ArtistsResponse
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.TracksCache
import audio.funkwhale.ffa.utils.TracksResponse
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.map
@ -10,13 +19,22 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class TracksSearchRepository(override val context: Context?, var query: String) : Repository<Track, TracksCache>() {
class TracksSearchRepository(override val context: Context?, var query: String) :
Repository<Track, TracksCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Track>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
get() = HttpUpstream(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/tracks/?playable=true&q=$query",
object : TypeToken<TracksResponse>() {}.type
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
@ -41,20 +59,34 @@ class TracksSearchRepository(override val context: Context?, var query: String)
}
}
class ArtistsSearchRepository(override val context: Context?, var query: String) : Repository<Artist, ArtistsCache>() {
class ArtistsSearchRepository(override val context: Context?, var query: String) :
Repository<Artist, ArtistsCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Artist>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken<ArtistsResponse>() {}.type)
get() = HttpUpstream(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/artists/?playable=true&q=$query",
object : TypeToken<ArtistsResponse>() {}.type
)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
}
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<Album, AlbumsCache>() {
class AlbumsSearchRepository(override val context: Context?, var query: String) :
Repository<Album, AlbumsCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Album>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken<AlbumsResponse>() {}.type)
get() = HttpUpstream(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/albums/?playable=true&q=$query",
object : TypeToken<AlbumsResponse>() {}.type
)
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
}

View File

@ -2,7 +2,12 @@ package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.utils.*
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.TracksCache
import audio.funkwhale.ffa.utils.TracksResponse
import audio.funkwhale.ffa.utils.getMetadata
import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.offline.Download
import com.google.gson.reflect.TypeToken
@ -11,12 +16,21 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class TracksRepository(override val context: Context?, albumId: Int) : Repository<Track, TracksCache>() {
class TracksRepository(override val context: Context?, albumId: Int) :
Repository<Track, TracksCache>() {
override val cacheId = "tracks-album-$albumId"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", object : TypeToken<TracksResponse>() {}.type)
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position",
object : TypeToken<TracksResponse>() {}.type
)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun uncache(reader: BufferedReader) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
companion object {
fun getDownloadedIds(): List<Int>? {

View File

@ -16,18 +16,23 @@ import java.security.MessageDigest
object RefreshError : Throwable()
object HTTP {
class HTTP(val context: Context?) {
suspend fun refresh(): Boolean {
val body = mapOf(
"username" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("username"),
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("password")
"username" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("username"),
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("password")
).toList()
val result = Fuel.post(mustNormalizeUrl("/api/v1/token"), body).awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
val result = Fuel.post(mustNormalizeUrl("/api/v1/token"), body)
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
return result.fold(
{ data ->
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("access_token", data.token)
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.setString("access_token", data.token)
true
},
@ -36,33 +41,44 @@ object HTTP {
}
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
val (_, response, result) = request.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
if (response.statusCode == 401) {
return retryGet(url)
}
return result
}
suspend inline fun <reified T : Any> retryGet(url: String): Result<T, FuelError> {
return if (refresh()) {
context?.let {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
authorize(it)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
} else {
Result.Failure(FuelError.wrap(RefreshError))
val (_, response, result) = request.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
if (response.statusCode == 401) {
return retryGet(url)
} else {
return result
}
}
throw IllegalStateException("Illegal state: context is null")
}
suspend inline fun <reified T : Any> retryGet(
url: String
): Result<T, FuelError> {
context?.let {
return if (refresh()) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
} else {
Result.Failure(FuelError.wrap(RefreshError))
}
}
throw IllegalStateException("Illegal state: context is null")
}
}

View File

@ -1,5 +1,6 @@
package audio.funkwhale.ffa.utils
import android.content.Context
import android.os.Build
import androidx.fragment.app.Fragment
import audio.funkwhale.ffa.R
@ -10,14 +11,21 @@ import com.google.android.exoplayer2.offline.Download
import com.google.gson.Gson
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import net.openid.appauth.ClientSecretPost
import kotlin.coroutines.CoroutineContext
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(scope: CoroutineScope, context: CoroutineContext = Main, crossinline callback: (data: List<D>, isCache: Boolean, page: Int, hasMore: Boolean) -> Unit) {
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
scope: CoroutineScope,
context: CoroutineContext = Main,
crossinline callback: (data: List<D>, isCache: Boolean, page: Int, hasMore: Boolean) -> Unit
) {
scope.launch(context) {
collect { data ->
callback(data.data, data.origin == Repository.Origin.Cache, data.page, data.hasMore)
@ -68,12 +76,29 @@ fun Picasso.maybeLoad(url: String?): RequestCreator {
else load(url)
}
fun Request.authorize(): Request {
return this.apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
fun Request.authorize(context: Context): Request {
return runBlocking {
this@authorize.apply {
if (!Settings.isAnonymous()) {
OAuth.state().let { state ->
val old = state.accessToken
val auth = ClientSecretPost(OAuth.state().clientSecret)
val done = CompletableDeferred<Boolean>()
state.performActionWithFreshTokens(OAuth.service(context), auth) { token, _, _ ->
if (token != old && token != null) {
state.save()
}
header("Authorization", "Bearer ${OAuth.state().accessToken}")
done.complete(true)
}
done.await()
return@runBlocking this
}
}
}
}
}
fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java)
fun Download.getMetadata(): DownloadInfo? =
Gson().fromJson(String(this.request.data), DownloadInfo::class.java)

View File

@ -0,0 +1,196 @@
package audio.funkwhale.ffa.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.fuel.gson.jsonBody
import com.github.kittinunf.result.Result
import com.preference.PowerPreference
import kotlinx.coroutines.runBlocking
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ClientSecretPost
import net.openid.appauth.RegistrationRequest
import net.openid.appauth.RegistrationResponse
import net.openid.appauth.ResponseTypeValues
fun AuthState.save() {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
val value = jsonSerializeString()
setString("state", value)
}
}
object OAuth {
data class App(val client_id: String, val client_secret: String)
private val REDIRECT_URI =
Uri.parse("urn:/audio.funkwhale.funkwhale-android/oauth/callback")
fun isAuthorized(context: Context): Boolean {
val state = tryState()
return if (state != null) {
state.isAuthorized || tryRefreshAccessToken(context)
} else {
false
}.also {
it.log("isAuthorized()")
}
}
fun state(): AuthState = tryState()!!
fun tryRefreshAccessToken(context: Context, overrideNeedsTokenRefresh: Boolean = false): Boolean {
tryState()?.let { state ->
val shouldRefreshAccessToken = overrideNeedsTokenRefresh || state.needsTokenRefresh
if (shouldRefreshAccessToken && state.refreshToken != null) {
val refreshRequest = state.createTokenRefreshRequest()
val auth = ClientSecretPost(state.clientSecret)
runBlocking {
service(context).performTokenRequest(refreshRequest, auth) { response, e ->
state.apply {
update(response, e)
save()
}
}
}
}
}
return (tryState()?.isAuthorized ?: false)
.also {
it.log("tryRefreshAccessToken()")
}
}
fun tryState(): AuthState? {
val savedState = PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("state")
return if (savedState != null && savedState.isNotEmpty()) {
return AuthState.jsonDeserialize(savedState)
} else {
null
}
}
fun init(hostname: String) {
AuthState(config(hostname)).save()
}
fun service(context: Context) = AuthorizationService(context)
fun register(callback: () -> Unit) {
state().authorizationServiceConfiguration?.let { config ->
runBlocking {
val (_, _, result) = Fuel.post(config.registrationEndpoint.toString())
.header("Content-Type", "application/json")
.jsonBody(registrationBody())
.awaitObjectResponseResult(gsonDeserializerOf(App::class.java))
when (result) {
is Result.Success -> {
val app = result.get()
val response = RegistrationResponse.Builder(registration()!!)
.setClientId(app.client_id)
.setClientSecret(app.client_secret)
.setClientIdIssuedAt(0)
.setClientSecretExpiresAt(null)
.build()
state().apply {
update(response)
save()
callback()
}
}
is Result.Failure -> {
}
}
}
}
}
private fun registrationBody(): Map<String, String> {
return mapOf(
"name" to "Funkwhale for Android (${android.os.Build.MODEL})",
"redirect_uris" to REDIRECT_URI.toString(),
"scopes" to "read write"
)
}
fun authorize(context: Activity) {
val intent = service(context).run {
authorizationRequest()?.let {
getAuthorizationRequestIntent(it)
}
}
context.startActivityForResult(intent, 0)
}
fun exchange(context: Activity, authorization: Intent, success: () -> Unit, error: () -> Unit) {
state().let { state ->
state.apply {
update(
AuthorizationResponse.fromIntent(authorization),
AuthorizationException.fromIntent(authorization)
)
save()
}
AuthorizationResponse.fromIntent(authorization)?.let {
val auth = ClientSecretPost(state().clientSecret)
service(context).performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
state
.apply {
update(response, e)
save()
}
if (response != null) success()
else error()
}
}
}
}
private fun config(hostname: String) = AuthorizationServiceConfiguration(
Uri.parse("$hostname/authorize"),
Uri.parse("$hostname/api/v1/oauth/token/"),
Uri.parse("$hostname/api/v1/oauth/apps/")
)
private fun registration() =
state().authorizationServiceConfiguration?.let { config ->
RegistrationRequest.Builder(config, listOf(REDIRECT_URI)).build()
}
private fun authorizationRequest() = state().let { state ->
state.authorizationServiceConfiguration?.let { config ->
AuthorizationRequest.Builder(
config,
state.lastRegistrationResponse?.clientId ?: "",
ResponseTypeValues.CODE,
REDIRECT_URI
)
.setScopes("read", "write")
.build()
}
}
}

View File

@ -1,5 +1,6 @@
package audio.funkwhale.ffa.utils
import android.content.Context
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
@ -7,11 +8,13 @@ import com.github.kittinunf.result.Result
import com.preference.PowerPreference
object Userinfo {
suspend fun get(): User? {
suspend fun get(context: Context): User? {
try {
val hostname = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val hostname =
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val (_, _, result) = Fuel.get("$hostname/api/v1/users/users/me/")
.authorize()
.authorize(context)
.awaitObjectResponseResult(gsonDeserializerOf(User::class.java))
return when (result) {

View File

@ -33,8 +33,8 @@ private fun logClassName(): String {
fun Any?.log(prefix: String? = null) {
prefix?.let {
Log.d("OTTER", "${logClassName()} - $prefix: $this")
} ?: Log.d("OTTER", "${logClassName()} - $this")
Log.d("FFA", "${logClassName()} - $prefix: $this")
} ?: Log.d("FFA", "${logClassName()} - $this")
}
fun maybeNormalizeUrl(rawUrl: String?): String? {
@ -48,7 +48,8 @@ fun maybeNormalizeUrl(rawUrl: String?): String? {
}
fun mustNormalizeUrl(rawUrl: String): String {
val fallbackHost = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val fallbackHost =
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val uri = URI(rawUrl).takeIf { it.host != null } ?: URI("$fallbackHost$rawUrl")
return uri.toString()
@ -71,9 +72,14 @@ fun toDurationString(duration: Long, showSeconds: Boolean = false): String {
}
object Settings {
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
fun hasAccessToken(): Boolean {
return PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
}
fun isAnonymous() =
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
fun areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false)
fun getScopes() = PowerPreference.getDefaultFile().getString("scope", "all").split(",")

View File

@ -18,15 +18,14 @@
android:layout_height="128dp"
android:layout_marginBottom="32dp"
android:contentDescription="@string/alt_app_logo"
android:src="@drawable/funkwhale"/>
android:src="@drawable/funkwhale" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="@string/login_welcome"
android:textAlignment="center"
android:textColor="@color/colorPrimary" />
android:textAlignment="center" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/hostname_field"
@ -34,7 +33,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:textColorHint="@drawable/login_input"
app:boxBackgroundColor="@color/elevatedSurface"
app:boxBackgroundColor="@color/controlAccent"
app:boxBackgroundMode="filled"
app:boxStrokeWidth="0dp"
app:hintTextColor="@drawable/login_input">
@ -47,7 +46,8 @@
android:inputType="textUri"
android:lines="1"
android:text="@string/debug.hostname"
android:textColor="@color/controlColorText"
android:textColor="@android:color/white"
android:background="@color/elevatedSurface"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
@ -56,68 +56,13 @@
android:id="@+id/cleartext"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:buttonTint="@color/controlColorText"
android:text="@string/login_cleartext"
android:textColor="@color/controlColorText" />
android:text="@string/login_cleartext" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/anonymous"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:buttonTint="@color/controlColorText"
android:text="@string/login_anonymous"
android:textColor="@color/controlColorText" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/login_username"
android:textColorHint="@drawable/login_input"
app:boxBackgroundColor="@color/elevatedSurface"
app:boxBackgroundMode="filled"
app:boxStrokeColor="@drawable/login_input"
app:boxStrokeWidth="0dp"
app:hintTextColor="@drawable/login_input">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:lines="1"
android:text="@string/debug.username"
android:textColor="@color/controlColorText"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/login_password"
android:textColorHint="@drawable/login_input"
app:boxBackgroundColor="@color/elevatedSurface"
app:boxBackgroundMode="filled"
app:boxStrokeColor="@drawable/login_input"
app:boxStrokeWidth="0dp"
app:hintTextColor="@drawable/login_input"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:lines="1"
android:text="@string/debug.password"
android:textColor="@color/controlColorText"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
android:text="@string/login_anonymous" />
<com.google.android.material.button.MaterialButton
android:id="@+id/login"