PixelDroid-App-Android/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt

443 lines
15 KiB
Kotlin

package org.pixeldroid.app.utils.api
import com.google.gson.*
import io.reactivex.rxjava3.core.Observable
import okhttp3.ConnectionSpec
import okhttp3.Interceptor
import org.pixeldroid.app.utils.api.objects.*
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import org.pixeldroid.app.searchDiscover.TrendingActivity
import org.pixeldroid.app.utils.api.objects.Collection
import org.pixeldroid.app.utils.api.objects.Tag
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.di.TokenAuthenticator
import org.pixeldroid.app.utils.typeAdapterInstantDeserializer
import org.pixeldroid.app.utils.typeAdapterInstantSerializer
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import retrofit2.http.Field
import java.time.Instant
import java.util.concurrent.TimeUnit
/*
Implements the Pixelfed API
https://docs.pixelfed.org/technical-documentation/api-v1
However, since this is mostly based on the Mastodon API, the documentation there
will be more useful: https://docs.joinmastodon.org/
*/
interface PixelfedAPI {
companion object {
val headerInterceptor = Interceptor { chain ->
val requestBuilder = chain.request().newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent", "PixelDroid")
chain.proceed(requestBuilder.build())
}
fun createFromUrl(baseUrl: String): PixelfedAPI {
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create(gSonInstance))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.client(
OkHttpClient().newBuilder().addNetworkInterceptor(headerInterceptor)
// Only do secure-ish TLS connections (no HTTP or very old SSL/TLS)
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
.readTimeout(20, TimeUnit.SECONDS)
.build()
)
.build().create(PixelfedAPI::class.java)
}
private val gSonInstance: Gson = GsonBuilder()
.registerTypeAdapter(Instant::class.java, typeAdapterInstantDeserializer)
.registerTypeAdapter(Instant::class.java, typeAdapterInstantSerializer)
.create()
fun apiForUser(
user: UserDatabaseEntity,
db: AppDatabase,
pixelfedAPIHolder: PixelfedAPIHolder
): PixelfedAPI =
Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create(gSonInstance))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.baseUrl(user.instance_uri)
.client(
OkHttpClient().newBuilder().addNetworkInterceptor(headerInterceptor)
// Only do secure-ish TLS connections (no HTTP or very old SSL/TLS)
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
.readTimeout(20, TimeUnit.SECONDS)
.authenticator(TokenAuthenticator(user, db, pixelfedAPIHolder))
.addInterceptor {
it.request().newBuilder().run {
header("Accept", "application/json")
header("Authorization", "Bearer ${user.accessToken}")
it.proceed(build())
}
}.build()
)
.build().create(PixelfedAPI::class.java)
}
@FormUrlEncoded
@POST("/api/v1/apps")
suspend fun registerApplication(
@Field("client_name") client_name: String,
@Field("redirect_uris") redirect_uris: String,
@Field("scopes") scopes: String? = null,
@Field("website") website: String? = null
): Application
@FormUrlEncoded
@POST("/oauth/token")
suspend fun obtainToken(
@Field("client_id") client_id: String,
@Field("client_secret") client_secret: String,
@Field("redirect_uri") redirect_uri: String? = null,
@Field("scope") scope: String? = "read",
@Field("code") code: String? = null,
@Field("grant_type") grant_type: String? = null,
@Field("refresh_token") refresh_token: String? = null
): Token
// get instance configuration
@GET("/api/v1/instance")
suspend fun instance() : Instance
/**
* Instance info from the Nodeinfo .well_known (https://nodeinfo.diaspora.software/protocol.html) endpoint
*/
@GET("/.well-known/nodeinfo")
suspend fun wellKnownNodeInfo() : NodeInfoJRD
/**
* Instance info from [NodeInfo] (https://nodeinfo.diaspora.software/schema.html) endpoint
*/
@GET
suspend fun nodeInfoSchema(
@Url nodeInfo_schema_url: String
) : NodeInfo
@FormUrlEncoded
@POST("/api/v1/accounts/{id}/follow")
suspend fun follow(
@Path("id") statusId: String,
@Field("reblogs") reblogs : Boolean = true
) : Relationship
@POST("/api/v1/accounts/{id}/unfollow")
suspend fun unfollow(
@Path("id") statusId: String,
) : Relationship
@POST("api/v1/statuses/{id}/favourite")
suspend fun likePost(
@Path("id") statusId: String
) : Status
@POST("/api/v1/statuses/{id}/unfavourite")
suspend fun unlikePost(
@Path("id") statusId: String
) : Status
//Used in our case to post a comment or a status
@FormUrlEncoded
@POST("/api/v1/statuses")
suspend fun postStatus(
@Field("status") statusText: String,
@Field("in_reply_to_id") in_reply_to_id: String? = null,
@Field("media_ids[]") media_ids: List<String> = emptyList(),
@Field("poll[options][]") poll_options: List<String>? = null,
@Field("poll[expires_in]") poll_expires: List<String>? = null,
@Field("poll[multiple]") poll_multiple: List<String>? = null,
@Field("poll[hide_totals]") poll_hideTotals: List<String>? = null,
//FIXME this should be able to take a boolean or at least "true"/"false" but only "0"/"1" works
@Field("sensitive") sensitive: Int? = null,
@Field("spoiler_text") spoiler_text: String? = null,
@Field("visibility") visibility: String = "public",
@Field("scheduled_at") scheduled_at: String? = null,
@Field("language") language: String? = null
) : Status
@DELETE("/api/v1/statuses/{id}")
suspend fun deleteStatus(
@Path("id") statusId: String
)
@FormUrlEncoded
@POST("/api/v1/statuses/{id}/reblog")
suspend fun reblogStatus(
@Path("id") statusId: String,
@Field("visibility") visibility: String? = null
) : Status
@POST("/api/v1/statuses/{id}/unreblog")
suspend fun undoReblogStatus(
@Path("id") statusId: String,
) : Status
@GET("/api/v1/bookmarks")
suspend fun bookmarks(
@Query("limit") limit: Number? = null,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null
) : List<Status>
@POST("/api/v1/statuses/{id}/bookmark")
suspend fun bookmarkStatus(
@Path("id") statusId: String
) : Status
@POST("/api/v1/statuses/{id}/unbookmark")
suspend fun undoBookmarkStatus(
@Path("id") statusId: String
) : Status
@GET("/api/v1.1/collections/accounts/{id}")
suspend fun accountCollections(
@Path("id") account_id: String? = null
): List<Collection>
@GET("/api/v1.1/collections/items/{id}")
suspend fun collectionItems(
@Path("id") id: String,
@Query("page") page: String? = null
): List<Status>
@DELETE("/api/v1.1/collections/delete/{id}")
suspend fun deleteCollection(
@Path("id") id: String,
)
@POST("/api/v1.1/collections/add")
suspend fun addToCollection(
@Query("collection_id") collection_id: String,
@Query("post_id") post_id: String,
): Status
@POST("/api/v1.1/collections/remove")
suspend fun removeFromCollection(
@Query("collection_id") collection_id: String,
@Query("post_id") post_id: String,
)
@GET("/api/pixelfed/v1/stories/self-carousel")
suspend fun carousel(): StoryCarousel
@POST("/api/v1.1/stories/seen")
suspend fun storySeen(
@Query("id") id: String
)
@POST("/api/v1.1/stories/comment")
suspend fun storyComment(
@Query("sid") sid: String,
@Query("caption") caption: String
)
@Multipart
@POST("/api/v1.1/stories/add")
fun storyUpload(
@Part file: MultipartBody.Part,
// The API takes this value but then overwrites it in /api/v1.1/stories/publish, so ignore this
@Part duration: MultipartBody.Part? = null,
): Observable<Attachment>
@POST("/api/v1.1/stories/publish")
suspend fun storyPublish(
@Query("media_id") media_id: String,
//From 0 to 30, duration in seconds of the story
@Query("duration") duration: Int = 10,
//FIXME this should be able to take a boolean or at least "true"/"false" but only "0"/"1" works. Same issue as sensitive boolean in postStatus
@Query("can_reply") can_reply: String,
@Query("can_react") can_react: String,
)
@POST("/api/v1.1/stories/self-expire/{id}")
suspend fun deleteCarousel(
@Path("id") storyId: String
)
//Used in our case to retrieve comments for a given status
@GET("/api/v1/statuses/{id}/context")
suspend fun statusComments(
@Path("id") statusId: String,
) : Context
@GET("/api/v1/timelines/public")
suspend fun timelinePublic(
@Query("local") local: Boolean? = null,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: String? = null
): List<Status>
@GET("/api/v1/timelines/home")
suspend fun timelineHome(
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: String? = null,
@Query("local") local: Boolean? = null
): List<Status>
@GET("/api/v1/timelines/tag/{hashtag}")
suspend fun hashtag(
@Path("hashtag") hashtag: String? = null,
@Query("local") local: Boolean? = null,
@Query("only_media") only_media: Boolean? = null,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: Int? = null,
): List<Status>
@GET("/api/v2/search")
suspend fun search(
@Query("account_id") account_id: String? = null,
@Query("max_id") max_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("type") type: Results.SearchType? = null,
@Query("exclude_unreviewed") exclude_unreviewed: Boolean? = null,
@Query("q") q: String,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: String? = null,
@Query("offset") offset: String? = null,
@Query("following") following: Boolean? = null
): Results
@GET("/api/v1/notifications")
suspend fun notifications(
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: String? = null,
@Query("exclude_types") exclude_types: List<String>? = null,
@Query("account_id") account_id: Boolean? = null
): List<Notification>
@GET("/api/v1/accounts/verify_credentials")
suspend fun verifyCredentials(
//The authorization header needs to be of the form "Bearer <token>"
@Header("Authorization") authorization: String? = null
): Account
@PATCH("/api/v1/accounts/update_credentials")
suspend fun updateCredentials(
@Query(value = "display_name") displayName: String?,
@Query(value = "note") note: String?,
@Query(value = "locked") locked: Boolean?,
): Account
/**
* Pixelfed uses PHP, multipart uploads don't work through PATCH so we use POST as suggested
* here: https://github.com/pixelfed/pixelfed/issues/4250
* However, changing to POST breaks the upload on Mastodon.
*
* To have this work on Pixelfed and Mastodon without special logic to distinguish the two,
* we'll have to wait for PHP 8.4 and https://wiki.php.net/rfc/rfc1867-non-post
* which should come out end of 2024
*/
@Multipart
@POST("/api/v1/accounts/update_credentials")
fun updateProfilePicture(
@Part avatar: MultipartBody.Part?
): Observable<Account>
@Multipart
@PATCH("/api/v1/accounts/update_credentials")
fun updateProfilePictureMastodon(
@Part avatar: MultipartBody.Part?
): Observable<Account>
@GET("/api/v1/accounts/{id}/statuses")
suspend fun accountPosts(
@Path("id") account_id: String,
@Query("min_id") min_id: String? = null,
@Query("max_id") max_id: String?,
@Query("limit") limit: Int
) : List<Status>
@GET("/api/v1/accounts/relationships")
suspend fun checkRelationships(
@Query("id[]") account_ids : List<String>
) : List<Relationship>
@GET("/api/v1/accounts/{id}/followers")
suspend fun followers(
@Path("id") account_id: String,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("limit") limit: Number? = null,
@Query("page") page: String? = null
) : Response<List<Account>>
@GET("/api/v1/accounts/{id}/following")
suspend fun following(
@Path("id") account_id: String,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("limit") limit: Number? = 40,
@Query("page") page: String? = null
) : Response<List<Account>>
@GET("/api/v1/accounts/{id}")
suspend fun getAccount(
@Path("id") accountId : String
): Account
@GET("/api/v1/statuses/{id}")
suspend fun getStatus(
@Path("id") accountId : String
): Status
@Multipart
@POST("/api/v1/media")
fun mediaUpload(
@Part description: MultipartBody.Part? = null,
@Part file: MultipartBody.Part
): Observable<Attachment>
// get discover
@GET("/api/v1/discover/posts")
suspend fun discover() : DiscoverPosts
@GET("/api/v1.1/discover/accounts/popular")
suspend fun popularAccounts() : List<Account>
@GET("/api/v1.1/discover/posts/trending")
suspend fun trendingPosts(
@Query("range") range: TrendingActivity.Companion.Range
) : List<Status>
@GET("/api/v1.1/discover/posts/hashtags")
suspend fun trendingHashtags() : List<Tag>
@FormUrlEncoded
@POST("/api/v1/reports")
@JvmSuppressWildcards
suspend fun report(
@Field("account_id") account_id: String,
@Field("status_ids") status_ids: List<Status>,
@Field("comment") comment: String,
@Field("forward") forward: Boolean = true
) : Report
}