fix(mobile): video auth (#26887)

* fix video auth

* update commit
This commit is contained in:
Mert
2026-03-13 09:38:21 -05:00
committed by GitHub
parent 754f072ef9
commit 226b9390db
7 changed files with 90 additions and 53 deletions

View File

@@ -113,6 +113,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0' implementation 'org.chromium.net:cronet-embedded:143.7445.0'
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version"

View File

@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImageApi
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
companion object { companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx) HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin()) flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger val messenger = flutterEngine.dartExecutor.binaryMessenger

View File

@@ -3,7 +3,13 @@ package app.alextran.immich.core
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.security.KeyChain import android.security.KeyChain
import androidx.annotation.OptIn
import androidx.core.content.edit import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import app.alextran.immich.BuildConfig import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import okhttp3.Cache import okhttp3.Cache
@@ -16,6 +22,7 @@ import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@@ -25,6 +32,8 @@ import java.security.KeyStore
import java.security.Principal import java.security.Principal
import java.security.PrivateKey import java.security.PrivateKey
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
@@ -56,6 +65,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
*/ */
object HttpClientManager { object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
private const val KEEP_ALIVE_CONNECTIONS = 10 private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64 private const val MAX_REQUESTS_PER_HOST = 64
@@ -67,6 +77,11 @@ object HttpClientManager {
private lateinit var appContext: Context private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
var cronetEngine: CronetEngine? = null
private set
private lateinit var cronetStorageDir: File
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var keyChainAlias: String? = null var keyChainAlias: String? = null
@@ -107,6 +122,10 @@ object HttpClientManager {
val cacheDir = File(File(context.cacheDir, "okhttp"), "api") val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir) client = build(cacheDir)
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()
initialized = true initialized = true
} }
} }
@@ -223,6 +242,53 @@ object HttpClientManager {
?.joinToString("; ") { "${it.name}=${it.value}" } ?.joinToString("; ") { "${it.name}=${it.value}" }
} }
fun getAuthHeaders(url: String): Map<String, String> {
val result = mutableMapOf<String, String>()
headers.forEach { (key, value) -> result[key] = value }
loadCookieHeader(url)?.let { result["Cookie"] = it }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
}
}
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
}
val cronetStoragePath: File get() = cronetStorageDir
@OptIn(UnstableApi::class)
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
return if (isMtls) {
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
} else {
ResolvingDataSource.Factory(
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
) { dataSpec ->
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
newHeaders["Cache-Control"] = "no-store"
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
}
}
}
private fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(cronetStorageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
.build()
}
private fun build(cacheDir: File): OkHttpClient { private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool( val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS, maxIdleConnections = KEEP_ALIVE_CONNECTIONS,

View File

@@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.Cache import okhttp3.Cache
import okhttp3.Call import okhttp3.Call
@@ -15,9 +14,6 @@ import okhttp3.Callback
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException import org.chromium.net.CronetException
import org.chromium.net.UrlRequest import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo import org.chromium.net.UrlResponseInfo
@@ -31,10 +27,6 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal) private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
} }
private object ImageFetcherManager { private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher private lateinit var fetcher: ImageFetcher
private var initialized = false private var initialized = false
@@ -110,7 +101,6 @@ private object ImageFetcherManager {
if (initialized) return if (initialized) return
synchronized(this) { synchronized(this) {
if (initialized) return if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir cacheDir = context.cacheDir
fetcher = build() fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate) HttpClientManager.addClientChangedListener(::invalidate)
@@ -143,7 +133,7 @@ private object ImageFetcherManager {
return if (HttpClientManager.isMtls) { return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir) OkHttpImageFetcher.create(cacheDir)
} else { } else {
CronetImageFetcher(appContext, cacheDir) CronetImageFetcher()
} }
} }
} }
@@ -161,19 +151,11 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit) fun clearCache(onCleared: (Result<Long>) -> Unit)
} }
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { private class CronetImageFetcher : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private val stateLock = Any() private val stateLock = Any()
private var activeCount = 0 private var activeCount = 0
private var draining = false private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
init {
engine = build(context)
}
override fun fetch( override fun fetch(
url: String, url: String,
@@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
} }
val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) val requestBuilder = HttpClientManager.cronetEngine!!
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } .newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) } HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
url.toHttpUrlOrNull()?.let { httpUrl -> requestBuilder.addHeader(key, value)
if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
} }
val request = requestBuilder.build() val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel) signal.setOnCancelListener(request::cancel)
request.start() request.start()
} }
private fun build(ctx: Context): CronetEngine {
return CronetEngine.Builder(ctx)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(storageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
.build()
}
private fun onComplete() { private fun onComplete() {
val didDrain = synchronized(stateLock) { val didDrain = synchronized(stateLock) {
activeCount-- activeCount--
@@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
} }
private fun onDrained() { private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) { val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared val onCacheCleared = onCacheCleared
this.onCacheCleared = null this.onCacheCleared = null
onCacheCleared onCacheCleared
} }
if (onCacheCleared == null) { if (onCacheCleared != null) {
executor.shutdown() val oldEngine = HttpClientManager.rebuildCronetEngine()
} else { oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) } val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
engine = build(ctx)
synchronized(stateLock) { draining = false } synchronized(stateLock) { draining = false }
onCacheCleared(result) onCacheCleared(result)
} }
@@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor(
val dir = File(cacheDir, "okhttp") val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder() val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) .cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.build() .build()
return OkHttpImageFetcher(client) return OkHttpImageFetcher(client)

View File

@@ -1,5 +1,6 @@
import BackgroundTasks import BackgroundTasks
import Flutter import Flutter
import native_video_player
import network_info_plus import network_info_plus
import path_provider_foundation import path_provider_foundation
import permission_handler_apple import permission_handler_apple
@@ -18,6 +19,7 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
} }
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller) AppDelegate.registerPlugins(with: controller.engine, controller: controller)

View File

@@ -1194,10 +1194,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1218,8 +1218,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
thumbhash: thumbhash:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -56,7 +56,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2' ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi: