Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
5ab05e5
fix(mobile): inconsistent asset details background (#26634)
uhthomas Mar 5, 2026
7b0deb1
fix: playback style migration (#26718)
alextran1502 Mar 5, 2026
9597f8c
feat(mobile): SyncAssetEditV1 (#26518)
bwees Mar 5, 2026
ec7246b
refactor(web): add --font-sans CSS variable for primary font (#26730)
midzelis Mar 6, 2026
abfcffb
feat(web): toggle zoom on double-click in photo viewer (#26732)
midzelis Mar 6, 2026
6012d22
fix(mobile): incorrect asset dimensions in search (#26725)
uhthomas Mar 6, 2026
6e9a425
fix(web): asset viewer showing wrong viewer type when hovering on sta…
Snowknight26 Mar 6, 2026
e73686b
feat(android): enhance playback style detection using MIME type, redu…
LeLunZ Mar 7, 2026
dd72ec2
fix(mobile): correct local asset dimensions (#26677)
uhthomas Mar 7, 2026
4a384bc
fix(server): opus handling as accepted audio codec in transcode polic…
skatsubo Mar 7, 2026
aaf34fa
feat(ml): enable openvino for cpu (#22948)
apejcic Mar 7, 2026
7a83baa
feat: responsive video duration in thumbnail (#26770)
midzelis Mar 8, 2026
422111d
test(e2e): fix flakiness: optimize resetDatabase with TRUNCATE and fi…
midzelis Mar 8, 2026
df0c869
fix(mobile): restrict trashed asset migration to Android platform (#2…
LeLunZ Mar 9, 2026
a47b232
fix(web): refresh recent albums sidebar after album changes (#26757)
michelheusschen Mar 9, 2026
4791d9c
fix(web): show the correct cursor at crop bounds when editing an asse…
Snowknight26 Mar 9, 2026
0edbca2
fix(web): recalculate face bounding boxes (#26737)
cratoo Mar 9, 2026
f272660
fix(web): context menu overflow (#26760)
SevereCloud Mar 9, 2026
d325231
chore: refactor test factories (#26804)
danieldietzler Mar 9, 2026
08c4594
chore: remove release-pr workflow (#26742)
bo0tzz Mar 9, 2026
8222781
fix(web): correct tag rounding in search options (#26814)
michelheusschen Mar 10, 2026
8e50d25
feat(web): animate zoom toggle with cubicOut easing (#26731)
midzelis Mar 10, 2026
f79c8cf
feat(mobile): consolidate video controls (#26673)
uhthomas Mar 10, 2026
56b8e1b
chore(deps): update docker.io/valkey/valkey:9 docker digest to 3eeb09…
renovate[bot] Mar 10, 2026
45eff1c
fix(web): prevent unrelated assets from appearing in tag view (#26816)
michelheusschen Mar 10, 2026
22b43bf
chore(deps): update dependency @types/node to ^24.11.0 (#26808)
renovate[bot] Mar 10, 2026
1a4c5d7
feat(web): add shortcut "p" to open/close the face tag box (#26826)
cratoo Mar 10, 2026
1ceb6d2
fix(mobile): use tabular figures in backup page (#26830)
uhthomas Mar 11, 2026
4571940
fix(mobile): wrap backup error message text (#26834)
uhthomas Mar 11, 2026
9fc32b6
feat(mobile): use material design 3 slider (#26829)
uhthomas Mar 11, 2026
9fc6fbc
fix(web): restore asset update events in asset viewer (#26845)
michelheusschen Mar 11, 2026
27f69b3
fix(server): use correct day ordering in timeline buckets (#26821)
michelheusschen Mar 11, 2026
8764a18
feat: adaptive progressive image loading for photo viewer (#26636)
midzelis Mar 11, 2026
34ce680
chore: upgrade to kysely 0.28.11 (#26744)
danieldietzler Mar 11, 2026
0f2fe65
fix(deps): update typescript-projects (#26812)
renovate[bot] Mar 11, 2026
28d5c16
chore: use pokedex-large runner for rocm (#26823)
bo0tzz Mar 11, 2026
e7db3b2
feat(mobile): show animated images in asset viewer (#26614)
LeLunZ Mar 11, 2026
c403e03
fix(mobile): logout on upgrade (#26827)
mertalev Mar 11, 2026
e45308b
fix(web): exclude emoji from translation string (#26852)
meesfrensel Mar 11, 2026
0a79dd1
fix(server): extract make/model from sony video files (#26833)
brendanngo Mar 11, 2026
9996ee1
refactor(web): crop area tool (#26843)
meesfrensel Mar 11, 2026
0ac3d6a
fix(web): face selection box position resetting on browser resize (#2…
Snowknight26 Mar 11, 2026
d49d995
chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813)
renovate[bot] Mar 11, 2026
4773788
chore: more unused release workflow cleanup (#26817)
bo0tzz Mar 11, 2026
471c27c
chore(mobile): remove background from asset viewer back button (#26851)
uhthomas Mar 11, 2026
6c531e0
chore: add shadow to video play/pause icon shadow (#26836)
alextran1502 Mar 11, 2026
5c3777a
fix(web): fix zoom touch event handling (#26866)
midzelis Mar 12, 2026
3bd37eb
refactor: clean class (#26879)
jrasm91 Mar 12, 2026
d4605b2
refactor: external links (#26880)
jrasm91 Mar 12, 2026
6bb8f4f
refactor: clean class (#26885)
jrasm91 Mar 12, 2026
3fd24e2
fix(server): restrict individual shared link asset removal to owners …
michelheusschen Mar 12, 2026
001d7d0
refactor: small test factories (#26862)
danieldietzler Mar 12, 2026
990aff4
fix: add to shared link (#26886)
jrasm91 Mar 12, 2026
f3b7cd6
refactor: move encoded video to asset files table (#26863)
bwees Mar 12, 2026
c91d874
fix: use correct original URL for 360 video panorama playback (#26831)
luis15pt Mar 12, 2026
754f072
fix(web): disable drag and drop for internal items (#26897)
michelheusschen Mar 13, 2026
226b939
fix(mobile): video auth (#26887)
mertalev Mar 13, 2026
c2a279e
fix(web): keep header fixed on individual shared links (#26892)
michelheusschen Mar 13, 2026
e322d44
fix: SMTP over TLS (#26893)
nathanielhourt Mar 13, 2026
10fa928
feat: require pull requests to follow template (#26902)
bo0tzz Mar 13, 2026
6e6fb54
Merge remote-tracking branch 'upstream/main' into worktree-upstream-m…
Deeds67 Mar 13, 2026
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
80 changes: 80 additions & 0 deletions .github/workflows/check-pr-template.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Check PR Template

on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]

permissions: {}

jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false

- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(grep "^## " .github/pull_request_template.md)
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"

act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

- name: Reopen PR (sections now present, PR closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
2 changes: 2 additions & 0 deletions mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,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
Loading
Loading