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()
|
versionCode = androidGitVersion.code()
|
||||||
versionName = androidGitVersion.name()
|
versionName = androidGitVersion.name()
|
||||||
|
|
||||||
|
manifestPlaceholders["appAuthRedirectScheme"] = "urn"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
@ -158,4 +160,6 @@ dependencies {
|
||||||
implementation("com.google.code.gson:gson:2.8.7")
|
implementation("com.google.code.gson:gson:2.8.7")
|
||||||
implementation("com.squareup.picasso:picasso:2.71828")
|
implementation("com.squareup.picasso:picasso:2.71828")
|
||||||
implementation("jp.wasabeef:picasso-transformations:2.4.0")
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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
|
<application
|
||||||
android:name="audio.funkwhale.ffa.FFA"
|
android:name="audio.funkwhale.ffa.FFA"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/security"
|
android:networkSecurityConfig="@xml/security"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SplashActivity"
|
android:name=".activities.SplashActivity"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:noHistory="true">
|
android:noHistory="true">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.LoginActivity"
|
android:name=".activities.LoginActivity"
|
||||||
android:configChanges="screenSize|orientation"
|
android:configChanges="screenSize|orientation"
|
||||||
android:launchMode="singleInstance" />
|
android:launchMode="singleInstance" />
|
||||||
|
|
||||||
<activity android:name=".activities.MainActivity" />
|
<activity android:name=".activities.MainActivity" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SearchActivity"
|
android:name=".activities.SearchActivity"
|
||||||
android:launchMode="singleTop" />
|
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
|
<service
|
||||||
android:name=".playback.PlayerService"
|
android:name=".playback.PlayerService"
|
||||||
android:foregroundServiceType="mediaPlayback">
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".playback.PinService"
|
android:name=".playback.PinService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" />
|
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<receiver android:name="androidx.media.session.MediaButtonReceiver">
|
<receiver android:name="androidx.media.session.MediaButtonReceiver">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -70,7 +70,7 @@ class FFA : Application() {
|
||||||
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler())
|
Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler())
|
||||||
|
|
||||||
FFA.Companion.instance = this
|
instance = this
|
||||||
|
|
||||||
when (PowerPreference.getDefaultFile().getString("night_mode")) {
|
when (PowerPreference.getDefaultFile().getString("night_mode")) {
|
||||||
"on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
"on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||||
|
|
|
@ -4,7 +4,6 @@ import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.view.doOnLayout
|
import androidx.core.view.doOnLayout
|
||||||
|
@ -13,12 +12,13 @@ import audio.funkwhale.ffa.R
|
||||||
import audio.funkwhale.ffa.databinding.ActivityLoginBinding
|
import audio.funkwhale.ffa.databinding.ActivityLoginBinding
|
||||||
import audio.funkwhale.ffa.fragments.LoginDialog
|
import audio.funkwhale.ffa.fragments.LoginDialog
|
||||||
import audio.funkwhale.ffa.utils.AppContext
|
import audio.funkwhale.ffa.utils.AppContext
|
||||||
|
import audio.funkwhale.ffa.utils.OAuth
|
||||||
import audio.funkwhale.ffa.utils.Userinfo
|
import audio.funkwhale.ffa.utils.Userinfo
|
||||||
|
import audio.funkwhale.ffa.utils.log
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import com.github.kittinunf.fuel.Fuel
|
||||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
import com.github.kittinunf.result.Result
|
import com.github.kittinunf.result.Result
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.preference.PowerPreference
|
import com.preference.PowerPreference
|
||||||
import kotlinx.coroutines.Dispatchers.Main
|
import kotlinx.coroutines.Dispatchers.Main
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -29,62 +29,79 @@ class LoginActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var binding: ActivityLoginBinding
|
private lateinit var binding: ActivityLoginBinding
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
limitContainerWidth()
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
with(binding) {
|
||||||
|
login.setOnClickListener {
|
||||||
|
var hostname = hostname.text.toString().trim()
|
||||||
|
|
||||||
binding.anonymous.setOnCheckedChangeListener { _, isChecked ->
|
try {
|
||||||
val state = when (isChecked) {
|
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
|
||||||
true -> View.GONE
|
|
||||||
false -> View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.usernameField.visibility = state
|
Uri.parse(hostname).apply {
|
||||||
binding.passwordField.visibility = state
|
if (!cleartext.isChecked && scheme == "http") {
|
||||||
}
|
throw Exception(getString(R.string.login_error_hostname_https))
|
||||||
|
}
|
||||||
|
|
||||||
binding.login?.setOnClickListener {
|
if (scheme == null) {
|
||||||
var hostname = binding.hostname.text.toString().trim()
|
hostname = when (cleartext.isChecked) {
|
||||||
val username = binding.username.text.toString()
|
true -> "http://$hostname"
|
||||||
val password = binding.password.text.toString()
|
false -> "https://$hostname"
|
||||||
|
}
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
limitContainerWidth()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun authedLogin(hostname: String, username: String, password: String) {
|
private fun authedLogin(hostname: String) {
|
||||||
val body = mapOf(
|
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname)
|
||||||
"username" to username,
|
|
||||||
"password" to password
|
|
||||||
).toList()
|
|
||||||
|
|
||||||
val dialog = LoginDialog().apply {
|
OAuth.init(hostname)
|
||||||
show(supportFragmentManager, "LoginDialog")
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch(Main) {
|
OAuth.register {
|
||||||
try {
|
OAuth.authorize(this)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -103,7 +103,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
CommandBus.send(Command.RefreshService)
|
CommandBus.send(Command.RefreshService)
|
||||||
|
|
||||||
lifecycleScope.launch(IO) {
|
lifecycleScope.launch(IO) {
|
||||||
Userinfo.get()
|
Userinfo.get(this@MainActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
with(binding) {
|
with(binding) {
|
||||||
|
@ -630,7 +630,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
try {
|
try {
|
||||||
Fuel
|
Fuel
|
||||||
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
|
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
|
||||||
.authorize()
|
.authorize(this@MainActivity)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(Gson().toJson(mapOf("track" to track.id)))
|
.body(Gson().toJson(mapOf("track" to track.id)))
|
||||||
.awaitStringResponse()
|
.awaitStringResponse()
|
||||||
|
|
|
@ -78,7 +78,7 @@ class SettingsFragment :
|
||||||
activity?.let { activity ->
|
activity?.let { activity ->
|
||||||
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.also { clip ->
|
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.also { clip ->
|
||||||
Cache.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
|
Cache.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
|
||||||
clip.setPrimaryClip(ClipData.newPlainText("Otter logs", it))
|
clip.setPrimaryClip(ClipData.newPlainText("Funkwhale logs", it))
|
||||||
|
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
activity,
|
activity,
|
||||||
|
@ -95,7 +95,7 @@ class SettingsFragment :
|
||||||
AlertDialog.Builder(context)
|
AlertDialog.Builder(context)
|
||||||
.setTitle(context.getString(R.string.logout_title))
|
.setTitle(context.getString(R.string.logout_title))
|
||||||
.setMessage(context.getString(R.string.logout_content))
|
.setMessage(context.getString(R.string.logout_content))
|
||||||
.setPositiveButton(android.R.string.yes) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
CommandBus.send(Command.ClearQueue)
|
CommandBus.send(Command.ClearQueue)
|
||||||
|
|
||||||
FFA.get().deleteAllData()
|
FFA.get().deleteAllData()
|
||||||
|
@ -103,7 +103,7 @@ class SettingsFragment :
|
||||||
activity?.setResult(MainActivity.ResultCode.LOGOUT.code)
|
activity?.setResult(MainActivity.ResultCode.LOGOUT.code)
|
||||||
activity?.finish()
|
activity?.finish()
|
||||||
}
|
}
|
||||||
.setNegativeButton(android.R.string.no, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import audio.funkwhale.ffa.FFA
|
import audio.funkwhale.ffa.FFA
|
||||||
import audio.funkwhale.ffa.utils.AppContext
|
import audio.funkwhale.ffa.utils.AppContext
|
||||||
|
import audio.funkwhale.ffa.utils.OAuth
|
||||||
import audio.funkwhale.ffa.utils.Settings
|
import audio.funkwhale.ffa.utils.Settings
|
||||||
|
|
||||||
class SplashActivity : AppCompatActivity() {
|
class SplashActivity : AppCompatActivity() {
|
||||||
|
@ -13,22 +14,21 @@ class SplashActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
|
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE)
|
||||||
when (Settings.hasAccessToken() || Settings.isAnonymous()) {
|
.apply {
|
||||||
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
|
when (OAuth.isAuthorized(this@SplashActivity) || Settings.isAnonymous()) {
|
||||||
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
|
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
|
||||||
false -> Intent(this@SplashActivity, LoginActivity::class.java).apply {
|
startActivity(this)
|
||||||
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 android.net.Uri
|
||||||
import audio.funkwhale.ffa.FFA
|
import audio.funkwhale.ffa.FFA
|
||||||
import audio.funkwhale.ffa.R
|
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.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
|
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
|
@ -21,16 +30,21 @@ class QueueManager(val context: Context) {
|
||||||
var current = -1
|
var current = -1
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun factory(context: Context): CacheDataSourceFactory {
|
fun factory(context: Context): CacheDataSourceFactory {
|
||||||
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
|
val http = DefaultHttpDataSourceFactory(
|
||||||
defaultRequestProperties.apply {
|
Util.getUserAgent(context, context.getString(R.string.app_name))
|
||||||
if (!Settings.isAnonymous()) {
|
)
|
||||||
set("Authorization", "Bearer ${Settings.getAccessToken()}")
|
.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(
|
return CacheDataSourceFactory(
|
||||||
FFA.get().exoDownloadCache,
|
FFA.get().exoDownloadCache,
|
||||||
|
@ -53,7 +67,8 @@ class QueueManager(val context: Context) {
|
||||||
datasources.addMediaSources(metadata.map { track ->
|
datasources.addMediaSources(metadata.map { track ->
|
||||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
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.R
|
||||||
import audio.funkwhale.ffa.repositories.FavoritedRepository
|
import audio.funkwhale.ffa.repositories.FavoritedRepository
|
||||||
import audio.funkwhale.ffa.repositories.Repository
|
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.Fuel
|
||||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
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 body = Gson().toJson(request)
|
||||||
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
|
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
|
||||||
.authorize()
|
.authorize(context)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(body)
|
.body(body)
|
||||||
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
|
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
|
||||||
|
@ -107,7 +116,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
|
||||||
try {
|
try {
|
||||||
val body = Gson().toJson(RadioTrackBody(session))
|
val body = Gson().toJson(RadioTrackBody(session))
|
||||||
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
|
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
|
||||||
.authorize()
|
.authorize(context)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.apply {
|
.apply {
|
||||||
cookie?.let {
|
cookie?.let {
|
||||||
|
@ -118,7 +127,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
|
||||||
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
|
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
|
||||||
|
|
||||||
val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
|
val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
|
||||||
.authorize()
|
.authorize(context)
|
||||||
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
|
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
|
||||||
|
|
||||||
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)
|
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.Album
|
||||||
import audio.funkwhale.ffa.utils.AlbumsCache
|
import audio.funkwhale.ffa.utils.AlbumsCache
|
||||||
import audio.funkwhale.ffa.utils.AlbumsResponse
|
import audio.funkwhale.ffa.utils.AlbumsResponse
|
||||||
import audio.funkwhale.ffa.utils.OtterResponse
|
|
||||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import java.io.BufferedReader
|
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 {
|
override val cacheId: String by lazy {
|
||||||
if (artistId == null) "albums"
|
if (artistId == null) "albums"
|
||||||
else "albums-artist-$artistId"
|
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"
|
if (artistId == null) "/api/v1/albums/?playable=true&ordering=title"
|
||||||
else "/api/v1/albums/?playable=true&artist=$artistId&ordering=release_date"
|
else "/api/v1/albums/?playable=true&artist=$artistId&ordering=release_date"
|
||||||
|
|
||||||
HttpUpstream<Album, OtterResponse<Album>>(
|
HttpUpstream(
|
||||||
|
context!!,
|
||||||
HttpUpstream.Behavior.Progressive,
|
HttpUpstream.Behavior.Progressive,
|
||||||
url,
|
url,
|
||||||
object : TypeToken<AlbumsResponse>() {}.type
|
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 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 com.google.gson.reflect.TypeToken
|
||||||
import java.io.BufferedReader
|
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 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 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
|
import java.io.BufferedReader
|
||||||
|
|
||||||
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
|
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
|
||||||
|
|
||||||
override val cacheId = "artists"
|
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 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 android.content.Context
|
||||||
import audio.funkwhale.ffa.FFA
|
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.Fuel
|
||||||
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
||||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
|
@ -15,13 +27,21 @@ import kotlinx.coroutines.runBlocking
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
|
|
||||||
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
|
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
|
||||||
|
|
||||||
override val cacheId = "favorites.v2"
|
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 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 {
|
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||||
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
|
||||||
|
@ -41,50 +61,62 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addFavorite(id: Int) {
|
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 {
|
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
|
||||||
if (!Settings.isAnonymous()) {
|
if (!Settings.isAnonymous()) {
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
authorize(context)
|
||||||
|
header("Authorization", "Bearer ${OAuth.state().accessToken}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch(IO) {
|
scope.launch(IO) {
|
||||||
request
|
request
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(Gson().toJson(body))
|
.body(Gson().toJson(body))
|
||||||
.awaitByteArrayResponseResult()
|
.awaitByteArrayResponseResult()
|
||||||
|
|
||||||
favoritedRepository.update(context, scope)
|
favoritedRepository.update(context, scope)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteFavorite(id: Int) {
|
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 {
|
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
|
||||||
if (!Settings.isAnonymous()) {
|
if (!Settings.isAnonymous()) {
|
||||||
request.header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
authorize(context)
|
||||||
|
request.header("Authorization", "Bearer ${OAuth.state().accessToken}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch(IO) {
|
scope.launch(IO) {
|
||||||
request
|
request
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(Gson().toJson(body))
|
.body(Gson().toJson(body))
|
||||||
.awaitByteArrayResponseResult()
|
.awaitByteArrayResponseResult()
|
||||||
|
|
||||||
favoritedRepository.update(context, scope)
|
favoritedRepository.update(context, scope)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
|
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
|
||||||
override val cacheId = "favorited"
|
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 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) {
|
fun update(context: Context?, scope: CoroutineScope) {
|
||||||
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->
|
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package audio.funkwhale.ffa.repositories
|
package audio.funkwhale.ffa.repositories
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import audio.funkwhale.ffa.utils.*
|
import audio.funkwhale.ffa.utils.*
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import com.github.kittinunf.fuel.Fuel
|
||||||
|
@ -19,48 +20,57 @@ import java.lang.reflect.Type
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
class HttpUpstream<D : Any, R : OtterResponse<D>>(
|
class HttpUpstream<D : Any, R : OtterResponse<D>>(
|
||||||
|
val context: Context?,
|
||||||
val behavior: Behavior,
|
val behavior: Behavior,
|
||||||
private val url: String,
|
private val url: String,
|
||||||
private val type: Type
|
private val type: Type
|
||||||
) : Upstream<D> {
|
) : Upstream<D> {
|
||||||
|
|
||||||
enum class Behavior {
|
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>> {
|
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 =
|
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
|
||||||
Uri.parse(url)
|
|
||||||
.buildUpon()
|
|
||||||
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
|
|
||||||
.appendQueryParameter("page", page.toString())
|
|
||||||
.appendQueryParameter("scope", Settings.getScopes().joinToString(","))
|
|
||||||
.build()
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
get(url).fold(
|
val url =
|
||||||
{ response ->
|
Uri.parse(url)
|
||||||
val data = response.getData()
|
.buildUpon()
|
||||||
|
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
|
||||||
|
.appendQueryParameter("page", page.toString())
|
||||||
|
.appendQueryParameter("scope", Settings.getScopes().joinToString(" "))
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
|
||||||
when (behavior) {
|
get(it, url).fold(
|
||||||
Behavior.Single -> emit(networkResponse(data, page, false))
|
{ response ->
|
||||||
Behavior.Progressive -> emit(networkResponse(data, page, response.next != null))
|
val data = response.getData()
|
||||||
else -> {
|
|
||||||
emit(networkResponse(data, page, response.next != null))
|
when (behavior) {
|
||||||
if (response.next != null) fetch(size + data.size).collect { emit(it) }
|
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)
|
}.flowOn(IO)
|
||||||
|
|
||||||
private fun networkResponse(data: List<D>, page: Int, hasMore: Boolean) = Repository.Response(
|
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 {
|
return try {
|
||||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||||
if (!Settings.isAnonymous()) {
|
authorize(context)
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
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> {
|
private suspend fun retryGet(url: String): Result<R, FuelError> {
|
||||||
return try {
|
context?.let {
|
||||||
return if (HTTP.refresh()) {
|
return try {
|
||||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
return if (http.refresh()) {
|
||||||
if (!Settings.isAnonymous()) {
|
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
if (!Settings.isAnonymous()) {
|
||||||
|
header("Authorization", "Bearer ${OAuth.state().accessToken}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
request.awaitObjectResult(GenericDeserializer(type))
|
request.awaitObjectResult(GenericDeserializer(type))
|
||||||
} else {
|
} else {
|
||||||
Result.Failure(FuelError.wrap(RefreshError))
|
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 kotlinx.coroutines.runBlocking
|
||||||
import java.io.BufferedReader
|
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 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 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 {
|
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
|
||||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
|
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
package audio.funkwhale.ffa.repositories
|
package audio.funkwhale.ffa.repositories
|
||||||
|
|
||||||
import android.content.Context
|
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.Fuel
|
||||||
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
||||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
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)
|
data class PlaylistAdd(val tracks: List<Int>, val allow_duplicates: Boolean)
|
||||||
|
|
||||||
class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
|
class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
|
||||||
|
|
||||||
override val cacheId = "tracks-playlists"
|
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 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 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 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? {
|
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 {
|
val body = mapOf("name" to name, "privacy_level" to "me")
|
||||||
if (!Settings.isAnonymous()) {
|
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
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
|
||||||
}
|
}
|
||||||
|
throw IllegalStateException("Illegal state: context is null")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun add(id: Int, tracks: List<Track>) {
|
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 {
|
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/add/")).apply {
|
||||||
if (!Settings.isAnonymous()) {
|
if (!Settings.isAnonymous()) {
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
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")
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
request
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(Gson().toJson(body))
|
|
||||||
.awaitByteArrayResponseResult()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun remove(id: Int, track: Track, index: Int) {
|
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 {
|
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/remove/")).apply {
|
||||||
if (!Settings.isAnonymous()) {
|
if (!Settings.isAnonymous()) {
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
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
|
request
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(Gson().toJson(body))
|
.body(Gson().toJson(body))
|
||||||
.awaitByteArrayResponseResult()
|
.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
|
import java.io.BufferedReader
|
||||||
|
|
||||||
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
|
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
|
||||||
|
|
||||||
override val cacheId = "radios"
|
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 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> {
|
override fun onDataFetched(data: List<Radio>): List<Radio> {
|
||||||
return data
|
return data
|
||||||
|
|
|
@ -2,7 +2,16 @@ package audio.funkwhale.ffa.repositories
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import audio.funkwhale.ffa.FFA
|
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.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
@ -10,13 +19,22 @@ import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.io.BufferedReader
|
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 cacheId: String? = null
|
||||||
|
|
||||||
override val upstream: Upstream<Track>
|
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 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 {
|
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||||
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
|
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 cacheId: String? = null
|
||||||
override val upstream: Upstream<Artist>
|
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 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 cacheId: String? = null
|
||||||
override val upstream: Upstream<Album>
|
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 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 android.content.Context
|
||||||
import audio.funkwhale.ffa.FFA
|
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.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
import com.google.android.exoplayer2.offline.Download
|
import com.google.android.exoplayer2.offline.Download
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
|
@ -11,12 +16,21 @@ import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.io.BufferedReader
|
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 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 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 {
|
companion object {
|
||||||
fun getDownloadedIds(): List<Int>? {
|
fun getDownloadedIds(): List<Int>? {
|
||||||
|
|
|
@ -16,18 +16,23 @@ import java.security.MessageDigest
|
||||||
|
|
||||||
object RefreshError : Throwable()
|
object RefreshError : Throwable()
|
||||||
|
|
||||||
object HTTP {
|
class HTTP(val context: Context?) {
|
||||||
|
|
||||||
suspend fun refresh(): Boolean {
|
suspend fun refresh(): Boolean {
|
||||||
val body = mapOf(
|
val body = mapOf(
|
||||||
"username" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("username"),
|
"username" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
|
||||||
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("password")
|
.getString("username"),
|
||||||
|
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
|
||||||
|
.getString("password")
|
||||||
).toList()
|
).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(
|
return result.fold(
|
||||||
{ data ->
|
{ data ->
|
||||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("access_token", data.token)
|
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
|
||||||
|
.setString("access_token", data.token)
|
||||||
|
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
|
@ -36,33 +41,44 @@ object HTTP {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
|
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))
|
context?.let {
|
||||||
|
|
||||||
if (response.statusCode == 401) {
|
|
||||||
return retryGet(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend inline fun <reified T : Any> retryGet(url: String): Result<T, FuelError> {
|
|
||||||
return if (refresh()) {
|
|
||||||
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
val request = Fuel.get(mustNormalizeUrl(url)).apply {
|
||||||
if (!Settings.isAnonymous()) {
|
if (!Settings.isAnonymous()) {
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
authorize(it)
|
||||||
|
header("Authorization", "Bearer ${OAuth.state().accessToken}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
|
val (_, response, result) = request.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
|
||||||
} else {
|
|
||||||
Result.Failure(FuelError.wrap(RefreshError))
|
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
|
package audio.funkwhale.ffa.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import audio.funkwhale.ffa.R
|
import audio.funkwhale.ffa.R
|
||||||
|
@ -10,14 +11,21 @@ import com.google.android.exoplayer2.offline.Download
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.squareup.picasso.Picasso
|
import com.squareup.picasso.Picasso
|
||||||
import com.squareup.picasso.RequestCreator
|
import com.squareup.picasso.RequestCreator
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers.Main
|
import kotlinx.coroutines.Dispatchers.Main
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import net.openid.appauth.ClientSecretPost
|
||||||
import kotlin.coroutines.CoroutineContext
|
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) {
|
scope.launch(context) {
|
||||||
collect { data ->
|
collect { data ->
|
||||||
callback(data.data, data.origin == Repository.Origin.Cache, data.page, data.hasMore)
|
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)
|
else load(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Request.authorize(): Request {
|
fun Request.authorize(context: Context): Request {
|
||||||
return this.apply {
|
return runBlocking {
|
||||||
if (!Settings.isAnonymous()) {
|
this@authorize.apply {
|
||||||
header("Authorization", "Bearer ${Settings.getAccessToken()}")
|
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
|
package audio.funkwhale.ffa.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import com.github.kittinunf.fuel.Fuel
|
||||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||||
|
@ -7,11 +8,13 @@ import com.github.kittinunf.result.Result
|
||||||
import com.preference.PowerPreference
|
import com.preference.PowerPreference
|
||||||
|
|
||||||
object Userinfo {
|
object Userinfo {
|
||||||
suspend fun get(): User? {
|
|
||||||
|
suspend fun get(context: Context): User? {
|
||||||
try {
|
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/")
|
val (_, _, result) = Fuel.get("$hostname/api/v1/users/users/me/")
|
||||||
.authorize()
|
.authorize(context)
|
||||||
.awaitObjectResponseResult(gsonDeserializerOf(User::class.java))
|
.awaitObjectResponseResult(gsonDeserializerOf(User::class.java))
|
||||||
|
|
||||||
return when (result) {
|
return when (result) {
|
||||||
|
|
|
@ -33,8 +33,8 @@ private fun logClassName(): String {
|
||||||
|
|
||||||
fun Any?.log(prefix: String? = null) {
|
fun Any?.log(prefix: String? = null) {
|
||||||
prefix?.let {
|
prefix?.let {
|
||||||
Log.d("OTTER", "${logClassName()} - $prefix: $this")
|
Log.d("FFA", "${logClassName()} - $prefix: $this")
|
||||||
} ?: Log.d("OTTER", "${logClassName()} - $this")
|
} ?: Log.d("FFA", "${logClassName()} - $this")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun maybeNormalizeUrl(rawUrl: String?): String? {
|
fun maybeNormalizeUrl(rawUrl: String?): String? {
|
||||||
|
@ -48,7 +48,8 @@ fun maybeNormalizeUrl(rawUrl: String?): String? {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mustNormalizeUrl(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")
|
val uri = URI(rawUrl).takeIf { it.host != null } ?: URI("$fallbackHost$rawUrl")
|
||||||
|
|
||||||
return uri.toString()
|
return uri.toString()
|
||||||
|
@ -71,9 +72,14 @@ fun toDurationString(duration: Long, showSeconds: Boolean = false): String {
|
||||||
}
|
}
|
||||||
|
|
||||||
object Settings {
|
object Settings {
|
||||||
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
|
|
||||||
fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
|
fun hasAccessToken(): Boolean {
|
||||||
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
|
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 areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false)
|
||||||
|
|
||||||
fun getScopes() = PowerPreference.getDefaultFile().getString("scope", "all").split(",")
|
fun getScopes() = PowerPreference.getDefaultFile().getString("scope", "all").split(",")
|
||||||
|
|
|
@ -18,15 +18,14 @@
|
||||||
android:layout_height="128dp"
|
android:layout_height="128dp"
|
||||||
android:layout_marginBottom="32dp"
|
android:layout_marginBottom="32dp"
|
||||||
android:contentDescription="@string/alt_app_logo"
|
android:contentDescription="@string/alt_app_logo"
|
||||||
android:src="@drawable/funkwhale"/>
|
android:src="@drawable/funkwhale" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="32dp"
|
android:layout_marginBottom="32dp"
|
||||||
android:text="@string/login_welcome"
|
android:text="@string/login_welcome"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center" />
|
||||||
android:textColor="@color/colorPrimary" />
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/hostname_field"
|
android:id="@+id/hostname_field"
|
||||||
|
@ -34,7 +33,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:textColorHint="@drawable/login_input"
|
android:textColorHint="@drawable/login_input"
|
||||||
app:boxBackgroundColor="@color/elevatedSurface"
|
app:boxBackgroundColor="@color/controlAccent"
|
||||||
app:boxBackgroundMode="filled"
|
app:boxBackgroundMode="filled"
|
||||||
app:boxStrokeWidth="0dp"
|
app:boxStrokeWidth="0dp"
|
||||||
app:hintTextColor="@drawable/login_input">
|
app:hintTextColor="@drawable/login_input">
|
||||||
|
@ -47,7 +46,8 @@
|
||||||
android:inputType="textUri"
|
android:inputType="textUri"
|
||||||
android:lines="1"
|
android:lines="1"
|
||||||
android:text="@string/debug.hostname"
|
android:text="@string/debug.hostname"
|
||||||
android:textColor="@color/controlColorText"
|
android:textColor="@android:color/white"
|
||||||
|
android:background="@color/elevatedSurface"
|
||||||
android:textCursorDrawable="@null" />
|
android:textCursorDrawable="@null" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
@ -56,68 +56,13 @@
|
||||||
android:id="@+id/cleartext"
|
android:id="@+id/cleartext"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:buttonTint="@color/controlColorText"
|
android:text="@string/login_cleartext" />
|
||||||
android:text="@string/login_cleartext"
|
|
||||||
android:textColor="@color/controlColorText" />
|
|
||||||
|
|
||||||
<com.google.android.material.checkbox.MaterialCheckBox
|
<com.google.android.material.checkbox.MaterialCheckBox
|
||||||
android:id="@+id/anonymous"
|
android:id="@+id/anonymous"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:buttonTint="@color/controlColorText"
|
android:text="@string/login_anonymous" />
|
||||||
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>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/login"
|
android:id="@+id/login"
|
||||||
|
|
Loading…
Reference in New Issue