Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
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 "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
Expand All @@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())

val messenger = flutterEngine.dartExecutor.binaryMessenger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ package app.alextran.immich.core
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.annotation.OptIn
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.NativeBuffer
import okhttp3.Cache
Expand All @@ -16,6 +22,7 @@ import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
Expand All @@ -25,6 +32,8 @@ import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
Expand Down Expand Up @@ -56,6 +65,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
*/
object HttpClientManager {
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_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
Expand All @@ -67,6 +77,11 @@ object HttpClientManager {
private lateinit var appContext: Context
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) }

var keyChainAlias: String? = null
Expand Down Expand Up @@ -107,6 +122,10 @@ object HttpClientManager {

val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)

cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()

initialized = true
}
}
Expand Down Expand Up @@ -223,6 +242,53 @@ object HttpClientManager {
?.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 {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,13 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
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.UrlRequest
import org.chromium.net.UrlResponseInfo
Expand All @@ -31,10 +27,6 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
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)

Expand Down Expand Up @@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}

private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
Expand All @@ -110,7 +101,6 @@ private object ImageFetcherManager {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate)
Expand Down Expand Up @@ -143,7 +133,7 @@ private object ImageFetcherManager {
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
CronetImageFetcher(appContext, cacheDir)
CronetImageFetcher()
}
}
}
Expand All @@ -161,19 +151,11 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}

private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private class CronetImageFetcher : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }

init {
engine = build(context)
}

override fun fetch(
url: String,
Expand All @@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}

val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
val requestBuilder = HttpClientManager.cronetEngine!!
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
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() {
val didDrain = synchronized(stateLock) {
activeCount--
Expand All @@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}

private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared == null) {
executor.shutdown()
} else {
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
engine = build(ctx)
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
Expand Down Expand Up @@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor(
val dir = File(cacheDir, "okhttp")

val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.build()

return OkHttpImageFetcher(client)
Expand Down
2 changes: 2 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import BackgroundTasks
import Flutter
import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
Expand All @@ -18,6 +19,7 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}

SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
Expand Down
12 changes: 6 additions & 6 deletions mobile/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1194,10 +1194,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
Expand All @@ -1218,8 +1218,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
Expand Down Expand Up @@ -1897,10 +1897,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:
Expand Down
2 changes: 1 addition & 1 deletion mobile/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.3
octo_image: ^2.1.0
openapi:
Expand Down
Loading