#7: Add unit tests to OAuthDatasource

This commit is contained in:
Ryan Harg 2021-07-30 08:57:49 +00:00
parent 6d086c021f
commit e60d93a05a
21 changed files with 462 additions and 128 deletions

View File

@ -1,51 +1,82 @@
image: jangrewe/gitlab-ci-android
variables:
JACOCO_CSV_LOCATION: '$CI_PROJECT_DIR/app/build/reports/jacoco/testDebugUnitTestCoverage/testDebugUnitTestCoverage.csv'
stages:
- build
- deploy
- test
- visualize
- build
- deploy
.build:
.gradle-default:
stage: build
before_script:
- export GRADLE_USER_HOME=$(pwd)/.gradle
- chmod +x ./gradlew
- mkdir -p .android && touch .android/repositories.cfg
- export GRADLE_USER_HOME=$(pwd)/.gradle
- chmod +x ./gradlew
- mkdir -p .android && touch .android/repositories.cfg
script:
- echo "Overwrite me"
- echo "Overwrite me"
cache:
key: ${CI_PROJECT_ID}
paths:
- .gradle/
- .gradle/
.build:
extends: .gradle-default
artifacts:
paths:
- app/build/outputs/apk/debug/app-debug.apk
- app/build/outputs/apk/debug/app-debug.apk
test:
extends: .gradle-default
stage: test
script:
- ./gradlew test testDebugUnitTestCoverage
- awk -F"," '{ instructions += $4 + $5; covered += $5 } END { print covered, "/", instructions, " instructions covered"; print 100*covered/instructions, "% covered" }' $JACOCO_CSV_LOCATION
artifacts:
reports:
junit: app/build/test-results/test**/TEST-*.xml
paths:
- app/build/reports/jacoco/testDebugUnitTestCoverage/testDebugUnitTestCoverage.xml
coverage:
stage: visualize
image: gjrtimmer/jacoco2cobertura:1.0.8
script:
# convert report from jacoco to cobertura, use relative project path
- 'python /opt/cover2cover.py app/build/reports/jacoco/testDebugUnitTestCoverage/testDebugUnitTestCoverage.xml $CI_PROJECT_DIR/app/src/main/java > app/build/reports/cobertura.xml'
needs: [ "test" ]
dependencies:
- test
artifacts:
reports:
cobertura: app/build/reports/cobertura.xml
build-develop:
extends: .build
script:
- echo -n $SIGNING_KEY_STORE | base64 -d > app/android.keystore
- ./gradlew assembleDebug -Psigning.store=android.keystore -Psigning.store_passphrase=$SIGNING_KEY_PASS -Psigning.key_passphrase=$SIGNING_KEY_PASS
- echo -n $SIGNING_KEY_STORE | base64 -d > app/android.keystore
- ./gradlew assembleDebug -Psigning.store=android.keystore -Psigning.store_passphrase=$SIGNING_KEY_PASS -Psigning.key_passphrase=$SIGNING_KEY_PASS
only:
- develop
- develop
build-bleeding-edge:
extends: .build
script:
- ./gradlew assembleDebug
- ./gradlew assembleDebug
except:
- develop
- develop
deploy-develop:
stage: deploy
only:
- develop
only:
- develop
script:
- eval `ssh-agent -s`
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/debug/app-debug.apk fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/repo/audio.funkwhale.ffa.dev-$CI_COMMIT_SHORT_SHA.apk
- ssh -o StrictHostKeyChecking=no fdroid@apps.funkwhale.audio 'docker run --rm -u $(id -u):$(id -g) -v /srv/fdroid/fdroid/develop:/repo registry.gitlab.com/fdroid/docker-executable-fdroidserver:master update'
- eval `ssh-agent -s`
- ssh-add <(echo "$SSH_PRIVATE_KEY")
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/debug/app-debug.apk fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/repo/audio.funkwhale.ffa.dev-$CI_COMMIT_SHORT_SHA.apk
- ssh -o StrictHostKeyChecking=no fdroid@apps.funkwhale.audio 'docker run --rm -u $(id -u):$(id -g) -v /srv/fdroid/fdroid/develop:/repo registry.gitlab.com/fdroid/docker-executable-fdroidserver:master update'
tags:
- shell

View File

@ -9,6 +9,7 @@ plugins {
id("org.jlleitschuh.gradle.ktlint") version "8.1.0"
id("com.gladed.androidgitversion") version "0.4.14"
id("com.github.triplet.play") version "2.4.2"
jacoco
}
val props = Properties().apply {
@ -18,6 +19,10 @@ val props = Properties().apply {
}
}
jacoco {
toolVersion = "0.8.7"
}
androidGitVersion {
codeFormat = "MMNNPPBBB"
format = "%tag%%-count%%-commit%%-branch%"
@ -75,6 +80,10 @@ android {
}
}
testOptions {
unitTests.isReturnDefaultValues = true
}
buildTypes {
getByName("debug") {
isDebuggable = true
@ -84,6 +93,8 @@ android {
signingConfig = signingConfigs.getByName("debug")
}
isTestCoverageEnabled = true
resValue("string", "debug.hostname", props.getProperty("debug.hostname", ""))
resValue("string", "debug.username", props.getProperty("debug.username", ""))
resValue("string", "debug.password", props.getProperty("debug.password", ""))
@ -160,6 +171,60 @@ dependencies {
implementation("com.google.code.gson:gson:2.8.7")
implementation("com.squareup.picasso:picasso:2.71828")
implementation("jp.wasabeef:picasso-transformations:2.4.0")
implementation("net.openid:appauth:0.9.1")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.12.0")
androidTestImplementation("io.mockk:mockk-android:1.12.0")
testImplementation("androidx.test:core:1.4.0")
testImplementation("io.strikt:strikt-core:0.31.0")
}
project.afterEvaluate {
android.applicationVariants.forEach { variant ->
val testTaskName = "test${variant.name.capitalize()}UnitTest"
tasks.create<JacocoReport>(name = "${testTaskName}Coverage") {
dependsOn(testTaskName)
group = "Reporting"
description = "Generate Jacoco coverage reports after running tests."
reports {
xml.required.set(true)
csv.required.set(true)
html.required.set(true)
}
val excludes = listOf(
"**/R.class",
"**/R$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*Test*.*",
"android/**/*.*"
)
val javaClasses =
fileTree(baseDir = variant.javaCompileProvider.get().destinationDirectory).matching {
setExcludes(excludes)
}
val kotlinClasses =
fileTree(baseDir = "$buildDir/tmp/kotlin-classes/${variant.name}").matching {
setExcludes(excludes)
}
classDirectories.setFrom(files(listOf(javaClasses, kotlinClasses)))
val sourceDirectories = files(
listOf(
"$project.projectDir/src/main/java",
"$project.projectDir/src/${variant.name}/java",
"$project.projectDir/src/main/kotlin",
"$project.projectDir/src/${variant.name}/kotlin"
)
)
sourceDirectories.setFrom(files(sourceDirectories))
executionData.setFrom(files("${project.buildDir}/jacoco/$testTaskName.exec"))
}
}
}

View File

@ -13,6 +13,7 @@ import audio.funkwhale.ffa.databinding.ActivityLoginBinding
import audio.funkwhale.ffa.fragments.LoginDialog
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.Userinfo
import audio.funkwhale.ffa.utils.log
import com.github.kittinunf.fuel.Fuel
@ -28,13 +29,13 @@ data class FwCredentials(val token: String, val non_field_errors: List<String>?)
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private lateinit var oAuth: OAuth
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
oAuth = OAuthFactory.instance()
setContentView(binding.root)
limitContainerWidth()
}
@ -45,7 +46,7 @@ class LoginActivity : AppCompatActivity() {
data?.let {
when (requestCode) {
0 -> {
OAuth.exchange(this, data,
oAuth.exchange(this, data,
{
PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
@ -114,11 +115,9 @@ class LoginActivity : AppCompatActivity() {
private fun authedLogin(hostname: String) {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname)
OAuth.init(hostname)
OAuth.register {
OAuth.authorize(this)
oAuth.init(hostname)
oAuth.register {
oAuth.authorize(this)
}
}

View File

@ -7,16 +7,21 @@ import androidx.appcompat.app.AppCompatActivity
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.Settings
class SplashActivity : AppCompatActivity() {
private lateinit var oAuth: OAuth
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
oAuth = OAuthFactory.instance()
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE)
.apply {
when (OAuth.isAuthorized(this@SplashActivity) || Settings.isAnonymous()) {
when (oAuth.isAuthorized(this@SplashActivity) || Settings.isAnonymous()) {
true -> Intent(this@SplashActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
@ -30,5 +35,4 @@ class SplashActivity : AppCompatActivity() {
}
}
}
}

View File

@ -11,7 +11,8 @@ import com.google.android.exoplayer2.upstream.TransferListener
class OAuthDatasource(
private val context: Context,
private val http: HttpDataSource
private val http: HttpDataSource,
private val oauth: OAuth
) : DataSource {
override fun addTransferListener(transferListener: TransferListener?) {
@ -19,7 +20,10 @@ class OAuthDatasource(
}
override fun open(dataSpec: DataSpec?): Long {
OAuth.tryRefreshAccessToken(context)
oauth.tryRefreshAccessToken(context)
http.apply {
setRequestProperty("Authorization", "Bearer ${oauth.state().accessToken}")
}
return http.open(dataSpec)
}
@ -34,15 +38,15 @@ class OAuthDatasource(
override fun close() {
http.close()
}
}
class OAuth2DatasourceFactory(
private val context: Context,
private val http: DefaultHttpDataSourceFactory
private val http: DefaultHttpDataSourceFactory,
private val oauth: OAuth
) : DataSource.Factory {
override fun createDataSource(): DataSource {
return OAuthDatasource(context, http.createDataSource())
return OAuthDatasource(context, http.createDataSource(), oauth)
}
}

View File

@ -9,7 +9,7 @@ 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.OAuthFactory
import audio.funkwhale.ffa.utils.QueueCache
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.Track
@ -17,6 +17,7 @@ import audio.funkwhale.ffa.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory
import com.google.android.exoplayer2.upstream.FileDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
@ -32,19 +33,9 @@ class QueueManager(val context: Context) {
companion object {
fun factory(context: Context): CacheDataSourceFactory {
val http = DefaultHttpDataSourceFactory(
Util.getUserAgent(context, context.getString(R.string.app_name))
)
.apply {
defaultRequestProperties.apply {
if (!Settings.isAnonymous()) {
set("Authorization", "Bearer ${OAuth.state().accessToken}")
}
}
}
val playbackCache =
CacheDataSourceFactory(FFA.get().exoCache, OAuth2DatasourceFactory(context, http))
CacheDataSourceFactory(FFA.get().exoCache, createDatasourceFactory(context))
return CacheDataSourceFactory(
FFA.get().exoDownloadCache,
@ -55,6 +46,17 @@ class QueueManager(val context: Context) {
null
)
}
private fun createDatasourceFactory(context: Context): DataSource.Factory {
val http = DefaultHttpDataSourceFactory(
Util.getUserAgent(context, context.getString(R.string.app_name))
)
return if (!Settings.isAnonymous()) {
OAuth2DatasourceFactory(context, http, OAuthFactory.instance())
} else {
http
}
}
}
init {

View File

@ -4,6 +4,7 @@ import android.content.Context
import audio.funkwhale.ffa.utils.Album
import audio.funkwhale.ffa.utils.AlbumsCache
import audio.funkwhale.ffa.utils.AlbumsResponse
import audio.funkwhale.ffa.utils.OAuthFactory
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
@ -11,6 +12,8 @@ import java.io.BufferedReader
class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
Repository<Album, AlbumsCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId: String by lazy {
if (artistId == null) "albums"
else "albums-artist-$artistId"
@ -25,7 +28,8 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
context!!,
HttpUpstream.Behavior.Progressive,
url,
object : TypeToken<AlbumsResponse>() {}.type
object : TypeToken<AlbumsResponse>() {}.type,
oAuth
)
}

View File

@ -1,6 +1,7 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.TracksCache
@ -12,13 +13,16 @@ import java.io.BufferedReader
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) :
Repository<Track, TracksCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId = "tracks-artist-$artistId"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/tracks/?playable=true&artist=$artistId",
object : TypeToken<TracksResponse>() {}.type
object : TypeToken<TracksResponse>() {}.type,
oAuth
)
override fun cache(data: List<Track>) = TracksCache(data)

View File

@ -4,6 +4,7 @@ import android.content.Context
import audio.funkwhale.ffa.utils.Artist
import audio.funkwhale.ffa.utils.ArtistsCache
import audio.funkwhale.ffa.utils.ArtistsResponse
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.OtterResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
@ -11,13 +12,16 @@ import java.io.BufferedReader
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId = "artists"
override val upstream = HttpUpstream<Artist, OtterResponse<Artist>>(
context,
HttpUpstream.Behavior.Progressive,
"/api/v1/artists/?playable=true&ordering=name",
object : TypeToken<ArtistsResponse>() {}.type
object : TypeToken<ArtistsResponse>() {}.type,
oAuth
)
override fun cache(data: List<Artist>) = ArtistsCache(data)

View File

@ -5,7 +5,7 @@ import audio.funkwhale.ffa.FFA
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.OAuthFactory
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Settings
import audio.funkwhale.ffa.utils.Track
@ -28,13 +28,16 @@ import java.io.BufferedReader
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
private var oAuth = OAuthFactory.instance()
override val cacheId = "favorites.v2"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(
context!!,
HttpUpstream.Behavior.AtOnce,
"/api/v1/tracks/?favorites=true&playable=true&ordering=title",
object : TypeToken<TracksResponse>() {}.type
object : TypeToken<TracksResponse>() {}.type,
oAuth
)
override fun cache(data: List<Track>) = TracksCache(data)
@ -67,7 +70,7 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${oAuth.state().accessToken}")
}
}
@ -89,7 +92,7 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
request.header("Authorization", "Bearer ${OAuth.state().accessToken}")
request.header("Authorization", "Bearer ${oAuth.state().accessToken}")
}
}
@ -106,12 +109,16 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
}
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId = "favorited"
override val upstream = HttpUpstream<Int, OtterResponse<Int>>(
context,
HttpUpstream.Behavior.Single,
"/api/v1/favorites/tracks/all/?playable=true",
object : TypeToken<FavoritedResponse>() {}.type
object : TypeToken<FavoritedResponse>() {}.type,
oAuth
)
override fun cache(data: List<Int>) = FavoritedCache(data)

View File

@ -23,7 +23,8 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(
val context: Context?,
val behavior: Behavior,
private val url: String,
private val type: Type
private val type: Type,
private val oAuth: OAuth
) : Upstream<D> {
enum class Behavior {
@ -110,7 +111,7 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(
return if (http.refresh()) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${oAuth.state().accessToken}")
}
}

View File

@ -1,6 +1,7 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.PlaylistTrack
import audio.funkwhale.ffa.utils.PlaylistTracksCache
@ -15,13 +16,16 @@ import java.io.BufferedReader
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) :
Repository<PlaylistTrack, PlaylistTracksCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId = "tracks-playlist-$playlistId"
override val upstream = HttpUpstream<PlaylistTrack, OtterResponse<PlaylistTrack>>(
context,
HttpUpstream.Behavior.Single,
"/api/v1/playlists/$playlistId/tracks/?playable=true",
object : TypeToken<PlaylistTracksResponse>() {}.type
object : TypeToken<PlaylistTracksResponse>() {}.type,
oAuth
)
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)

View File

@ -1,7 +1,7 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Playlist
import audio.funkwhale.ffa.utils.PlaylistsCache
@ -30,7 +30,8 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist,
context!!,
HttpUpstream.Behavior.Progressive,
"/api/v1/playlists/?playable=true&ordering=name",
object : TypeToken<PlaylistsResponse>() {}.type
object : TypeToken<PlaylistsResponse>() {}.type,
OAuthFactory.instance()
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
@ -41,13 +42,16 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist,
class ManagementPlaylistsRepository(override val context: Context?) :
Repository<Playlist, PlaylistsCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId = "tracks-playlists-management"
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/playlists/?scope=me&ordering=name",
object : TypeToken<PlaylistsResponse>() {}.type
object : TypeToken<PlaylistsResponse>() {}.type,
oAuth
)
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
@ -62,7 +66,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${oAuth.state().accessToken}")
}
}
@ -85,7 +89,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/add/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${oAuth.state().accessToken}")
}
}
@ -106,7 +110,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/remove/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${oAuth.state().accessToken}")
}
}
@ -125,7 +129,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${oAuth.state().accessToken}")
}
}

View File

@ -1,6 +1,7 @@
package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Radio
import audio.funkwhale.ffa.utils.RadiosCache
@ -11,13 +12,16 @@ import java.io.BufferedReader
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId = "radios"
override val upstream = HttpUpstream<Radio, OtterResponse<Radio>>(
context,
HttpUpstream.Behavior.Progressive,
"/api/v1/radios/radios/?ordering=name",
object : TypeToken<RadiosResponse>() {}.type
object : TypeToken<RadiosResponse>() {}.type,
oAuth
)
override fun cache(data: List<Radio>) = RadiosCache(data)

View File

@ -8,6 +8,7 @@ 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.OAuthFactory
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.TracksCache
import audio.funkwhale.ffa.utils.TracksResponse
@ -22,6 +23,8 @@ import java.io.BufferedReader
class TracksSearchRepository(override val context: Context?, var query: String) :
Repository<Track, TracksCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId: String? = null
override val upstream: Upstream<Track>
@ -29,7 +32,8 @@ class TracksSearchRepository(override val context: Context?, var query: String)
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/tracks/?playable=true&q=$query",
object : TypeToken<TracksResponse>() {}.type
object : TypeToken<TracksResponse>() {}.type,
oAuth
)
override fun cache(data: List<Track>) = TracksCache(data)
@ -61,13 +65,17 @@ class TracksSearchRepository(override val context: Context?, var query: String)
class ArtistsSearchRepository(override val context: Context?, var query: String) :
Repository<Artist, ArtistsCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId: String? = null
override val upstream: Upstream<Artist>
get() = HttpUpstream(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/artists/?playable=true&q=$query",
object : TypeToken<ArtistsResponse>() {}.type
object : TypeToken<ArtistsResponse>() {}.type,
oAuth
)
override fun cache(data: List<Artist>) = ArtistsCache(data)
@ -77,13 +85,17 @@ class ArtistsSearchRepository(override val context: Context?, var query: String)
class AlbumsSearchRepository(override val context: Context?, var query: String) :
Repository<Album, AlbumsCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId: String? = null
override val upstream: Upstream<Album>
get() = HttpUpstream(
context,
HttpUpstream.Behavior.AtOnce,
"/api/v1/albums/?playable=true&q=$query",
object : TypeToken<AlbumsResponse>() {}.type
object : TypeToken<AlbumsResponse>() {}.type,
oAuth
)
override fun cache(data: List<Album>) = AlbumsCache(data)

View File

@ -2,6 +2,7 @@ package audio.funkwhale.ffa.repositories
import android.content.Context
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.utils.OAuthFactory
import audio.funkwhale.ffa.utils.OtterResponse
import audio.funkwhale.ffa.utils.Track
import audio.funkwhale.ffa.utils.TracksCache
@ -19,13 +20,16 @@ import java.io.BufferedReader
class TracksRepository(override val context: Context?, albumId: Int) :
Repository<Track, TracksCache>() {
private val oAuth = OAuthFactory.instance()
override val cacheId = "tracks-album-$albumId"
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
object : TypeToken<TracksResponse>() {}.type,
oAuth
)
override fun cache(data: List<Track>) = TracksCache(data)

View File

@ -19,25 +19,33 @@ object RefreshError : Throwable()
class HTTP(val context: Context?) {
suspend fun refresh(): Boolean {
val body = mapOf(
"username" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("username"),
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("password")
).toList()
context?.let {
val body = mapOf(
"username" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("username"),
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("password")
).toList()
val result = Fuel.post(mustNormalizeUrl("/api/v1/token"), body)
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
val result = Fuel.post(mustNormalizeUrl("/api/v1/token"), body).apply {
if (!Settings.isAnonymous()) {
authorize(it)
header("Authorization", "Bearer ${OAuthFactory.instance().state().accessToken}")
}
}
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
return result.fold(
{ data ->
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.setString("access_token", data.token)
return result.fold(
{ data ->
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS)
.setString("access_token", data.token)
true
},
{ false }
)
true
},
{ false }
)
}
throw IllegalStateException("Illegal state: context is null")
}
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
@ -46,7 +54,7 @@ class HTTP(val context: Context?) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
authorize(it)
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${OAuthFactory.instance().state().accessToken}")
}
}
@ -65,18 +73,13 @@ class HTTP(val context: Context?) {
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}")
}
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
authorize(context)
header("Authorization", "Bearer ${OAuthFactory.instance().state().accessToken}")
}
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
} else {
Result.Failure(FuelError.wrap(RefreshError))
}
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
}
throw IllegalStateException("Illegal state: context is null")
}

View File

@ -80,16 +80,17 @@ fun Request.authorize(context: Context): Request {
return runBlocking {
this@authorize.apply {
if (!Settings.isAnonymous()) {
OAuth.state().let { state ->
val oauth = OAuthFactory.instance()
oauth.state().let { state ->
val old = state.accessToken
val auth = ClientSecretPost(OAuth.state().clientSecret)
val auth = ClientSecretPost(oauth.state().clientSecret)
val done = CompletableDeferred<Boolean>()
state.performActionWithFreshTokens(OAuth.service(context), auth) { token, _, _ ->
state.performActionWithFreshTokens(oauth.service(context), auth) { token, _, _ ->
if (token != old && token != null) {
state.save()
}
header("Authorization", "Bearer ${OAuth.state().accessToken}")
header("Authorization", "Bearer ${oauth.state().accessToken}")
done.complete(true)
}
done.await()

View File

@ -29,13 +29,64 @@ fun AuthState.save() {
}
}
object OAuth {
interface OAuth {
fun exchange(context: Activity, authorization: Intent, success: () -> Unit, error: () -> Unit)
fun init(hostname: String)
fun register(callback: () -> Unit)
fun authorize(context: Activity)
fun isAuthorized(context: Context): Boolean
fun tryRefreshAccessToken(context: Context, overrideNeedsTokenRefresh: Boolean = false): Boolean
fun tryState(): AuthState?
fun state(): AuthState
fun service(context: Context): AuthorizationService
}
object OAuthFactory {
private val oAuth: OAuth
init {
oAuth = DefaultOAuth()
}
fun instance() = oAuth
}
class DefaultOAuth : OAuth {
companion object {
private val REDIRECT_URI =
Uri.parse("urn:/audio.funkwhale.funkwhale-android/oauth/callback")
}
data class App(val client_id: String, val client_secret: String)
private val REDIRECT_URI =
Uri.parse("urn:/audio.funkwhale.funkwhale-android/oauth/callback")
override fun tryState(): AuthState? {
fun isAuthorized(context: Context): Boolean {
val savedState = PowerPreference
.getFileByName(AppContext.PREFS_CREDENTIALS)
.getString("state")
return if (savedState != null && savedState.isNotEmpty()) {
return AuthState.jsonDeserialize(savedState)
} else {
null
}
}
override fun state(): AuthState = tryState()!!
override fun isAuthorized(context: Context): Boolean {
val state = tryState()
return if (state != null) {
state.isAuthorized || tryRefreshAccessToken(context)
@ -46,9 +97,10 @@ object OAuth {
}
}
fun state(): AuthState = tryState()!!
fun tryRefreshAccessToken(context: Context, overrideNeedsTokenRefresh: Boolean = false): Boolean {
override fun tryRefreshAccessToken(
context: Context,
overrideNeedsTokenRefresh: Boolean
): Boolean {
tryState()?.let { state ->
val shouldRefreshAccessToken = overrideNeedsTokenRefresh || state.needsTokenRefresh
if (shouldRefreshAccessToken && state.refreshToken != null) {
@ -71,26 +123,13 @@ object OAuth {
}
}
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) {
override fun init(hostname: String) {
AuthState(config(hostname)).save()
}
fun service(context: Context) = AuthorizationService(context)
override fun service(context: Context): AuthorizationService = AuthorizationService(context)
fun register(callback: () -> Unit) {
override fun register(callback: () -> Unit) {
state().authorizationServiceConfiguration?.let { config ->
runBlocking {
@ -133,7 +172,7 @@ object OAuth {
)
}
fun authorize(context: Activity) {
override fun authorize(context: Activity) {
val intent = service(context).run {
authorizationRequest()?.let {
getAuthorizationRequestIntent(it)
@ -143,7 +182,12 @@ object OAuth {
context.startActivityForResult(intent, 0)
}
fun exchange(context: Activity, authorization: Intent, success: () -> Unit, error: () -> Unit) {
override fun exchange(
context: Activity,
authorization: Intent,
success: () -> Unit,
error: () -> Unit
) {
state().let { state ->
state.apply {
update(

View File

@ -0,0 +1,75 @@
package audio.funkwhale.ffa.playback
import android.content.Context
import android.net.Uri
import audio.funkwhale.ffa.utils.OAuth
import audio.funkwhale.util.MockKJUnitRunner
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.TransferListener
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.junit.runner.RunWith
import strikt.api.expectThat
import strikt.assertions.isEqualTo
@RunWith(MockKJUnitRunner::class)
class OAuthDatasourceTest {
@InjectMockKs
private lateinit var datasource: OAuthDatasource
@MockK
private lateinit var context: Context
@MockK
private lateinit var http: HttpDataSource
@MockK
private lateinit var oAuth: OAuth
private var dataSpec: DataSpec = DataSpec(Uri.EMPTY)
@Test
fun `open() should set accessToken and delegate to http dataSource`() {
every { http.open(any()) } returns 0
every { oAuth.tryRefreshAccessToken(any(), any()) } returns true
every { oAuth.state().accessToken } returns "accessToken"
datasource.open(dataSpec)
verify { http.open(dataSpec) }
verify { http.setRequestProperty("Authorization", "Bearer accessToken") }
}
@Test
fun `close() should delegate to http dataSource`() {
datasource.close()
verify { http.close() }
}
@Test
fun `addTransferListener() should delegate to http dataSource`() {
val transferListener = mockk<TransferListener>()
datasource.addTransferListener(transferListener)
verify { http.addTransferListener(transferListener) }
}
@Test
fun `read() should delegate to http dataSource`() {
every { http.read(any(), any(), any()) } returns 0
datasource.read("123".encodeToByteArray(), 1, 2)
verify { http.read("123".encodeToByteArray(), 1, 2) }
}
@Test
fun `getUri() should delegate to http dataSource`() {
every { http.uri } returns Uri.EMPTY
val result = datasource.uri
verify { http.uri }
expectThat(result).isEqualTo(Uri.EMPTY)
}
}

View File

@ -0,0 +1,58 @@
package audio.funkwhale.util
import io.mockk.MockKAnnotations
import io.mockk.clearAllMocks
import org.junit.Test
import org.junit.runner.Description
import org.junit.runner.Runner
import org.junit.runner.notification.Failure
import org.junit.runner.notification.RunNotifier
import java.lang.reflect.Method
class MockKJUnitRunner(private val testClass: Class<*>) : Runner() {
private val methodDescriptions: MutableMap<Method, Description> = mutableMapOf()
init {
// Build method/descriptions map
testClass.methods
.map { method ->
val annotation: Annotation? = method.getAnnotation(Test::class.java)
method to annotation
}
.filter { (_, annotation) ->
annotation != null
}
.map { (method, annotation) ->
val desc = Description.createTestDescription(testClass, method.name, annotation)
method to desc
}
.forEach { (method, desc) -> methodDescriptions[method] = desc }
}
override fun getDescription(): Description {
val description = Description.createSuiteDescription(
testClass.name, *testClass.annotations
)
methodDescriptions.values.forEach { description.addChild(it) }
return description
}
override fun run(notifier: RunNotifier?) {
val testObject = testClass.newInstance()
MockKAnnotations.init(testObject, relaxUnitFun = true)
methodDescriptions
.onEach { (_, _) -> clearAllMocks() }
.onEach { (_, desc) -> notifier!!.fireTestStarted(desc) }
.forEach { (method, desc) ->
try {
method.invoke(testObject)
} catch (e: Throwable) {
notifier!!.fireTestFailure(Failure(desc, e.cause))
} finally {
notifier!!.fireTestFinished(desc)
}
}
}
}