Data layer and baseline profile
This commit is contained in:
@@ -17,6 +17,7 @@ android {
|
||||
dependencies {
|
||||
api(libs.jellyfin.sdk)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
api(libs.okhttp)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package ru.stenfredd.movies.core.jellyfin
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
|
||||
data class Account(
|
||||
val serverId: String,
|
||||
val serverName: String,
|
||||
/** Address the user entered (usually the public HTTPS name); API calls are addressed to it. */
|
||||
val serverUrl: String,
|
||||
val userId: UUID,
|
||||
val userName: String,
|
||||
val accessToken: String,
|
||||
/** Direct LAN address of the same server found last time, see [LocalServerDiscovery]; tried first on the next start. */
|
||||
val localUrl: String? = null,
|
||||
val lastUsedAt: Long = 0,
|
||||
) {
|
||||
val id: String get() = accountId(serverId, userId)
|
||||
|
||||
internal fun toJson(): JSONObject = JSONObject()
|
||||
.put("serverId", serverId)
|
||||
.put("serverName", serverName)
|
||||
.put("serverUrl", serverUrl)
|
||||
.put("userId", userId.toString())
|
||||
.put("userName", userName)
|
||||
.put("token", accessToken)
|
||||
.put("localUrl", localUrl ?: JSONObject.NULL)
|
||||
.put("lastUsedAt", lastUsedAt)
|
||||
|
||||
internal companion object {
|
||||
fun fromJson(o: JSONObject) = Account(
|
||||
serverId = o.getString("serverId"),
|
||||
serverName = o.optString("serverName"),
|
||||
serverUrl = o.getString("serverUrl"),
|
||||
userId = UUID.fromString(o.getString("userId")),
|
||||
userName = o.optString("userName"),
|
||||
accessToken = o.getString("token"),
|
||||
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" },
|
||||
lastUsedAt = o.optLong("lastUsedAt"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
|
||||
|
||||
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
|
||||
|
||||
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
|
||||
internal class AccountStore(private val context: Context) {
|
||||
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
|
||||
|
||||
suspend fun read(): Saved {
|
||||
val prefs = context.sessionStore.data.first()
|
||||
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
|
||||
}
|
||||
|
||||
suspend fun setDeviceId(id: String) {
|
||||
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
||||
}
|
||||
|
||||
/** Applies [change] to the saved list and sets the current account; returns the new list. */
|
||||
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
|
||||
var result = emptyList<Account>()
|
||||
context.sessionStore.edit { prefs ->
|
||||
result = change(parse(prefs))
|
||||
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString()
|
||||
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The single session saved before accounts existed (prototype); null when there is none. */
|
||||
suspend fun legacySession(): LegacySession? {
|
||||
val prefs = context.sessionStore.data.first()
|
||||
return LegacySession(
|
||||
url = prefs[LEGACY_URL] ?: return null,
|
||||
token = prefs[LEGACY_TOKEN] ?: return null,
|
||||
userId = prefs[LEGACY_USER_ID]?.let(UUID::fromString) ?: return null,
|
||||
userName = prefs[LEGACY_USER_NAME].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun dropLegacySession() {
|
||||
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
|
||||
}
|
||||
|
||||
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
|
||||
|
||||
private fun parse(prefs: Preferences): List<Account> {
|
||||
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
|
||||
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
|
||||
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_ACCOUNTS = stringPreferencesKey("accounts")
|
||||
val KEY_CURRENT = stringPreferencesKey("current_account")
|
||||
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
||||
val LEGACY_URL = stringPreferencesKey("server_url")
|
||||
val LEGACY_TOKEN = stringPreferencesKey("access_token")
|
||||
val LEGACY_USER_ID = stringPreferencesKey("user_id")
|
||||
val LEGACY_USER_NAME = stringPreferencesKey("user_name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ru.stenfredd.movies.core.jellyfin
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting]. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object HttpModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun serverRouting(): ServerRouting = ServerRouting()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpClient(routing: ServerRouting): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(routing)
|
||||
.build()
|
||||
}
|
||||
@@ -3,22 +3,26 @@ package ru.stenfredd.movies.core.jellyfin
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
@@ -27,95 +31,133 @@ import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
|
||||
|
||||
data class SessionInfo(
|
||||
val accountId: String,
|
||||
val serverId: String,
|
||||
val serverUrl: String,
|
||||
val userId: UUID,
|
||||
val userName: String,
|
||||
val accessToken: String,
|
||||
)
|
||||
|
||||
/** Holds the single authenticated [ApiClient]; persists the session across restarts. */
|
||||
/**
|
||||
* Holds the authenticated [ApiClient] of the current account and the accounts saved on the device.
|
||||
*
|
||||
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
|
||||
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
|
||||
*/
|
||||
@Singleton
|
||||
class JellyfinSession @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
httpClient: OkHttpClient,
|
||||
private val routing: ServerRouting,
|
||||
) {
|
||||
private val store = AccountStore(context)
|
||||
private val jellyfin = createJellyfin {
|
||||
context = this@JellyfinSession.context
|
||||
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
|
||||
apiClientFactory = OkHttpFactory(httpClient)
|
||||
}
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _session = MutableStateFlow<SessionInfo?>(null)
|
||||
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
|
||||
|
||||
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
|
||||
/** Saved accounts, most recently used first. */
|
||||
val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
|
||||
|
||||
private val _userDataChanges = MutableSharedFlow<UUID>(extraBufferCapacity = 16)
|
||||
/** Items whose watch state, progress or favourite flag just changed on the server (playback stopped, marked …). */
|
||||
val userDataChanges: SharedFlow<UUID> = _userDataChanges.asSharedFlow()
|
||||
|
||||
lateinit var api: ApiClient
|
||||
private set
|
||||
|
||||
val deviceId: String get() = api.deviceInfo.id
|
||||
|
||||
/** Direct LAN address of the same server for media streams, see [LocalServerDiscovery]; null away from home. */
|
||||
@Volatile
|
||||
private var localUrl: String? = null
|
||||
@Volatile
|
||||
private var localCheckedAt = 0L
|
||||
private val discoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
routing.onLocalFailed = {
|
||||
localCheckedAt = 0
|
||||
refreshLocalAddress()
|
||||
}
|
||||
}
|
||||
|
||||
/** Direct LAN address of the current server, or null (away from home, or not found yet). */
|
||||
val localUrl: String? get() = routing.localUrl
|
||||
|
||||
/**
|
||||
* Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]); playback uses
|
||||
* whatever is known at that moment and never waits for it.
|
||||
* Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]). Requests use
|
||||
* whatever is known at that moment and never wait for it; the address found is remembered for the next start.
|
||||
*/
|
||||
fun refreshLocalAddress() {
|
||||
val s = _session.value ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
|
||||
localCheckedAt = now
|
||||
discoveryScope.launch {
|
||||
val id = LocalServerDiscovery.serverId(s.serverUrl)
|
||||
val found = id?.let { LocalServerDiscovery.find(it) }
|
||||
if (found != localUrl) Timber.i("Media streams: %s", found ?: "через ${s.serverUrl}")
|
||||
localUrl = found
|
||||
backgroundScope.launch {
|
||||
val found = LocalServerDiscovery.find(s.serverId)
|
||||
if (_session.value?.accountId != s.accountId) return@launch
|
||||
if (found != routing.localUrl) Timber.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
|
||||
routing.set(s.serverUrl, found)
|
||||
mutex.withLock {
|
||||
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
|
||||
_accounts.value = store.update(s.accountId) { list -> list.map { if (it.id == s.accountId) it.copy(localUrl = found) else it } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores the saved session. Returns false when the user has to log in. */
|
||||
suspend fun restore(): Boolean {
|
||||
val prefs = context.sessionStore.data.first()
|
||||
api = jellyfin.createApi(deviceInfo = deviceInfo(prefs[KEY_DEVICE_ID] ?: newDeviceId()))
|
||||
val url = prefs[KEY_URL] ?: return false
|
||||
val token = prefs[KEY_TOKEN] ?: return false
|
||||
val userId = prefs[KEY_USER_ID]?.let(UUID::fromString) ?: return false
|
||||
api.update(baseUrl = url, accessToken = token)
|
||||
_session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token)
|
||||
refreshLocalAddress()
|
||||
return true
|
||||
/** Restores the current account. Returns false when the user has to log in. */
|
||||
suspend fun restore(): Boolean = mutex.withLock {
|
||||
val saved = store.read()
|
||||
api = jellyfin.createApi(deviceInfo = deviceInfo(saved.deviceId ?: newDeviceId()))
|
||||
var accounts = saved.accounts
|
||||
var currentId = saved.currentId
|
||||
if (accounts.isEmpty()) migrateLegacy()?.let { account ->
|
||||
accounts = listOf(account)
|
||||
currentId = account.id
|
||||
}
|
||||
_accounts.value = accounts.sortedByDescending { it.lastUsedAt }
|
||||
val current = accounts.firstOrNull { it.id == currentId } ?: return false
|
||||
activate(current)
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun login(serverUrl: String, userName: String, password: String) {
|
||||
val url = serverUrl.trim().trimEnd('/')
|
||||
api.update(baseUrl = url, accessToken = null)
|
||||
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
|
||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||
api.update(accessToken = token)
|
||||
context.sessionStore.edit {
|
||||
it[KEY_URL] = url
|
||||
it[KEY_TOKEN] = token
|
||||
it[KEY_USER_ID] = user.id.toString()
|
||||
it[KEY_USER_NAME] = user.name.orEmpty()
|
||||
mutex.withLock {
|
||||
routing.set(null, null)
|
||||
api.update(baseUrl = url, accessToken = null)
|
||||
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
|
||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||
api.update(accessToken = token)
|
||||
val serverId = result.serverId ?: user.serverId ?: url
|
||||
val serverName = runCatching { call { systemApi.getPublicSystemInfo().content.serverName } }.getOrNull().orEmpty()
|
||||
val account = Account(serverId, serverName, url, user.id, user.name.orEmpty(), token, lastUsedAt = System.currentTimeMillis())
|
||||
save(account)
|
||||
activate(account)
|
||||
}
|
||||
_session.value = SessionInfo(url, user.id, user.name.orEmpty(), token)
|
||||
localUrl = null
|
||||
localCheckedAt = 0
|
||||
refreshLocalAddress()
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
context.sessionStore.edit {
|
||||
it.remove(KEY_TOKEN)
|
||||
it.remove(KEY_USER_ID)
|
||||
}
|
||||
/** Makes a saved account current (profile switch); its token is reused, no password needed. */
|
||||
suspend fun switchTo(accountId: String) = mutex.withLock {
|
||||
val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis())
|
||||
save(account)
|
||||
activate(account)
|
||||
}
|
||||
|
||||
/** Signs out of the current account and forgets it on this device. */
|
||||
suspend fun logout() = mutex.withLock {
|
||||
val current = _session.value ?: return@withLock
|
||||
_accounts.value = store.update(currentId = null) { list -> list.filterNot { it.id == current.accountId } }
|
||||
api.update(accessToken = null)
|
||||
routing.set(null, null)
|
||||
_session.value = null
|
||||
}
|
||||
|
||||
@@ -124,13 +166,19 @@ class JellyfinSession @Inject constructor(
|
||||
|
||||
fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" }
|
||||
|
||||
/** Tells screens that [itemId]'s user data changed on the server, so cached lists (resume, next up …) reload. */
|
||||
fun notifyUserDataChanged(itemId: UUID) {
|
||||
_userDataChanges.tryEmit(itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL for a server-relative path or a URL on the server, with the token as `ApiKey` (engines can't send
|
||||
* headers). Media goes to the LAN address when one was found; API calls keep using [SessionInfo.serverUrl].
|
||||
* headers). Media goes to the LAN address when one is known; engines use their own HTTP stacks, so the address is
|
||||
* substituted here rather than by [ServerRouting].
|
||||
*/
|
||||
fun authorizedUrl(pathOrUrl: String): String {
|
||||
val s = requireSession()
|
||||
val base = localUrl ?: s.serverUrl
|
||||
val base = routing.localUrl ?: s.serverUrl
|
||||
val absolute = when {
|
||||
pathOrUrl.startsWith(s.serverUrl) -> base + pathOrUrl.removePrefix(s.serverUrl)
|
||||
pathOrUrl.startsWith("http") -> pathOrUrl
|
||||
@@ -141,9 +189,34 @@ class JellyfinSession @Inject constructor(
|
||||
return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}"
|
||||
}
|
||||
|
||||
private fun activate(account: Account) {
|
||||
api.update(baseUrl = account.serverUrl, accessToken = account.accessToken)
|
||||
// The LAN address from last time is used right away; a wrong one costs one fast failed connect.
|
||||
routing.set(account.serverUrl, account.localUrl)
|
||||
_session.value = SessionInfo(account.id, account.serverId, account.serverUrl, account.userId, account.userName, account.accessToken)
|
||||
localCheckedAt = 0
|
||||
refreshLocalAddress()
|
||||
}
|
||||
|
||||
private suspend fun save(account: Account) {
|
||||
_accounts.value = store.update(account.id) { list -> (listOf(account) + list.filterNot { it.id == account.id }) }
|
||||
.sortedByDescending { it.lastUsedAt }
|
||||
}
|
||||
|
||||
/** The prototype kept one session in separate keys; turn it into the first saved account. */
|
||||
private suspend fun migrateLegacy(): Account? {
|
||||
val legacy = store.legacySession() ?: return null
|
||||
val serverId = LocalServerDiscovery.serverId(legacy.url) ?: legacy.url
|
||||
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
|
||||
store.update(account.id) { listOf(account) }
|
||||
store.dropLegacySession()
|
||||
Timber.i("Migrated the saved session to account %s", account.id)
|
||||
return account
|
||||
}
|
||||
|
||||
private suspend fun newDeviceId(): String {
|
||||
val id = UUID.randomUUID().toString()
|
||||
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
||||
store.setDeviceId(id)
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -152,11 +225,6 @@ class JellyfinSession @Inject constructor(
|
||||
private companion object {
|
||||
const val CLIENT_NAME = "Movies (prototype)"
|
||||
const val CLIENT_VERSION = "0.1.0"
|
||||
val KEY_URL = stringPreferencesKey("server_url")
|
||||
val KEY_TOKEN = stringPreferencesKey("access_token")
|
||||
val KEY_USER_ID = stringPreferencesKey("user_id")
|
||||
val KEY_USER_NAME = stringPreferencesKey("user_name")
|
||||
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
||||
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package ru.stenfredd.movies.core.jellyfin
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Sends requests for the server's public address to its LAN address when one is known.
|
||||
*
|
||||
* API calls and images are built on the public address (it is also the image cache key, so the cache stays valid
|
||||
* at home and away); at home this interceptor swaps the origin to the plain-HTTP LAN address, which skips the router
|
||||
* hairpin, the reverse proxy and TLS on the TV's weak CPU. If the LAN address can't be connected to, the request goes
|
||||
* to the public address and [onLocalFailed] lets the session look for the server again.
|
||||
*/
|
||||
class ServerRouting internal constructor() : Interceptor {
|
||||
private class Route(val public: HttpUrl, val local: HttpUrl)
|
||||
|
||||
@Volatile
|
||||
private var route: Route? = null
|
||||
|
||||
internal var onLocalFailed: (() -> Unit)? = null
|
||||
|
||||
val localUrl: String? get() = route?.local?.toString()?.trimEnd('/')
|
||||
|
||||
internal fun set(publicUrl: String?, localUrl: String?) {
|
||||
val public = publicUrl?.toHttpUrlOrNull()
|
||||
val local = localUrl?.toHttpUrlOrNull()
|
||||
route = if (public != null && local != null) Route(public, local) else null
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val r = route ?: return chain.proceed(request)
|
||||
val local = rewrite(request.url, r) ?: return chain.proceed(request)
|
||||
return try {
|
||||
// A LAN address that isn't there (away from home) must fail fast, not after the default 10 s.
|
||||
chain.withConnectTimeout(LOCAL_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS).proceed(request.newBuilder().url(local).build())
|
||||
} catch (e: IOException) {
|
||||
// Only failures to connect are safe to repeat: the request never reached the server.
|
||||
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
|
||||
Timber.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
|
||||
if (route === r) route = null
|
||||
onLocalFailed?.invoke()
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewrite(url: HttpUrl, r: Route): HttpUrl? {
|
||||
if (url.host != r.public.host || url.port != r.public.port || url.scheme != r.public.scheme) return null
|
||||
val prefix = r.public.encodedPath.trimEnd('/')
|
||||
val path = url.encodedPath
|
||||
if (!path.startsWith(prefix)) return null
|
||||
val tail = path.removePrefix(prefix)
|
||||
val base = r.local.toString().trimEnd('/')
|
||||
val query = url.encodedQuery?.let { "?$it" }.orEmpty()
|
||||
return "$base$tail$query".toHttpUrlOrNull()
|
||||
}
|
||||
|
||||
private fun IOException.isConnectFailure() = this is ConnectException || this is NoRouteToHostException ||
|
||||
this is UnknownHostException || (this is SocketTimeoutException && message?.contains("connect", ignoreCase = true) == true)
|
||||
|
||||
private companion object {
|
||||
const val LOCAL_CONNECT_TIMEOUT_MS = 1500
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user