Phase 2: network scaffold (bookshelf-api + ABS clients, DI, models)
This commit is contained in:
parent
ad821faecd
commit
2b63ff2bec
13 changed files with 592 additions and 0 deletions
|
|
@ -164,4 +164,19 @@ dependencies {
|
|||
|
||||
// Json
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
// Network (bookshelf-api + ABS)
|
||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Audio (Media3 / ExoPlayer)
|
||||
implementation("androidx.media3:media3-exoplayer:1.6.0")
|
||||
implementation("androidx.media3:media3-session:1.6.0")
|
||||
implementation("androidx.media3:media3-ui:1.6.0")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.6.0")
|
||||
|
||||
// Background work (cache, TTS sync)
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package org.dueattendant149.bookshelf.data.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds (and caches) a Retrofit-backed [AudiobookshelfApiService] for the configured
|
||||
* Audiobookshelf instance. All requests are authenticated with the provided bearer token.
|
||||
*/
|
||||
@Singleton
|
||||
class AudiobookshelfApiClientFactory
|
||||
@Inject
|
||||
constructor(
|
||||
private val json: Json,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private var cachedUrl: String? = null
|
||||
private var cachedToken: String? = null
|
||||
private var cachedClient: AudiobookshelfApiService? = null
|
||||
|
||||
@Synchronized
|
||||
fun provideClient(
|
||||
url: String,
|
||||
token: String,
|
||||
): AudiobookshelfApiService? {
|
||||
val fixedUrl = url.fixUriScheme()
|
||||
if (fixedUrl == cachedUrl && token == cachedToken && cachedClient != null) {
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
val authClient =
|
||||
okHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val request =
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
Retrofit
|
||||
.Builder()
|
||||
.client(authClient)
|
||||
.baseUrl(fixedUrl)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(AudiobookshelfApiService::class.java)
|
||||
}.onFailure {
|
||||
Log.e("AudiobookshelfApiFactory", "Failed to create client for $fixedUrl", it)
|
||||
}.getOrNull().also {
|
||||
cachedUrl = fixedUrl
|
||||
cachedToken = token
|
||||
cachedClient = it
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cachedUrl = null
|
||||
cachedToken = null
|
||||
cachedClient = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
/**
|
||||
* Direct Audiobookshelf API calls for audio files and covers.
|
||||
* bookshelf-api does not proxy audio streams, so the app talks to ABS directly.
|
||||
*/
|
||||
interface AudiobookshelfApiService {
|
||||
|
||||
@Streaming
|
||||
@GET("api/items/{itemId}/file/{fileId}")
|
||||
suspend fun downloadAudioFile(
|
||||
@Path("itemId") itemId: String,
|
||||
@Path("fileId") fileId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@Streaming
|
||||
@GET("api/items/{itemId}/download")
|
||||
suspend fun downloadBook(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@GET("api/items/{itemId}/cover")
|
||||
suspend fun getCover(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@GET("api/me/progress/{itemId}")
|
||||
suspend fun getProgress(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<String>
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds (and caches) a Retrofit-backed [BookshelfApiService] for the configured
|
||||
* bookshelf-api instance.
|
||||
*/
|
||||
@Singleton
|
||||
class BookshelfApiClientFactory
|
||||
@Inject
|
||||
constructor(
|
||||
private val json: Json,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private var cachedUrl: String? = null
|
||||
private var cachedClient: BookshelfApiService? = null
|
||||
|
||||
@Synchronized
|
||||
fun provideClient(url: String): BookshelfApiService? {
|
||||
val fixedUrl = url.fixUriScheme()
|
||||
if (fixedUrl == cachedUrl && cachedClient != null) {
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
Retrofit
|
||||
.Builder()
|
||||
.client(
|
||||
okHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.baseUrl(fixedUrl)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(BookshelfApiService::class.java)
|
||||
}.onFailure {
|
||||
Log.e("BookshelfApiClientFactory", "Failed to create BookshelfApiService for $fixedUrl", it)
|
||||
}.getOrNull().also {
|
||||
cachedUrl = fixedUrl
|
||||
cachedClient = it
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cachedUrl = null
|
||||
cachedClient = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
/**
|
||||
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
|
||||
* All endpoints are relative to the configured base URL.
|
||||
*/
|
||||
interface BookshelfApiService {
|
||||
|
||||
// Libraries / books
|
||||
@GET("api/v1/books/libraries")
|
||||
suspend fun getLibraries(): Response<List<LibraryResponse>>
|
||||
|
||||
@GET("api/v1/books/{itemId}")
|
||||
suspend fun getBook(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<String>
|
||||
|
||||
@GET("api/v1/books/library/{libraryId}/search")
|
||||
suspend fun searchLibrary(
|
||||
@Path("libraryId") libraryId: String,
|
||||
@Query("q") query: String,
|
||||
): Response<List<BookItemResponse>>
|
||||
|
||||
@POST("api/v1/search")
|
||||
suspend fun search(
|
||||
@Body request: SearchRequest,
|
||||
): Response<SearchResponse>
|
||||
|
||||
// TTS
|
||||
@GET("api/v1/tts/engines")
|
||||
suspend fun getTtsEngines(): Response<TtsEnginesResponse>
|
||||
|
||||
@GET("api/v1/tts/voices")
|
||||
suspend fun getTtsVoices(
|
||||
@Query("engine") engine: String? = null,
|
||||
): Response<TtsVoicesResponse>
|
||||
|
||||
@POST("api/v1/tts")
|
||||
suspend fun createTtsJob(
|
||||
@Body request: TtsCreateRequest,
|
||||
): Response<TtsJobResponse>
|
||||
|
||||
@GET("api/v1/tts/{jobId}")
|
||||
suspend fun getTtsJobStatus(
|
||||
@Path("jobId") jobId: String,
|
||||
): Response<TtsJobResponse>
|
||||
|
||||
@GET("api/v1/tts/jobs/list")
|
||||
suspend fun listTtsJobs(): Response<TtsJobsResponse>
|
||||
|
||||
@Streaming
|
||||
@GET("api/v1/tts/{jobId}/download")
|
||||
suspend fun downloadTtsAudio(
|
||||
@Path("jobId") jobId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
// Downloads / sources
|
||||
@POST("api/v1/download")
|
||||
suspend fun startDownload(
|
||||
@Body request: DownloadRequest,
|
||||
): Response<DownloadResponse>
|
||||
|
||||
@GET("api/v1/download/list")
|
||||
suspend fun listDownloads(): Response<DownloadsListResponse>
|
||||
|
||||
@POST("api/v1/podcasts")
|
||||
suspend fun downloadPodcast(
|
||||
@Body request: PodcastRequest,
|
||||
): Response<PodcastResponse>
|
||||
|
||||
@POST("api/v1/sources/yandex")
|
||||
suspend fun downloadYandex(
|
||||
@Body request: YandexRequest,
|
||||
): Response<YandexResponse>
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BookItemResponse(
|
||||
val id: String,
|
||||
val title: String = "",
|
||||
val author: String = "",
|
||||
@SerialName("media_type")
|
||||
val mediaType: String = "",
|
||||
@SerialName("library_id")
|
||||
val libraryId: String = "",
|
||||
@SerialName("cover_url")
|
||||
val coverUrl: String = "",
|
||||
val duration: Double = 0.0,
|
||||
val size: Long = 0L,
|
||||
)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DownloadRequest(
|
||||
val source: String = "",
|
||||
val title: String = "",
|
||||
val author: String = "",
|
||||
@SerialName("download_url")
|
||||
val downloadUrl: String? = null,
|
||||
@SerialName("magnet_url")
|
||||
val magnetUrl: String? = null,
|
||||
@SerialName("info_hash")
|
||||
val infoHash: String? = null,
|
||||
val md5: String? = null,
|
||||
val url: String? = null,
|
||||
@SerialName("media_type")
|
||||
val mediaType: String = "ebook",
|
||||
@SerialName("download_protocol")
|
||||
val downloadProtocol: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DownloadResponse(
|
||||
val success: Boolean = false,
|
||||
val error: String? = null,
|
||||
@SerialName("download_id")
|
||||
val downloadId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DownloadStatusResponse(
|
||||
val title: String = "",
|
||||
val status: String = "",
|
||||
val progress: Double = 0.0,
|
||||
val speed: String = "",
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DownloadsListResponse(
|
||||
val downloads: List<DownloadStatusResponse> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PodcastRequest(
|
||||
val url: String,
|
||||
val title: String? = null,
|
||||
val format: String = "m4a",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PodcastResponse(
|
||||
val url: String = "",
|
||||
val title: String = "",
|
||||
val tracks: Int = 0,
|
||||
val files: List<String> = emptyList(),
|
||||
@SerialName("abs_scan_triggered")
|
||||
val absScanTriggered: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class YandexRequest(
|
||||
val bookid: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class YandexResponse(
|
||||
val bookid: String,
|
||||
val files: List<String> = emptyList(),
|
||||
@SerialName("abs_scan_triggered")
|
||||
val absScanTriggered: Boolean = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LibraryResponse(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
@SerialName("media_type")
|
||||
val mediaType: String = "",
|
||||
@SerialName("item_count")
|
||||
val itemCount: Int = 0,
|
||||
)
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchResponse(
|
||||
val results: List<SearchResultItemResponse> = emptyList(),
|
||||
@SerialName("search_time_ms")
|
||||
val searchTimeMs: Int = 0,
|
||||
val total: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResultItemResponse(
|
||||
val source: String = "",
|
||||
val title: String = "",
|
||||
val author: String = "",
|
||||
val format: String = "",
|
||||
@SerialName("media_type")
|
||||
val mediaType: String = "",
|
||||
@SerialName("size_human")
|
||||
val sizeHuman: String = "",
|
||||
val seeders: Int? = null,
|
||||
val score: Double = 0.0,
|
||||
val guid: String = "",
|
||||
val md5: String = "",
|
||||
@SerialName("magnet_url")
|
||||
val magnetUrl: String = "",
|
||||
@SerialName("download_url")
|
||||
val downloadUrl: String = "",
|
||||
val url: String = "",
|
||||
@SerialName("cover_url")
|
||||
val coverUrl: String = "",
|
||||
@SerialName("info_hash")
|
||||
val infoHash: String = "",
|
||||
@SerialName("download_protocol")
|
||||
val downloadProtocol: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchRequest(
|
||||
val query: String,
|
||||
val author: String? = null,
|
||||
@SerialName("media_type")
|
||||
val mediaType: String? = null,
|
||||
val format: String? = null,
|
||||
val language: String? = null,
|
||||
@SerialName("year_from")
|
||||
val yearFrom: Int? = null,
|
||||
@SerialName("year_to")
|
||||
val yearTo: Int? = null,
|
||||
val limit: Int = 50,
|
||||
)
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TtsCreateRequest(
|
||||
@SerialName("book_id")
|
||||
val bookId: String,
|
||||
val engine: String = "silero",
|
||||
@SerialName("voice_id")
|
||||
val voiceId: String = "",
|
||||
val speed: Double = 1.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TtsJobResponse(
|
||||
@SerialName("job_id")
|
||||
val jobId: String,
|
||||
@SerialName("book_id")
|
||||
val bookId: String,
|
||||
val title: String = "",
|
||||
val author: String = "",
|
||||
val engine: String = "",
|
||||
@SerialName("voice_id")
|
||||
val voiceId: String = "",
|
||||
val speed: Double = 1.0,
|
||||
val status: String = "",
|
||||
val progress: Double = 0.0,
|
||||
@SerialName("current_chapter")
|
||||
val currentChapter: String = "",
|
||||
@SerialName("total_chapters")
|
||||
val totalChapters: Int = 0,
|
||||
@SerialName("completed_chapters")
|
||||
val completedChapters: Int = 0,
|
||||
@SerialName("output_path")
|
||||
val outputPath: String = "",
|
||||
val error: String = "",
|
||||
@SerialName("created_at")
|
||||
val createdAt: Double = 0.0,
|
||||
@SerialName("started_at")
|
||||
val startedAt: Double = 0.0,
|
||||
@SerialName("completed_at")
|
||||
val completedAt: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TtsJobsResponse(
|
||||
val jobs: List<TtsJobResponse> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TtsEnginesResponse(
|
||||
val engines: List<TtsEngineResponse> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TtsVoicesResponse(
|
||||
val voices: List<TtsVoiceResponse> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TtsEngineResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val capabilities: TtsEngineCapabilitiesResponse,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TtsEngineCapabilitiesResponse(
|
||||
@SerialName("supports_streaming")
|
||||
val supportsStreaming: Boolean = false,
|
||||
@SerialName("supports_cloning")
|
||||
val supportsCloning: Boolean = false,
|
||||
@SerialName("max_text_length")
|
||||
val maxTextLength: Int = 5000,
|
||||
@SerialName("needs_network")
|
||||
val needsNetwork: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TtsVoiceResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val language: String,
|
||||
val engine: String,
|
||||
val gender: String = "",
|
||||
val quality: String = "",
|
||||
@SerialName("requires_reference")
|
||||
val requiresReference: Boolean = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.dueattendant149.bookshelf.domain.util
|
||||
|
||||
/**
|
||||
* Ensures the URL ends with a trailing slash so Retrofit can append relative paths.
|
||||
* Also adds a default scheme if missing.
|
||||
*/
|
||||
fun String.fixUriScheme(): String {
|
||||
var url = this.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "https://$url"
|
||||
}
|
||||
if (!url.endsWith("/")) {
|
||||
url = "$url/"
|
||||
}
|
||||
return url
|
||||
}
|
||||
0
gradlew
vendored
Normal file → Executable file
0
gradlew
vendored
Normal file → Executable file
Loading…
Add table
Add a link
Reference in a new issue