From fcfc30a97eb4cc9121bf5fa328fde5175399b8b1 Mon Sep 17 00:00:00 2001 From: Ryan Harg <3821-ryan_harg@users.noreply.dev.funkwhale.audio> Date: Fri, 23 Jul 2021 12:10:13 +0000 Subject: [PATCH] #48: Implement OAuth2 authentication --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 113 +++++----- app/src/main/java/audio/funkwhale/ffa/FFA.kt | 2 +- .../funkwhale/ffa/activities/LoginActivity.kt | 161 ++++++-------- .../funkwhale/ffa/activities/MainActivity.kt | 4 +- .../ffa/activities/SettingsActivity.kt | 6 +- .../ffa/activities/SplashActivity.kt | 28 +-- .../ffa/playback/OAuth2Datasource.kt | 48 +++++ .../funkwhale/ffa/playback/QueueManager.kt | 31 ++- .../funkwhale/ffa/playback/RadioPlayer.kt | 17 +- .../ffa/repositories/AlbumsRepository.kt | 11 +- .../repositories/ArtistTracksRepository.kt | 15 +- .../ffa/repositories/ArtistsRepository.kt | 12 +- .../ffa/repositories/FavoritesRepository.kt | 88 +++++--- .../ffa/repositories/HttpUpstream.kt | 97 +++++---- .../repositories/PlaylistTracksRepository.kt | 15 +- .../ffa/repositories/PlaylistsRepository.kt | 144 ++++++++----- .../ffa/repositories/RadiosRepository.kt | 12 +- .../ffa/repositories/SearchRepository.kt | 52 ++++- .../ffa/repositories/TracksRepository.kt | 22 +- .../java/audio/funkwhale/ffa/utils/Data.kt | 66 +++--- .../audio/funkwhale/ffa/utils/Extensions.kt | 37 +++- .../java/audio/funkwhale/ffa/utils/OAuth.kt | 196 ++++++++++++++++++ .../audio/funkwhale/ffa/utils/Userinfo.kt | 9 +- .../java/audio/funkwhale/ffa/utils/Util.kt | 18 +- app/src/main/res/layout/activity_login.xml | 69 +----- 26 files changed, 838 insertions(+), 439 deletions(-) create mode 100644 app/src/main/java/audio/funkwhale/ffa/playback/OAuth2Datasource.kt create mode 100644 app/src/main/java/audio/funkwhale/ffa/utils/OAuth.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 47ac51f..dd6db60 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 10bfe9a..5e495a2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,82 +1,81 @@ + package="audio.funkwhale.ffa"> - - + + - + - + - + - - - + + + - - + + - + - + - + - + - + - + - + - + - - - + + + - + - + - - - - + + + + - + - - - - - + + + + + - + diff --git a/app/src/main/java/audio/funkwhale/ffa/FFA.kt b/app/src/main/java/audio/funkwhale/ffa/FFA.kt index 93eebb0..2a02fbb 100644 --- a/app/src/main/java/audio/funkwhale/ffa/FFA.kt +++ b/app/src/main/java/audio/funkwhale/ffa/FFA.kt @@ -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) diff --git a/app/src/main/java/audio/funkwhale/ffa/activities/LoginActivity.kt b/app/src/main/java/audio/funkwhale/ffa/activities/LoginActivity.kt index d5ef6d0..b2f4196 100644 --- a/app/src/main/java/audio/funkwhale/ffa/activities/LoginActivity.kt +++ b/app/src/main/java/audio/funkwhale/ffa/activities/LoginActivity.kt @@ -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) } } diff --git a/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt b/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt index bfb2cf9..c1f2e70 100644 --- a/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt +++ b/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt @@ -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() diff --git a/app/src/main/java/audio/funkwhale/ffa/activities/SettingsActivity.kt b/app/src/main/java/audio/funkwhale/ffa/activities/SettingsActivity.kt index 21f4081..9f864f2 100644 --- a/app/src/main/java/audio/funkwhale/ffa/activities/SettingsActivity.kt +++ b/app/src/main/java/audio/funkwhale/ffa/activities/SettingsActivity.kt @@ -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() } } diff --git a/app/src/main/java/audio/funkwhale/ffa/activities/SplashActivity.kt b/app/src/main/java/audio/funkwhale/ffa/activities/SplashActivity.kt index aa62427..26e147d 100644 --- a/app/src/main/java/audio/funkwhale/ffa/activities/SplashActivity.kt +++ b/app/src/main/java/audio/funkwhale/ffa/activities/SplashActivity.kt @@ -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) + } } } - } } + } diff --git a/app/src/main/java/audio/funkwhale/ffa/playback/OAuth2Datasource.kt b/app/src/main/java/audio/funkwhale/ffa/playback/OAuth2Datasource.kt new file mode 100644 index 0000000..e189045 --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/playback/OAuth2Datasource.kt @@ -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()) + } +} diff --git a/app/src/main/java/audio/funkwhale/ffa/playback/QueueManager.kt b/app/src/main/java/audio/funkwhale/ffa/playback/QueueManager.kt index 16f0dc9..baac829 100644 --- a/app/src/main/java/audio/funkwhale/ffa/playback/QueueManager.kt +++ b/app/src/main/java/audio/funkwhale/ffa/playback/QueueManager.kt @@ -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)) }) } } diff --git a/app/src/main/java/audio/funkwhale/ffa/playback/RadioPlayer.kt b/app/src/main/java/audio/funkwhale/ffa/playback/RadioPlayer.kt index 31745c1..a53eddb 100644 --- a/app/src/main/java/audio/funkwhale/ffa/playback/RadioPlayer.kt +++ b/app/src/main/java/audio/funkwhale/ffa/playback/RadioPlayer.kt @@ -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) diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/AlbumsRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/AlbumsRepository.kt index 923f03b..8ff809d 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/AlbumsRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/AlbumsRepository.kt @@ -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() { +class AlbumsRepository(override val context: Context?, artistId: Int? = null) : + Repository() { + 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>( + HttpUpstream( + context!!, HttpUpstream.Behavior.Progressive, url, object : TypeToken() {}.type @@ -28,5 +30,6 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) : } override fun cache(data: List) = AlbumsCache(data) - override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader) + override fun uncache(reader: BufferedReader) = + gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader) } diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistTracksRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistTracksRepository.kt index 69e503c..ef8b8f8 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistTracksRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistTracksRepository.kt @@ -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() { +class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : + Repository() { + override val cacheId = "tracks-artist-$artistId" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context, + HttpUpstream.Behavior.AtOnce, + "/api/v1/tracks/?playable=true&artist=$artistId", + object : TypeToken() {}.type + ) override fun cache(data: List) = TracksCache(data) - override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader) + override fun uncache(reader: BufferedReader) = + gsonDeserializerOf(TracksCache::class.java).deserialize(reader) } diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistsRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistsRepository.kt index a47dc5d..9aed863 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistsRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/ArtistsRepository.kt @@ -10,9 +10,17 @@ import com.google.gson.reflect.TypeToken import java.io.BufferedReader class ArtistsRepository(override val context: Context?) : Repository() { + override val cacheId = "artists" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true&ordering=name", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context, + HttpUpstream.Behavior.Progressive, + "/api/v1/artists/?playable=true&ordering=name", + object : TypeToken() {}.type + ) override fun cache(data: List) = ArtistsCache(data) - override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader) + override fun uncache(reader: BufferedReader) = + gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader) } diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/FavoritesRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/FavoritesRepository.kt index 577c1e1..e30c805 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/FavoritesRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/FavoritesRepository.kt @@ -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() { + override val cacheId = "favorites.v2" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true&ordering=title", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context!!, + HttpUpstream.Behavior.AtOnce, + "/api/v1/tracks/?favorites=true&playable=true&ordering=title", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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): List = runBlocking { val downloaded = TracksRepository.getDownloadedIds() ?: listOf() @@ -41,50 +61,62 @@ class FavoritesRepository(override val context: Context?) : Repository() { override val cacheId = "favorited" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken() {}.type) + override val upstream = HttpUpstream>( + context, + HttpUpstream.Behavior.Single, + "/api/v1/favorites/tracks/all/?playable=true", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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, _, _, _ -> diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/HttpUpstream.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/HttpUpstream.kt index 9164185..e983f3c 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/HttpUpstream.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/HttpUpstream.kt @@ -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>( + val context: Context?, val behavior: Behavior, private val url: String, private val type: Type ) : Upstream { + enum class Behavior { - Single, AtOnce, Progressive + Single, + AtOnce, + Progressive } + private val http = HTTP(context) + override fun fetch(size: Int): Flow> = flow> { - 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, page: Int, hasMore: Boolean) = Repository.Response( @@ -76,12 +86,10 @@ class HttpUpstream>( } } - suspend fun get(url: String): Result { + suspend fun get(context: Context, url: String): Result { 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(type)) @@ -97,20 +105,23 @@ class HttpUpstream>( } private suspend fun retryGet(url: String): Result { - 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") } } diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistTracksRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistTracksRepository.kt index decb62a..9c94261 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistTracksRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistTracksRepository.kt @@ -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() { +class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : + Repository() { + override val cacheId = "tracks-playlist-$playlistId" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context, + HttpUpstream.Behavior.Single, + "/api/v1/playlists/$playlistId/tracks/?playable=true", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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): List = runBlocking { val favorites = FavoritedRepository(context).fetch(Origin.Network.origin) diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistsRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistsRepository.kt index 735154f..703a6ed 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistsRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/PlaylistsRepository.kt @@ -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, val allow_duplicates: Boolean) class PlaylistsRepository(override val context: Context?) : Repository() { + override val cacheId = "tracks-playlists" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists/?playable=true&ordering=name", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context!!, + HttpUpstream.Behavior.Progressive, + "/api/v1/playlists/?playable=true&ordering=name", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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() { +class ManagementPlaylistsRepository(override val context: Context?) : + Repository() { + override val cacheId = "tracks-playlists-management" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/playlists/?scope=me&ordering=name", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context, + HttpUpstream.Behavior.AtOnce, + "/api/v1/playlists/?scope=me&ordering=name", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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) { - 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") } } diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/RadiosRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/RadiosRepository.kt index a3fb360..0126d2e 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/RadiosRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/RadiosRepository.kt @@ -10,11 +10,19 @@ import com.google.gson.reflect.TypeToken import java.io.BufferedReader class RadiosRepository(override val context: Context?) : Repository() { + override val cacheId = "radios" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/?ordering=name", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context, + HttpUpstream.Behavior.Progressive, + "/api/v1/radios/radios/?ordering=name", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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): List { return data diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/SearchRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/SearchRepository.kt index 9a98822..449c3fc 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/SearchRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/SearchRepository.kt @@ -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() { +class TracksSearchRepository(override val context: Context?, var query: String) : + Repository() { + override val cacheId: String? = null + override val upstream: Upstream - get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken() {}.type) + get() = HttpUpstream( + context, + HttpUpstream.Behavior.AtOnce, + "/api/v1/tracks/?playable=true&q=$query", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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): List = 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() { +class ArtistsSearchRepository(override val context: Context?, var query: String) : + Repository() { override val cacheId: String? = null override val upstream: Upstream - get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken() {}.type) + get() = HttpUpstream( + context, + HttpUpstream.Behavior.AtOnce, + "/api/v1/artists/?playable=true&q=$query", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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() { +class AlbumsSearchRepository(override val context: Context?, var query: String) : + Repository() { override val cacheId: String? = null override val upstream: Upstream - get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken() {}.type) + get() = HttpUpstream( + context, + HttpUpstream.Behavior.AtOnce, + "/api/v1/albums/?playable=true&q=$query", + object : TypeToken() {}.type + ) override fun cache(data: List) = AlbumsCache(data) - override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader) + override fun uncache(reader: BufferedReader) = + gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader) } diff --git a/app/src/main/java/audio/funkwhale/ffa/repositories/TracksRepository.kt b/app/src/main/java/audio/funkwhale/ffa/repositories/TracksRepository.kt index 6f9b857..6c63456 100644 --- a/app/src/main/java/audio/funkwhale/ffa/repositories/TracksRepository.kt +++ b/app/src/main/java/audio/funkwhale/ffa/repositories/TracksRepository.kt @@ -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() { +class TracksRepository(override val context: Context?, albumId: Int) : + Repository() { + override val cacheId = "tracks-album-$albumId" - override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", object : TypeToken() {}.type) + + override val upstream = HttpUpstream>( + context, + HttpUpstream.Behavior.AtOnce, + "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", + object : TypeToken() {}.type + ) override fun cache(data: List) = 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? { diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/Data.kt b/app/src/main/java/audio/funkwhale/ffa/utils/Data.kt index cd993f2..c4630fb 100644 --- a/app/src/main/java/audio/funkwhale/ffa/utils/Data.kt +++ b/app/src/main/java/audio/funkwhale/ffa/utils/Data.kt @@ -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 get(url: String): Result { - 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 retryGet(url: String): Result { - 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 retryGet( + url: String + ): Result { + 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") } } diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt b/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt index 38aec66..91138c9 100644 --- a/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt +++ b/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt @@ -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 Flow>.untilNetwork(scope: CoroutineScope, context: CoroutineContext = Main, crossinline callback: (data: List, isCache: Boolean, page: Int, hasMore: Boolean) -> Unit) { +inline fun Flow>.untilNetwork( + scope: CoroutineScope, + context: CoroutineContext = Main, + crossinline callback: (data: List, 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() + + 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) diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/OAuth.kt b/app/src/main/java/audio/funkwhale/ffa/utils/OAuth.kt new file mode 100644 index 0000000..4019be1 --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/utils/OAuth.kt @@ -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 { + 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() + } + } +} diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/Userinfo.kt b/app/src/main/java/audio/funkwhale/ffa/utils/Userinfo.kt index b865c88..ae29f34 100644 --- a/app/src/main/java/audio/funkwhale/ffa/utils/Userinfo.kt +++ b/app/src/main/java/audio/funkwhale/ffa/utils/Userinfo.kt @@ -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) { diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/Util.kt b/app/src/main/java/audio/funkwhale/ffa/utils/Util.kt index 08664bf..4707ae2 100644 --- a/app/src/main/java/audio/funkwhale/ffa/utils/Util.kt +++ b/app/src/main/java/audio/funkwhale/ffa/utils/Util.kt @@ -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(",") diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 4a5bc76..35a6e89 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -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" /> + android:textAlignment="center" /> @@ -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" /> @@ -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" /> - - - - - - - - - - - - + android:text="@string/login_anonymous" />