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:
commit
8d07349fa8
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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, _, _, _ ->
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>? {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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(",")
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue