Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • bugfix/46-adjust-now-playing-top-margin
  • deploy-in-docker
  • develop
  • enhancement/speed-up-pipelines
  • fix-ci-for-tags
  • gradle-v8
  • housekeeping/mavenCentral
  • housekeeping/remove-warnings
  • renovate/io.mockk-mockk-1.x
  • technical/skip-metadata-file-for-branches
  • technical/update-compile-sdk-version-docker
  • technical/update-jvmTarget-version
  • technical/upgrade-appcompat-1.5.x
  • technical/upgrade-exoplayer
  • 0.0.1
  • 0.1
  • 0.1.1
  • 0.1.2
  • 0.1.3
  • 0.1.4
  • 0.1.5
  • 0.1.5-1
  • 0.2.0
  • 0.2.1
  • 0.2.1-1
  • 0.3.0
  • fdroid-dummy-1
  • fdroid-dummy-2
  • fdroid-dummy-3
  • fdroid-dummy-4
  • fdroid-dummy-5
31 results

Target

Select target project
  • funkwhale/funkwhale-android
  • creak/funkwhale-android
  • Keunes/funkwhale-android
  • Mouath/funkwhale-android
4 results
Select Git revision
  • bugfix/46-adjust-now-playing-top-margin
  • bugfix/90-error-playing-user-radio
  • deploy-in-docker
  • develop
  • enhancement/89-update-appstore-metadata
  • enhancement/speed-up-pipelines
  • housekeeping/remove-warnings
  • renovate/configure
  • 0.0.1
  • 0.1
  • 0.1.1
11 results
Show changes
Showing
with 934 additions and 372 deletions
package audio.funkwhale.ffa.repositories package audio.funkwhale.ffa.repositories
import android.content.Context import android.content.Context
import audio.funkwhale.ffa.model.* import audio.funkwhale.ffa.model.FFAResponse
import audio.funkwhale.ffa.model.Playlist
import audio.funkwhale.ffa.model.PlaylistsCache
import audio.funkwhale.ffa.model.PlaylistsResponse
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.OAuth import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.Settings import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.authorize import audio.funkwhale.ffa.utils.authorize
...@@ -15,7 +19,6 @@ import com.google.gson.reflect.TypeToken ...@@ -15,7 +19,6 @@ import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
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)
...@@ -34,8 +37,8 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist, ...@@ -34,8 +37,8 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist,
) )
override fun cache(data: List<Playlist>) = PlaylistsCache(data) override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader) gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
} }
class ManagementPlaylistsRepository(override val context: Context?) : class ManagementPlaylistsRepository(override val context: Context?) :
...@@ -54,8 +57,8 @@ class ManagementPlaylistsRepository(override val context: Context?) : ...@@ -54,8 +57,8 @@ class ManagementPlaylistsRepository(override val context: Context?) :
) )
override fun cache(data: List<Playlist>) = PlaylistsCache(data) override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = override fun uncache(json: String) =
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader) gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
suspend fun new(name: String): Int? { suspend fun new(name: String): Int? {
context?.let { context?.let {
...@@ -104,7 +107,7 @@ class ManagementPlaylistsRepository(override val context: Context?) : ...@@ -104,7 +107,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
} }
suspend fun remove(albumId: Int, index: Int) { suspend fun remove(albumId: Int, index: Int) {
context?.let { if (context != null) {
val body = mapOf("index" to index) val body = mapOf("index" to index)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$albumId/remove/")).apply { val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$albumId/remove/")).apply {
...@@ -118,12 +121,13 @@ class ManagementPlaylistsRepository(override val context: Context?) : ...@@ -118,12 +121,13 @@ class ManagementPlaylistsRepository(override val context: Context?) :
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(Gson().toJson(body)) .body(Gson().toJson(body))
.awaitByteArrayResponseResult() .awaitByteArrayResponseResult()
} } else {
throw IllegalStateException("Illegal state: context is null") throw IllegalStateException("Illegal state: context is null")
} }
}
fun move(id: Int, from: Int, to: Int) { fun move(id: Int, from: Int, to: Int) {
context?.let { if (context != null) {
val body = mapOf("from" to from, "to" to to) val body = mapOf("from" to from, "to" to to)
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply { val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply {
...@@ -139,7 +143,8 @@ class ManagementPlaylistsRepository(override val context: Context?) : ...@@ -139,7 +143,8 @@ class ManagementPlaylistsRepository(override val context: Context?) :
.body(Gson().toJson(body)) .body(Gson().toJson(body))
.awaitByteArrayResponseResult() .awaitByteArrayResponseResult()
} }
} } else {
throw IllegalStateException("Illegal state: context is null") throw IllegalStateException("Illegal state: context is null")
} }
} }
}
...@@ -9,7 +9,6 @@ import audio.funkwhale.ffa.utils.OAuth ...@@ -9,7 +9,6 @@ import audio.funkwhale.ffa.utils.OAuth
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 org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() { class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
...@@ -26,8 +25,8 @@ class RadiosRepository(override val context: Context?) : Repository<Radio, Radio ...@@ -26,8 +25,8 @@ class RadiosRepository(override val context: Context?) : Repository<Radio, Radio
) )
override fun cache(data: List<Radio>) = RadiosCache(data) override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(reader: BufferedReader) = override fun uncache(json: String) =
gsonDeserializerOf(RadiosCache::class.java).deserialize(reader) gsonDeserializerOf(RadiosCache::class.java).deserialize(json.reader())
override fun onDataFetched(data: List<Radio>): List<Radio> { override fun onDataFetched(data: List<Radio>): List<Radio> {
return data return data
......
package audio.funkwhale.ffa.repositories package audio.funkwhale.ffa.repositories
import android.content.Context import android.content.Context
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.model.CacheItem import audio.funkwhale.ffa.model.CacheItem
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.FFACache import audio.funkwhale.ffa.utils.FFACache
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow
import java.io.BufferedReader import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlin.math.ceil import kotlin.math.ceil
interface Upstream<D> { interface Upstream<D> {
...@@ -30,7 +32,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> { ...@@ -30,7 +32,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
abstract val upstream: Upstream<D> abstract val upstream: Upstream<D>
open fun cache(data: List<D>): C? = null open fun cache(data: List<D>): C? = null
protected open fun uncache(reader: BufferedReader): C? = null protected open fun uncache(json: String): C? = null
fun fetch( fun fetch(
upstreams: Int = Origin.Cache.origin and Origin.Network.origin, upstreams: Int = Origin.Cache.origin and Origin.Network.origin,
...@@ -42,8 +44,8 @@ abstract class Repository<D : Any, C : CacheItem<D>> { ...@@ -42,8 +44,8 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
private fun fromCache() = flow { private fun fromCache() = flow {
cacheId?.let { cacheId -> cacheId?.let { cacheId ->
FFACache.get(context, cacheId)?.let { reader -> FFACache.getLine(context, cacheId)?.let { line ->
uncache(reader)?.let { cache -> uncache(line)?.let { cache ->
return@flow emit( return@flow emit(
Response( Response(
Origin.Cache, Origin.Cache,
...@@ -59,7 +61,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> { ...@@ -59,7 +61,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
} }
}.flowOn(IO) }.flowOn(IO)
private fun fromNetwork(size: Int) = flow { private fun fromNetwork(size: Int): Flow<Response<D>> = flow {
upstream upstream
.fetch(size) .fetch(size)
.map { response -> .map { response ->
......
package audio.funkwhale.ffa.repositories package audio.funkwhale.ffa.repositories
import android.content.Context import android.content.Context
import audio.funkwhale.ffa.model.* import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.utils.* import audio.funkwhale.ffa.model.AlbumsCache
import audio.funkwhale.ffa.model.AlbumsResponse
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.model.ArtistsCache
import audio.funkwhale.ffa.model.ArtistsResponse
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.model.TracksCache
import audio.funkwhale.ffa.model.TracksResponse
import audio.funkwhale.ffa.utils.OAuth
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.DownloadManager import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.upstream.cache.Cache import com.google.android.exoplayer2.upstream.cache.Cache
...@@ -12,7 +21,6 @@ import kotlinx.coroutines.flow.toList ...@@ -12,7 +21,6 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class TracksSearchRepository(override val context: Context?, var query: String) : class TracksSearchRepository(override val context: Context?, var query: String) :
Repository<Track, TracksCache>() { Repository<Track, TracksCache>() {
...@@ -33,8 +41,8 @@ class TracksSearchRepository(override val context: Context?, var query: String) ...@@ -33,8 +41,8 @@ class TracksSearchRepository(override val context: Context?, var query: String)
) )
override fun cache(data: List<Track>) = TracksCache(data) override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader) gsonDeserializerOf(TracksCache::class.java).deserialize(json.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)
...@@ -75,8 +83,8 @@ class ArtistsSearchRepository(override val context: Context?, var query: String) ...@@ -75,8 +83,8 @@ class ArtistsSearchRepository(override val context: Context?, var query: String)
) )
override fun cache(data: List<Artist>) = ArtistsCache(data) override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = override fun uncache(json: String) =
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader) gsonDeserializerOf(ArtistsCache::class.java).deserialize(json.reader())
} }
class AlbumsSearchRepository(override val context: Context?, var query: String) : class AlbumsSearchRepository(override val context: Context?, var query: String) :
...@@ -95,6 +103,6 @@ class AlbumsSearchRepository(override val context: Context?, var query: String) ...@@ -95,6 +103,6 @@ class AlbumsSearchRepository(override val context: Context?, var query: String)
) )
override fun cache(data: List<Album>) = AlbumsCache(data) override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) = override fun uncache(json: String) =
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader) gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
} }
...@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.toList ...@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import java.io.BufferedReader
class TracksRepository(override val context: Context?, albumId: Int) : class TracksRepository(override val context: Context?, albumId: Int) :
Repository<Track, TracksCache>() { Repository<Track, TracksCache>() {
...@@ -38,24 +37,23 @@ class TracksRepository(override val context: Context?, albumId: Int) : ...@@ -38,24 +37,23 @@ class TracksRepository(override val context: Context?, albumId: Int) :
) )
override fun cache(data: List<Track>) = TracksCache(data) override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = override fun uncache(json: String) =
gsonDeserializerOf(TracksCache::class.java).deserialize(reader) gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
companion object { companion object {
fun getDownloadedIds(exoDownloadManager: DownloadManager): List<Int>? { fun getDownloadedIds(exoDownloadManager: DownloadManager): List<Int>? {
val cursor = exoDownloadManager.downloadIndex.getDownloads()
val ids: MutableList<Int> = mutableListOf() val ids: MutableList<Int> = mutableListOf()
exoDownloadManager.downloadIndex.getDownloads()
.use { cursor ->
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val download = cursor.download val download = cursor.download
download.getMetadata()?.let { download.getMetadata()?.let {
if (download.state == Download.STATE_COMPLETED) { if (download.state == Download.STATE_COMPLETED) {
ids.add(it.id) ids.add(it.id)
} }
} }
} }
}
return ids return ids
} }
} }
......
package audio.funkwhale.ffa.utils package audio.funkwhale.ffa.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
...@@ -23,7 +22,7 @@ object AppContext { ...@@ -23,7 +22,7 @@ object AppContext {
const val PAGE_SIZE = 50 const val PAGE_SIZE = 50
const val TRANSITION_DURATION = 300L const val TRANSITION_DURATION = 300L
fun init(context: Activity) { fun init(context: Context) {
setupNotificationChannels(context) setupNotificationChannels(context)
// CastContext.getSharedInstance(context) // CastContext.getSharedInstance(context)
......
package audio.funkwhale.ffa.utils
import java.lang.ref.WeakReference
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
/**
* Similar to a Map, but with the semantic that operations single-thread on a per-key basis.
* That is: given concurrent accesses to keys "apple" and "banana", one "apple" thread
* will block all other "apple" threads, but not any "banana" threads.
* In practical terms, we use this to make sure we don't get weird edge cases when working
* with the filesystem cache.
*/
class Bottleneck<T> {
// It would be nice to use LruCache here, but its behavior of
// replacing values doesn't get us the right results.
// As it is, this should be a trivial amount of memory compared to
// images and media.
// We single-thread this, so it doesn't need to be concurrent.
private val keys = WeakHashMap<String, String>()
// This one needs to be concurrent, as we don't want to single-thread it.
private val values = ConcurrentHashMap<String, WeakReference<T>>()
/**
* As you would expect from the Map function of the same name, except concurrent
* accesses to the same key will block on each other. If the first call succeeds,
* all other calls will fall through with the same result. (Unlike LRUCache.)
*/
fun getOrCompute(key: String, materialize: (key: String) -> T?): T? {
// First, get the lockable version of the key, no matter how
// many copies of the key exist.
// This map doesn't need to be a synchronized collection, because
// we single-thread access to it. (And there's no compute, so
// it should be low-contention.)
val sharedKey: String = canonical(key)
synchronized(sharedKey) {
val ref = values[sharedKey]
var value = ref?.get()
if (value == null) {
if (ref != null) {
values.remove(sharedKey) // empty ref
}
value = materialize(sharedKey)
if (value != null) {
values[sharedKey] = WeakReference(value)
}
}
return value
}
}
/**
* The beating heart of this system: each key is is "upgraded" to
* the one which we use for locking. This does mean we block on
* access to `keys` for all concurrent access, but as it's so light-
* weight, this shouldn't be much of a problem in practical terms.
* The hope here is that this is slightly better than interning.
* In theory we could convert this over to also use WeakReference.
*/
private fun canonical(key: String): String {
val sharedKey: String
synchronized(keys) {
val maybeShared = keys[key]
if (maybeShared == null) {
keys[key] = key // first key of its value becomes canonical
sharedKey = key
} else {
sharedKey = maybeShared
}
}
return sharedKey
}
/**
* Invalidate a key and run the supplied bi-consumer with the old value.
* Note that this will <em>always</em> run the supplied block, even if
* the value is not in the cache.
*/
fun remove(key: String, andDo: ((T?, String) -> Unit)?) {
val sharedKey = canonical(key)
synchronized(sharedKey) {
val oldValue = values.remove(sharedKey)
if (andDo != null) {
andDo(oldValue?.get(), sharedKey)
}
}
}
}
package audio.funkwhale.ffa.utils
import androidx.customview.widget.Openable
interface BottomSheetIneractable: Openable {
val isHidden: Boolean
fun show()
fun hide()
fun toggle()
}
\ No newline at end of file
package audio.funkwhale.ffa.utils package audio.funkwhale.ffa.utils
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.model.Radio import audio.funkwhale.ffa.model.Radio
import audio.funkwhale.ffa.model.Track import audio.funkwhale.ffa.model.Track
import com.google.android.exoplayer2.offline.Download import com.google.android.exoplayer2.offline.Download
...@@ -8,8 +7,10 @@ import com.google.android.exoplayer2.offline.DownloadCursor ...@@ -8,8 +7,10 @@ import com.google.android.exoplayer2.offline.DownloadCursor
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed class Command { sealed class Command {
...@@ -60,6 +61,7 @@ sealed class Request(var channel: Channel<Response>? = null) { ...@@ -60,6 +61,7 @@ sealed class Request(var channel: Channel<Response>? = null) {
object GetState : Request() object GetState : Request()
object GetQueue : Request() object GetQueue : Request()
object GetCurrentTrack : Request() object GetCurrentTrack : Request()
object GetCurrentTrackIndex : Request()
object GetDownloads : Request() object GetDownloads : Request()
} }
...@@ -67,51 +69,59 @@ sealed class Response { ...@@ -67,51 +69,59 @@ sealed class Response {
class State(val playing: Boolean) : Response() class State(val playing: Boolean) : Response()
class Queue(val queue: List<Track>) : Response() class Queue(val queue: List<Track>) : Response()
class CurrentTrack(val track: Track?) : Response() class CurrentTrack(val track: Track?) : Response()
class CurrentTrackIndex(val index: Int) : Response()
class Downloads(val cursor: DownloadCursor) : Response() class Downloads(val cursor: DownloadCursor) : Response()
} }
object EventBus { object EventBus {
private var _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
fun send(event: Event) { fun send(event: Event) {
GlobalScope.launch(IO) { GlobalScope.launch(IO) {
FFA.get().eventBus.trySend(event).isSuccess _events.emit(event)
} }
} }
fun get() = FFA.get().eventBus.asFlow() fun get() = events
} }
object CommandBus { object CommandBus {
private var _commands = MutableSharedFlow<Command>()
var commands = _commands.asSharedFlow()
fun send(command: Command) { fun send(command: Command) {
GlobalScope.launch(IO) { GlobalScope.launch(IO) {
FFA.get().commandBus.trySend(command).isSuccess _commands.emit(command)
} }
} }
fun get() = FFA.get().commandBus.asFlow() fun get() = commands
} }
object RequestBus { object RequestBus {
// `replay` allows send requests before the PlayerService starts listening
private var _requests = MutableSharedFlow<Request>(replay = 100)
var requests = _requests.asSharedFlow()
fun send(request: Request): Channel<Response> { fun send(request: Request): Channel<Response> {
return Channel<Response>().also { return Channel<Response>().also {
GlobalScope.launch(IO) { GlobalScope.launch(IO) {
request.channel = it request.channel = it
FFA.get().requestBus.trySend(request).isSuccess _requests.emit(request)
} }
} }
} }
fun get() = FFA.get().requestBus.asFlow() fun get() = requests
} }
object ProgressBus { object ProgressBus {
private var _progress = MutableStateFlow(Triple(0, 0, 0))
val progress = _progress.asStateFlow()
fun send(current: Int, duration: Int, percent: Int) { fun send(current: Int, duration: Int, percent: Int) {
GlobalScope.launch(IO) { _progress.value = Triple(current, duration, percent)
FFA.get().progressBus.send(Triple(current, duration, percent))
}
} }
fun get() = FFA.get().progressBus.asFlow().conflate() fun get() = progress
} }
suspend inline fun <reified T> Channel<Response>.wait(): T? { suspend inline fun <reified T> Channel<Response>.wait(): T? {
......
package audio.funkwhale.ffa.utils
import android.content.Context
import android.net.Uri
import android.transition.CircularPropagation
import android.util.Log
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import audio.funkwhale.ffa.BuildConfig
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R
import com.squareup.picasso.Downloader
import com.squareup.picasso.NetworkPolicy
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import com.squareup.picasso.Picasso.LoadedFrom
import com.squareup.picasso.Request
import com.squareup.picasso.RequestCreator
import com.squareup.picasso.RequestHandler
import okhttp3.CacheControl
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okio.Okio
import java.io.File
import java.security.MessageDigest
/**
* Represent bytes as hex values.
*/
fun ByteArray.toHex(): String = joinToString("") { b -> "%02x".format(b) }
/**
* Convert the string to its SHA-256 hash in hex format.
*/
fun String.sha256(): String =
let { MessageDigest.getInstance("SHA-256").digest(it.encodeToByteArray()).toHex() }
/**
* Remove the query string and fragment from a URI.
* Mostly, this is to get rid of pre-signed URL silliness.
* If we ever need to keep some query params, we'll need a more robust approach.
*/
fun Uri.asStableKey(): String = buildUpon().clearQuery().fragment("").build().toString()
/**
* Try to extract a file suffix from the URI. This isn't strictly
* necessary, but it can make debugging easier when you're going through
* the app cache with a filesystem browser.
*/
fun Uri.fileSuffix(): String = let {
val p = it.path
val ext = p?.substringAfterLast(".", "")?.lowercase() ?: ""
if (ext == "") ext else ".$ext"
}
/**
* Wrapper around Picasso with some smarter caching of image files.
*/
open class CoverArt private constructor() {
companion object {
// For logging
val TAG: String = CoverArt::class.java.simpleName
// This is just a nice-to-have for API admins
private const val userAgent =
"${BuildConfig.APPLICATION_ID} ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
// This client has the UA above, and has caching intentionally disabled.
// (Because we cache the images ourselves and cannot rely on replaying requests.)
private var httpClient: OkHttpClient? = null
// Same: this has caching disabled.
private var downloader: OkHttp3Downloader? = null
// Cache with some useful concurrency semantics. See its docs for details.
val fileCache = Bottleneck<File>()
private val picasso = with (FFA.get()) {
Picasso.Builder(this)
.addRequestHandler(CoverNetworkRequestHandler(this))
// Be careful with this. There's at least one place in Picasso where it
// doesn't null-check when logging, so it'll throw errors in places you
// wouldn't get them with logging turned off. /sigh
.loggingEnabled(false) // (BuildConfig.DEBUG)
// Occasionally, we may get transient HTTP issues, or bogus files.
// Listen for Picasso errors and invalidate those files
.listener(invalidateIn(this))
.build()
}
/**
* We don't need to hang onto the Context, just the Path it gets us.
*/
fun cacheDirForContext(context: Context): File {
return context.applicationContext.cacheDir.resolve("covers")
}
/**
* Shim for Picasso which acts like a NetworkRequestHandler, but is opinionated
* about how we want to use it.
*/
open class CoverNetworkRequestHandler(context: Context) : RequestHandler() {
/**
* Path to the actual cache directory.
*/
val coverCacheDir: File
/**
* This goes out with every request and never changes.
*/
val noCacheControl: CacheControl = CacheControl.Builder()
.noCache()
.noStore()
.noTransform()
.build()
init {
coverCacheDir = cacheDirForContext(context)
// Make the cache directory if it doesn't already exist.
if (!coverCacheDir.isDirectory) {
coverCacheDir.mkdir()
}
}
/**
* The primary logic of going from a Request to a usable File.
* tl;dr: Use a local file if you can, otherwise download it and use that.
*/
private fun materializeFile(request: Request): (String) -> File? {
return fun(fileName: String): File? {
val existing = coverCacheDir.resolve(fileName)
if (existing.isFile) {
return existing
}
val key = request.stableKey ?: request.uri.asStableKey()
val httpUrl = HttpUrl.parse(request.uri.toString()) ?: return null
return fetchToFile(httpUrl, fileName, key)
}
}
/**
* Required by Picasso, we only want to handle HTTP traffic.
*/
override fun canHandleRequest(data: Request?): Boolean {
return data != null && ("http" == data.uri.scheme || "https" == data.uri.scheme)
}
/**
* Required by Picasso, this is the main entrypoint.
*/
override fun load(request: Request?, networkPolicy: Int): Result? {
if (request == null || !NetworkPolicy.shouldReadFromDiskCache(networkPolicy)) {
return null
}
// Ditch any query params.
val key = request.stableKey ?: request.uri.asStableKey()
// Convert to a short, stable filename.
val fileName =
key.sha256() + request.uri.fileSuffix() // file extension for easier forensics
// Actually find or fetch the file.
val file = fileCache.getOrCompute(fileName, materializeFile(request))
// Hand it back to Picasso in a way it can understand.
return if (file == null) null else Result(Okio.source(file), LoadedFrom.DISK)
}
/**
* The actual fetch logic is straightforward: download to a file.
* Sadly, this is more manual than you might expect.
*/
private fun fetchToFile(httpUrl: HttpUrl, fileName: String, cacheKey: String): File? {
val httpRequest = okhttp3.Request.Builder()
.get()
.url(httpUrl)
.cacheControl(noCacheControl)
.build()
val response = nonCachingDownloader().load(httpRequest)
if (!response.isSuccessful) {
return null
}
val body = response.body() ?: return null
val file = coverCacheDir.resolve(fileName)
if (BuildConfig.DEBUG) {
Log.d(TAG, "fetchToFile($cacheKey) <- $fileName <- NETWORK")
}
val bytesWritten: Long
body.use { b ->
Okio.buffer(Okio.sink(file)).use { sink ->
bytesWritten = sink.writeAll(b.source())
}
}
return if (bytesWritten > 0) file else null
}
}
/**
* Picasso can send back notification that files are busted.
* In those cases, it could be a transient problem, or credentials, etc.
* We probably don't want to trust the file, so we invalidate it
* from the memory cache and delete it from the filesystem.
* This uses Bottleneck, so it's thread-safe.
*/
fun invalidateIn(context: Context): (Picasso, Uri, Exception) -> Unit {
val coverCacheDir = cacheDirForContext(context)
return fun(_, uri: Uri, _) {
val key = uri.asStableKey()
val fileName = key.sha256() + uri.fileSuffix()
fileCache.remove(fileName) { f, _ ->
val file = f ?: coverCacheDir.resolve(fileName)
if (file.isFile) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Deleting failed cover: $file")
}
file.delete()
}
}
}
}
/**
* Low-level Picasso wiring.
*/
/**
* We don't want to cache the HTTP part of the flow, because:
* 1. It's double-caching, since we're saving the images already.
* 2. The URL may include pre-signed credentials, which expire, making the URL useless.
*/
protected fun nonCachingDownloader(): Downloader {
val downloader = this.downloader ?: OkHttp3Downloader(nonCachingHttpClient())
if (this.downloader == null) {
this.downloader = downloader
}
return downloader
}
/**
* Same here: build a non-caching version just for cover art.
*/
protected fun nonCachingHttpClient(): OkHttpClient {
val hc = httpClient ?: OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request()
.newBuilder()
.addHeader("User-Agent", userAgent)
.build()
)
}
.cache(null) // No cache here, intentionally
.build()
if (httpClient == null) {
httpClient = hc
}
return hc
}
/**
* The primary entrypoint for the codebase.
*/
fun requestCreator(url: String?): RequestCreator {
val request = picasso.load(url)
if(url == null) request.placeholder(R.drawable.cover)
else request.placeholder(CircularProgressDrawable(FFA.get()))
return request.error(R.drawable.cover)
}
}
}
...@@ -3,27 +3,23 @@ package audio.funkwhale.ffa.utils ...@@ -3,27 +3,23 @@ package audio.funkwhale.ffa.utils
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData
import audio.funkwhale.ffa.R import androidx.lifecycle.MediatorLiveData
import audio.funkwhale.ffa.fragments.BrowseFragment
import audio.funkwhale.ffa.model.DownloadInfo import audio.funkwhale.ffa.model.DownloadInfo
import audio.funkwhale.ffa.repositories.Repository import audio.funkwhale.ffa.repositories.Repository
import com.github.kittinunf.fuel.core.FuelError import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.Request import com.github.kittinunf.fuel.core.Request
import com.google.android.exoplayer2.offline.Download 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.RequestCreator
import kotlinx.coroutines.CompletableDeferred 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.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.openid.appauth.ClientSecretPost import net.openid.appauth.ClientSecretPost
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
inline fun <D> Flow<Repository.Response<D>>.untilNetwork( inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
...@@ -38,14 +34,6 @@ inline fun <D> Flow<Repository.Response<D>>.untilNetwork( ...@@ -38,14 +34,6 @@ inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
} }
} }
fun Fragment.onViewPager(block: Fragment.() -> Unit) {
for (f in activity?.supportFragmentManager?.fragments ?: listOf()) {
if (f is BrowseFragment) {
f.block()
}
}
}
fun <T> Int.onApi(block: () -> T) { fun <T> Int.onApi(block: () -> T) {
if (Build.VERSION.SDK_INT >= this) { if (Build.VERSION.SDK_INT >= this) {
block() block()
...@@ -60,26 +48,23 @@ fun <T, U> Int.onApi(block: () -> T, elseBlock: (() -> U)) { ...@@ -60,26 +48,23 @@ fun <T, U> Int.onApi(block: () -> T, elseBlock: (() -> U)) {
} }
} }
fun Picasso.maybeLoad(url: String?): RequestCreator {
return if (url == null) load(R.drawable.cover)
else load(url)
}
fun Request.authorize(context: Context, oAuth: OAuth): Request { fun Request.authorize(context: Context, oAuth: OAuth): Request {
return runBlocking { return runBlocking {
this@authorize.apply { this@authorize.apply {
if (!Settings.isAnonymous()) { if (!Settings.isAnonymous()) {
oAuth.state().let { state -> oAuth.state().let { state ->
state.accessTokenExpirationTime?.let {
Log.i("Request.authorize()", "Accesstoken expiration: ${Date(it).format()}")
}
val old = state.accessToken val old = state.accessToken
val auth = ClientSecretPost(oAuth.state().clientSecret) val auth = ClientSecretPost(oAuth.state().clientSecret)
val done = CompletableDeferred<Boolean>() val done = CompletableDeferred<Boolean>()
val tokenService = oAuth.service(context)
state.performActionWithFreshTokens(oAuth.service(context), auth) { token, _, _ -> state.performActionWithFreshTokens(tokenService, auth) { token, _, e ->
if (token == old) { if (e != null) {
Log.i("Request.authorize()", "Accesstoken not renewed") Log.e("Request.authorize()", "performActionWithFreshToken failed: $e")
if (e.type != 2 || e.code != 2002) {
Log.e("Request.authorize()", Log.getStackTraceString(e))
EventBus.send(Event.LogOut)
}
} }
if (token != old && token != null) { if (token != old && token != null) {
state.save() state.save()
...@@ -88,6 +73,7 @@ fun Request.authorize(context: Context, oAuth: OAuth): Request { ...@@ -88,6 +73,7 @@ fun Request.authorize(context: Context, oAuth: OAuth): Request {
done.complete(true) done.complete(true)
} }
done.await() done.await()
tokenService.dispose()
return@runBlocking this return@runBlocking this
} }
} }
...@@ -107,3 +93,58 @@ val ISO_8601_DATE_TIME_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") ...@@ -107,3 +93,58 @@ val ISO_8601_DATE_TIME_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
fun Date.format(): String { fun Date.format(): String {
return ISO_8601_DATE_TIME_FORMAT.format(this) return ISO_8601_DATE_TIME_FORMAT.format(this)
} }
fun String?.containsIgnoringCase(candidate: String): Boolean =
this != null && this.lowercase().contains(candidate.lowercase())
inline fun <T, U, V, R> LiveData<T>.mergeWith(
u: LiveData<U>,
v: LiveData<V>,
crossinline block: (valT: T, valU: U, valV: V) -> R
): LiveData<R> = MediatorLiveData<R>().apply {
addSource(this@mergeWith) {
if (u.value != null && v.value != null) {
postValue(block(it, u.value!!, v.value!!))
}
}
addSource(u) {
if (this@mergeWith.value != null && u.value != null) {
postValue(block(this@mergeWith.value!!, it, v.value!!))
}
}
addSource(v) {
if (this@mergeWith.value != null && u.value != null) {
postValue(block(this@mergeWith.value!!, u.value!!, it))
}
}
}
inline fun <T, U, V, W, R> LiveData<T>.mergeWith(
u: LiveData<U>,
v: LiveData<V>,
w: LiveData<W>,
crossinline block: (valT: T, valU: U, valV: V, valW: W) -> R
): LiveData<R> = MediatorLiveData<R>().apply {
addSource(this@mergeWith) {
if (u.value != null && v.value != null && w.value != null) {
postValue(block(it, u.value!!, v.value!!, w.value!!))
}
}
addSource(u) {
if (this@mergeWith.value != null && v.value != null && w.value != null) {
postValue(block(this@mergeWith.value!!, it, v.value!!, w.value!!))
}
}
addSource(v) {
if (this@mergeWith.value != null && u.value != null && w.value != null) {
postValue(block(this@mergeWith.value!!, u.value!!, it, w.value!!))
}
}
addSource(w) {
if (this@mergeWith.value != null && u.value != null && v.value != null) {
postValue(block(this@mergeWith.value!!, u.value!!, v.value!!, it))
}
}
}
public fun String?.toIntOrElse(default: Int): Int = this?.toIntOrNull(radix = 10) ?: default
...@@ -12,23 +12,32 @@ object FFACache { ...@@ -12,23 +12,32 @@ object FFACache {
val md = MessageDigest.getInstance("SHA-1") val md = MessageDigest.getInstance("SHA-1")
val digest = md.digest(key.toByteArray(Charset.defaultCharset())) val digest = md.digest(key.toByteArray(Charset.defaultCharset()))
return digest.fold("", { acc, it -> acc + "%02x".format(it) }) return digest.fold("") { acc, it -> acc + "%02x".format(it) }
} }
fun set(context: Context?, key: String, value: ByteArray) = context?.let { fun set(context: Context?, key: String, value: String) {
set(context, key, value.toByteArray())
}
fun set(context: Context?, key: String, value: ByteArray) {
context?.let {
with(File(it.cacheDir, key(key))) { with(File(it.cacheDir, key(key))) {
writeBytes(value) writeBytes(value)
} }
} }
fun get(context: Context?, key: String): BufferedReader? = context?.let {
try {
with(File(it.cacheDir, key(key))) {
bufferedReader()
} }
} catch (e: Exception) {
return null fun getLine(context: Context?, key: String): String? = get(context, key)?.let {
val line = it.readLine()
it.close()
line
} }
fun getLines(context: Context?, key: String): List<String>? = get(context, key)
?.let { reader ->
val lines = reader.readLines()
reader.close()
lines
} }
fun delete(context: Context?, key: String) = context?.let { fun delete(context: Context?, key: String) = context?.let {
...@@ -36,4 +45,14 @@ object FFACache { ...@@ -36,4 +45,14 @@ object FFACache {
delete() delete()
} }
} }
private fun get(context: Context?, key: String): BufferedReader? = context?.let {
try {
with(File(it.cacheDir, key(key))) {
bufferedReader()
}
} catch (e: Exception) {
return null
}
}
} }
...@@ -13,7 +13,16 @@ import com.github.kittinunf.fuel.gson.jsonBody ...@@ -13,7 +13,16 @@ import com.github.kittinunf.fuel.gson.jsonBody
import com.github.kittinunf.result.Result import com.github.kittinunf.result.Result
import com.preference.PowerPreference import com.preference.PowerPreference
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.openid.appauth.* 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() { fun AuthState.save() {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply { PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
...@@ -56,13 +65,14 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory ...@@ -56,13 +65,14 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
fun isAuthorized(context: Context): Boolean { fun isAuthorized(context: Context): Boolean {
val state = tryState() val state = tryState()
return (if (state != null) { return (
if (state != null) {
state.validAuthorization() || refreshAccessToken(state, context) state.validAuthorization() || refreshAccessToken(state, context)
} else { } else {
false false
}).also {
it.logInfo("isAuthorized()")
} }
)
.also { it.logInfo("isAuthorized()") }
} }
private fun AuthState.validAuthorization() = this.isAuthorized && !this.needsTokenRefresh private fun AuthState.validAuthorization() = this.isAuthorized && !this.needsTokenRefresh
...@@ -73,7 +83,7 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory ...@@ -73,7 +83,7 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
refreshAccessToken(state, context) refreshAccessToken(state, context)
} else { } else {
state.isAuthorized state.isAuthorized
}.also { it.logInfo("tryRefreshAccessToken()") } }
} }
return false return false
} }
...@@ -87,8 +97,14 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory ...@@ -87,8 +97,14 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
return if (state.refreshToken != null) { return if (state.refreshToken != null) {
val refreshRequest = state.createTokenRefreshRequest() val refreshRequest = state.createTokenRefreshRequest()
val auth = ClientSecretPost(state.clientSecret) val auth = ClientSecretPost(state.clientSecret)
val refreshService = service(context)
runBlocking { runBlocking {
service(context).performTokenRequest(refreshRequest, auth) { response, e -> refreshService.performTokenRequest(refreshRequest, auth) { response, e ->
if (e != null) {
Log.e("OAuth", "performTokenRequest failed: $e")
Log.e("OAuth", Log.getStackTraceString(e))
EventBus.send(Event.LogOut)
} else {
state.apply { state.apply {
Log.i("OAuth", "applying new authState") Log.i("OAuth", "applying new authState")
update(response, e) update(response, e)
...@@ -96,6 +112,8 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory ...@@ -96,6 +112,8 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
} }
} }
} }
}
refreshService.dispose()
true true
} else { } else {
false false
...@@ -167,11 +185,10 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory ...@@ -167,11 +185,10 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
) )
} }
fun authorize(activity: Activity) { fun authorizeIntent(activity: Activity): Intent? {
val authService = service(activity) val authService = service(activity)
authorizationRequest()?.let { it -> return authorizationRequest()?.let { it ->
val intent = authService.getAuthorizationRequestIntent(it) authService.getAuthorizationRequestIntent(it)
activity.startActivityForResult(intent, 0)
} }
} }
...@@ -191,17 +208,23 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory ...@@ -191,17 +208,23 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
AuthorizationResponse.fromIntent(authorization)?.let { AuthorizationResponse.fromIntent(authorization)?.let {
val auth = ClientSecretPost(state().clientSecret) val auth = ClientSecretPost(state().clientSecret)
val requestService = service(context)
service(context).performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e -> requestService.performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
state if (e != null) {
.apply { Log.e("FFA", "performTokenRequest failed: $e")
Log.e("FFA", Log.getStackTraceString(e))
} else {
state.apply {
update(response, e) update(response, e)
save() save()
} }
}
if (response != null) success() if (response != null) success()
else Log.e("FFA", "performTokenRequest() not successful") else Log.e("FFA", "performTokenRequest() not successful")
} }
requestService.dispose()
} }
} }
} }
......
package audio.funkwhale.ffa.utils
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.util.Log
import android.widget.ImageButton
import androidx.annotation.ColorRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.databinding.BindingAdapter
@BindingAdapter("srcCompat")
fun setImageViewResource(imageView: AppCompatImageView, resource: Any?) = when (resource) {
is Bitmap -> imageView.setImageBitmap(resource)
is Int -> imageView.setImageResource(resource)
is Drawable -> imageView.setImageDrawable(resource)
else -> imageView.setImageDrawable(ColorDrawable(Color.TRANSPARENT))
}
@BindingAdapter("tint")
fun setTint(imageView: ImageButton, @ColorRes resource: Int) = resource.let {
imageView.setColorFilter(resource)
}
\ No newline at end of file
package audio.funkwhale.ffa.viewmodel
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.liveData
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.utils.Event
import audio.funkwhale.ffa.utils.EventBus
import com.google.android.exoplayer2.Player
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class NowPlayingViewModel(app: Application) : AndroidViewModel(app) {
val isBuffering = EventBus.get()
.filter { it is Event.Buffering }
.map { (it as Event.Buffering).value }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
.asLiveData(viewModelScope.coroutineContext)
.distinctUntilChanged()
val isPlaying = EventBus.get()
.filter { it is Event.StateChanged }
.map { (it as Event.StateChanged).playing }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
.asLiveData(viewModelScope.coroutineContext)
.distinctUntilChanged()
val repeatMode = MutableLiveData(0)
val progress = MutableLiveData(0)
val currentTrack = MutableLiveData<Track?>(null)
val currentProgressText = MutableLiveData("")
val currentDurationText = MutableLiveData("")
// Calling distinctUntilChanged() prevents triggering an event when the track hasn't changed
val currentTrackTitle = currentTrack.distinctUntilChanged().map { it?.title ?: "" }
val currentTrackArtist = currentTrack.distinctUntilChanged().map { it?.artist?.name ?: "" }
// Not calling distinctUntilChanged() here as we need to process every event
val isCurrentTrackFavorite = currentTrack.map {
it?.favorite ?: false
}
val repeatModeResource = repeatMode.distinctUntilChanged().map {
when (it) {
Player.REPEAT_MODE_ONE -> AppCompatResources.getDrawable(context, R.drawable.repeat_one)
else -> AppCompatResources.getDrawable(context, R.drawable.repeat)
}
}
val repeatModeAlpha = repeatMode.distinctUntilChanged().map {
when (it) {
Player.REPEAT_MODE_OFF -> 0.2f
else -> 1f
}
}
private val context: Context
get() = getApplication<FFA>().applicationContext
}
package audio.funkwhale.ffa.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.AlbumsSearchRepository
import audio.funkwhale.ffa.repositories.ArtistsSearchRepository
import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.repositories.TracksSearchRepository
import audio.funkwhale.ffa.utils.mergeWith
import audio.funkwhale.ffa.utils.untilNetwork
import kotlinx.coroutines.Dispatchers
import java.net.URLEncoder
import java.util.Locale
class SearchViewModel(app: Application) : AndroidViewModel(app), Observer<String> {
private val artistResultsLoading = MutableLiveData(false)
private val albumResultsLoading = MutableLiveData(false)
private val tackResultsLoading = MutableLiveData(false)
private val artistsRepository =
ArtistsSearchRepository(getApplication<FFA>().applicationContext, "")
private val albumsRepository =
AlbumsSearchRepository(getApplication<FFA>().applicationContext, "")
private val tracksRepository =
TracksSearchRepository(getApplication<FFA>().applicationContext, "")
private val dedupQuery: LiveData<String>
val query = MutableLiveData("")
val artistResults: LiveData<List<Artist>> = MutableLiveData(listOf())
val albumResults: LiveData<List<Album>> = MutableLiveData(listOf())
val trackResults: LiveData<List<Track>> = MutableLiveData(listOf())
val isLoadingData: LiveData<Boolean> = artistResultsLoading.mergeWith(
albumResultsLoading, tackResultsLoading
) { b1, b2, b3 -> b1 || b2 || b3 }
val hasResults: LiveData<Boolean> = isLoadingData.mergeWith(
artistResults, albumResults, trackResults
) { b, r1, r2, r3 -> b || r1.isNotEmpty() || r2.isNotEmpty() || r3.isNotEmpty() }
init {
dedupQuery = query.map { it.trim().lowercase(Locale.ROOT) }.distinctUntilChanged()
dedupQuery.observeForever(this)
}
override fun onChanged(token: String) {
if (token.isBlank()) { // Empty search
(artistResults as MutableLiveData).postValue(listOf())
(albumResults as MutableLiveData).postValue(listOf())
(trackResults as MutableLiveData).postValue(listOf())
return
}
artistResultsLoading.postValue(true)
albumResultsLoading.postValue(true)
tackResultsLoading.postValue(true)
val encoded = URLEncoder.encode(token, "UTF-8")
(artistResults as MutableLiveData).postValue(listOf())
artistsRepository.apply {
query = encoded
fetch(Repository.Origin.Network.origin).untilNetwork(
viewModelScope,
Dispatchers.IO
) { data, _, _, hasMore ->
artistResults.postValue(artistResults.value!! + data)
if (!hasMore) {
artistResultsLoading.postValue(false)
}
}
}
(albumResults as MutableLiveData).postValue(listOf())
albumsRepository.apply {
query = encoded
fetch(Repository.Origin.Network.origin).untilNetwork(
viewModelScope,
Dispatchers.IO
) { data, _, _, hasMore ->
albumResults.postValue(albumResults.value!! + data)
if (!hasMore) {
albumResultsLoading.postValue(false)
}
}
}
(trackResults as MutableLiveData).postValue(listOf())
tracksRepository.apply {
query = encoded
fetch(Repository.Origin.Network.origin).untilNetwork(
viewModelScope,
Dispatchers.IO
) { data, _, _, hasMore ->
trackResults.postValue(trackResults.value!! + data)
if (!hasMore) {
tackResultsLoading.postValue(false)
}
}
}
}
override fun onCleared() {
dedupQuery.removeObserver(this)
}
}
package audio.funkwhale.ffa.views
import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.cardview.widget.CardView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.use
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.utils.BottomSheetIneractable
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
class NowPlayingBottomSheet @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : CardView(context, attrs, defStyleAttr), BottomSheetIneractable {
private val behavior = BottomSheetBehavior<NowPlayingBottomSheet>()
private val targetHeaderId: Int
val peekHeight get() = behavior.peekHeight
init {
targetHeaderId = context.theme.obtainStyledAttributes(
attrs, R.styleable.NowPlaying, defStyleAttr, 0
).use {
it.getResourceId(R.styleable.NowPlaying_target_header, NO_ID)
}
// Put default peek height to actionBarSize so it is not 0
val tv = TypedValue()
if (context.theme.resolveAttribute(android.R.attr.actionBarSize, tv, true)) {
behavior.peekHeight = TypedValue.complexToDimensionPixelSize(
tv.data, resources.displayMetrics
)
}
}
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
super.setLayoutParams(params)
(params as CoordinatorLayout.LayoutParams).behavior = behavior
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
findViewById<View>(targetHeaderId)?.apply {
behavior.setPeekHeight(this.height, false)
this.setOnClickListener { this@NowPlayingBottomSheet.toggle() }
} ?: hide()
}
override fun onTouchEvent(event: MotionEvent): Boolean = true
fun addBottomSheetCallback(callback: BottomSheetCallback) {
behavior.addBottomSheetCallback(callback)
}
// Bottom sheet interactions
override val isHidden: Boolean get() = behavior.state == BottomSheetBehavior.STATE_HIDDEN
override fun isOpen(): Boolean = behavior.state == BottomSheetBehavior.STATE_EXPANDED
override fun open() {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
override fun close() {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
override fun show() {
behavior.isHideable = false
if (behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
close()
}
}
override fun hide() {
behavior.isHideable = true
behavior.state = BottomSheetBehavior.STATE_HIDDEN
}
override fun toggle() {
if (isHidden) return
if (isOpen) close() else open()
}
}
package audio.funkwhale.ffa.views
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewTreeObserver
import android.view.animation.DecelerateInterpolator
import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.PartialNowPlayingBinding
import com.google.android.material.card.MaterialCardView
import kotlin.math.abs
import kotlin.math.min
class NowPlayingView : MaterialCardView {
val activity: Context
var gestureDetector: GestureDetector? = null
var gestureDetectorCallback: OnGestureDetection? = null
private val binding =
PartialNowPlayingBinding.inflate(LayoutInflater.from(context), this, true)
constructor(context: Context) : super(context) {
activity = context
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
activity = context
}
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) {
activity = context
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
binding.nowPlayingRoot.measure(
widthMeasureSpec,
MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), MeasureSpec.UNSPECIFIED)
)
}
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
if (visibility == View.VISIBLE && gestureDetector == null) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
gestureDetectorCallback = OnGestureDetection()
gestureDetector = GestureDetector(context, gestureDetectorCallback)
setOnTouchListener { _, motionEvent ->
val ret = gestureDetector?.onTouchEvent(motionEvent) ?: false
if (motionEvent.actionMasked == MotionEvent.ACTION_UP) {
if (gestureDetectorCallback?.isScrolling == true) {
gestureDetectorCallback?.onUp()
}
}
performClick()
ret
}
viewTreeObserver.removeOnGlobalLayoutListener(this)
}
})
}
}
fun isOpened(): Boolean = gestureDetectorCallback?.isOpened() ?: false
fun close() {
gestureDetectorCallback?.close()
}
inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() {
private var maxHeight = 0
private var minHeight = 0
private var maxMargin = 0
private var initialTouchY = 0f
private var lastTouchY = 0f
var isScrolling = false
private var flingAnimator: ValueAnimator? = null
init {
(layoutParams as? MarginLayoutParams)?.let {
maxMargin = it.marginStart
}
minHeight = TypedValue().let {
activity.theme.resolveAttribute(R.attr.actionBarSize, it, true)
TypedValue.complexToDimensionPixelSize(it.data, resources.displayMetrics)
}
maxHeight = binding.nowPlayingDetails.measuredHeight + (2 * maxMargin)
}
override fun onDown(e: MotionEvent): Boolean {
initialTouchY = e.rawY
lastTouchY = e.rawY
return true
}
fun onUp(): Boolean {
isScrolling = false
layoutParams.let {
val offsetToMax = maxHeight - height
val offsetToMin = height - minHeight
flingAnimator =
if (offsetToMin < offsetToMax) ValueAnimator.ofInt(it.height, minHeight)
else ValueAnimator.ofInt(it.height, maxHeight)
animateFling(500)
return true
}
}
override fun onFling(
firstMotionEvent: MotionEvent?,
secondMotionEvent: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
isScrolling = false
layoutParams.let {
val diff =
if (velocityY < 0) maxHeight - it.height
else it.height - minHeight
flingAnimator =
if (velocityY < 0) ValueAnimator.ofInt(it.height, maxHeight)
else ValueAnimator.ofInt(it.height, minHeight)
animateFling(min(abs((diff.toFloat() / velocityY * 1000).toLong()), 600))
}
return true
}
override fun onScroll(
firstMotionEvent: MotionEvent,
secondMotionEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
isScrolling = true
layoutParams.let {
val newHeight = it.height + lastTouchY - secondMotionEvent.rawY
val progress = (newHeight - minHeight) / (maxHeight - minHeight)
val newMargin = maxMargin - (maxMargin * progress)
(layoutParams as? MarginLayoutParams)?.let { params ->
params.marginStart = newMargin.toInt()
params.marginEnd = newMargin.toInt()
params.bottomMargin = newMargin.toInt()
}
layoutParams = layoutParams.apply {
when {
newHeight <= minHeight -> {
height = minHeight
return true
}
newHeight >= maxHeight -> {
height = maxHeight
return true
}
else -> height = newHeight.toInt()
}
}
binding.summary.alpha = 1f - progress
binding.summary.layoutParams = binding.summary.layoutParams.apply {
height = (minHeight * (1f - progress)).toInt()
}
}
lastTouchY = secondMotionEvent.rawY
return true
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
layoutParams.let {
if (height != minHeight) return true
flingAnimator = ValueAnimator.ofInt(it.height, maxHeight)
animateFling(300)
}
return true
}
fun isOpened(): Boolean = layoutParams.height == maxHeight
fun close(): Boolean {
layoutParams.let {
if (it.height == minHeight) return true
flingAnimator = ValueAnimator.ofInt(it.height, minHeight)
animateFling(300)
}
return true
}
private fun animateFling(dur: Long) {
flingAnimator?.apply {
duration = dur
interpolator = DecelerateInterpolator()
addUpdateListener { valueAnimator ->
layoutParams = layoutParams.apply {
val newHeight = valueAnimator.animatedValue as Int
val progress = (newHeight.toFloat() - minHeight) / (maxHeight - minHeight)
val newMargin = maxMargin - (maxMargin * progress)
(layoutParams as? MarginLayoutParams)?.let {
it.marginStart = newMargin.toInt()
it.marginEnd = newMargin.toInt()
it.bottomMargin = newMargin.toInt()
}
height = newHeight
binding.summary.alpha = 1f - progress
binding.summary.layoutParams = binding.summary.layoutParams.apply {
height = (minHeight * (1f - progress)).toInt()
}
}
}
start()
}
}
}
}
...@@ -2,9 +2,11 @@ package audio.funkwhale.ffa.views ...@@ -2,9 +2,11 @@ package audio.funkwhale.ffa.views
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.AppCompatImageButton
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
class SquareImageView : AppCompatImageView { open class SquareView : View {
constructor(context: Context) : super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
...@@ -12,6 +14,23 @@ class SquareImageView : AppCompatImageView { ...@@ -12,6 +14,23 @@ class SquareImageView : AppCompatImageView {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) super.onMeasure(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(measuredWidth, measuredWidth) val dimension = if(measuredWidth == 0 && measuredHeight > 0) measuredHeight else measuredWidth
setMeasuredDimension(dimension, dimension)
}
}
open class SquareImageView : AppCompatImageView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val dimension = if(measuredWidth == 0 && measuredHeight > 0) measuredHeight else measuredWidth
setMeasuredDimension(dimension, dimension)
} }
} }