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

#48: Implement OAuth2 authentication

Closes #48

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

View File

@ -51,6 +51,8 @@ android {
versionCode = androidGitVersion.code() 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")
} }

View File

@ -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>

View File

@ -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)

View File

@ -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
}
} }
} }

View File

@ -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()

View File

@ -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()
} }
} }

View File

@ -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)
} }
} }
}
} }
} }

View File

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

View File

@ -4,7 +4,16 @@ import android.content.Context
import android.net.Uri import 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))
}) })
} }
} }

View File

@ -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)

View File

@ -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)
} }

View File

@ -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)
} }

View File

@ -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)
} }

View File

@ -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, _, _, _ ->

View File

@ -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")
} }
} }

View File

@ -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)

View File

@ -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")
} }
} }

View File

@ -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

View File

@ -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)
} }

View File

@ -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>? {

View File

@ -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")
} }
} }

View File

@ -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)

View File

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

View File

@ -1,5 +1,6 @@
package audio.funkwhale.ffa.utils 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) {

View File

@ -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(",")

View File

@ -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"